SwiftUI's new onSubmit modifier

SwiftUI
3 August 2021

This year's SwiftUI flagship feature is no doubt the ability to direct and reflect focus.

As part of this great and heavily requested enhancement, views such as TextField had to adapt their APIs. Let's dive in.

The shift

Prior to this year's WWDC, TextField initializers followed this pattern:

init(
  _ titleKey: LocalizedStringKey, 
  text: Binding<String>, 
  onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
  onCommit: @escaping () -> Void = {}
)

Where:

  • the title was used both as a description and as a placeholder for the view
  • text is the main TextField value
  • onEditingChanged was called when the TextField gained or lost the focus (the Bool parameter was true when the field gained the focus, false when it lost the focus)
  • onCommit triggered when the user submitted (e.g., hit the Return key) while on focus on this specific field

New this year, all former initializers have been deprecated and replaced by new ones:

init(
  _ titleKey: LocalizedStringKey, 
  text: Binding<String>, 
  prompt: Text? = nil
)

Where:

  • the title describes the view
  • the text is unchanged
  • the prompt is used as the field placeholder

To summarize, we have...

  • ...gained one parameter, prompt, to disambiguate between the view description and placeholder
  • ...lost two parameters, onEditingChanged has been replaced with the new focus API, onCommit has been replaced by the new onSubmit modifier

Let's explore onSubmit next.

As of Xcode 13b4, the new focus APIs are not yet ready (FB9432618), and the TextField documentation still uses the deprecated initializers (FB9438780).

onSubmit definition

For an overview on the new onSubmit modifier and more, check out Submitting values to SwiftUI view by Majid Jabrayilov.

There's only one definition for this modifier:

public func onSubmit(
  of triggers: SubmitTriggers = .text, 
  _ action: @escaping (() -> Void)
) -> some View

The optional triggers parameter lets us define what triggers the given closure:
the current options are either .text (for TextFields and SecureFields) or .search for search submissions via the new searchable(text:placement:) modifier.

// Triggers when text fields submit:

Form {
  TextField(...)
  TextField(...)
}
.onSubmit {
  print("Form submitted")
}

// Triggers when search submits:

NavigationView {
  List {
    ...
  }
  .searchable(text: $text)
  .onSubmit(of: .search) {
    print("Search submitted")
  }
}

SubmitTriggers is an option set: meaning that, theoretically, we could have a single, "catch-all" onSubmit modifier:

.onSubmit(of: [.text, .search]) {
  print("Something has been submitted")
}

Unless we have an unique situation where this is needed, it's preferred to separate the two cases with two different submission closures:

NavigationView {
  Form {
    TextField(...)
    TextField(...)
  }
  .onSubmit {
    print("Form submitted")
  }
  .searchable(text: $text)
  .onSubmit(of: .search) {
    print("Search submitted")
  }
}

onSubmit environment

Behind the scenes, onSubmit adds a TriggerSubmitAction value into the environment. Understanding this is very important: the position and location of the view modifier are fundamental.

For example, this search will trigger the onSubmit closure:

FSView()
 .searchable(text: $text)
 .onSubmit(of: .search) {
   print("Search submitted")
 }

While this one won't:

FSView()
 .onSubmit(of: .search) {
   print("Search submitted")
 }
 .searchable(text: $text)

The explanation lays on the environment:

  • in the first example, searchable(...) receives the environment given by onSubmit, which includes the onSubmit closure
  • in the latter example, the onSubmit modifier is applied after/underneath the searchable(...) modifier. In this case, the onSubmit closure is not part of searchable(...)'s environment, thus never triggering it

Keeping this in mind, it should be clear what the next example does:

Form {
  TextField("Username", text: $username)
    .onSubmit {
      ...
    }

  SecureField("Password", text: $password)
}

Here onSubmit triggers only when the user submits on the TextField, but not on the SecureField.

To avoid confusion, it's recommended to apply onSubmit on the container (the Form in this example) instead.

This showcases a SwiftUI characteristic that we've explored over and over: SwiftUI never tries to be clever, it always behaves as intended, by doing as little work as possible, for performance reasons.

Unfortunately, this environment value is not exposed to third party developers: the submit action cannot be triggered programmatically (FB9429770).

Cumulative

Similarly to the new safeAreaInset(edge:alignment:spacing:content:) modifier, onSubmit is also cumulative:
if we apply multiple onSubmit modifiers, their closures will all trigger, according to their SubmitTriggers, from outermost to innermost.

For example:

Form {
  TextField(...)
  TextField(...)
  ...
}
.onSubmit {
  print("This will trigger last")
}
.onSubmit {
  print("This will trigger second")
}
.onSubmit {
  print("This will trigger first")
}

EnvironmentValues and closures

SwiftUI already had view modifiers accepting closures before (onAppear for example), however, onSubmit might be the first case where the closure is injected into the environment.

Let's recreate (a simplified version of) the onSubmit modifier ourselves, which can also help us back port the new behavior to older OSes.

First, we need an EnvironmentKey:

struct TriggerSubmitKey: EnvironmentKey {
  static let defaultValue: () -> Void = {}
}

This definition contains the default value of our closure, which is empty.
Alternatively, we could make the definition optional, static let defaultValue: (() -> Void)? = nil, we will use the non-optional variant for simplicity's sake.

Next, we will define the onSubmitAction environment value. As we want to mock SwiftUI's behavior, we will make our closures cumulative and triggering from outermost to innermost:

extension EnvironmentValues {
  public var onSubmitAction: () -> Void {
    get {
      self[TriggerSubmitKey.self]
    } set {
      let oldValue = self[TriggerSubmitKey.self]
      self[TriggerSubmitKey.self] = {
        oldValue()
        newValue()
      }
    }
  }
}

The "magic" happens on the onSubmitAction setter, where we make sure to always trigger both the "old" closure first, and then the new one.

Lastly, we need a new modifier definition, injecting the closure into the environment:

extension View {
  func onSubmit(_ action: @escaping (() -> Void)) -> some View {
    environment(\.onSubmitAction, action)
  }
}

With this, we're now all set to start reading and using our new environment value, for example we could define a new text field or a button that trigger the environment action:

struct SubmitButton<Label: View>: View {
  @Environment(\.onSubmitAction) private var onSubmitAction
  @ViewBuilder var label: Label

  var body: some View {
    Button(action: onSubmitAction) {
      label
    }
  }
}

struct SubmitTextField: View {
  @Environment(\.onSubmitAction) private var onSubmitAction
  let title: LocalizedStringKey
  @Binding var text: String

  init(_ title: LocalizedStringKey, text: Binding<String>) {
    self.title = title
    self._text = text
  }

  var body: some View {
    TextField(title, text: $text, onCommit: onSubmitAction) // 👈🏻 Uses the iOS 13/14 API
  }
}

Here's an example on how we could use them:

struct ContentView: View {
  @State var text = ""

  var body: some View {
    Form {
      SubmitTextField("Name", text: $text)
      SubmitButton {
        Text("Submit form")
      }
    }
    .onSubmit {
      print("Form Submitted")
    }
  }
}

In this example we can submit either when:

  • we commit on the text field
  • we tap on the button

This is possible because both views, SubmitTextField and SubmitButton, have access to the onSubmitAction environment value. We cannot do this directly with SwiftUI's definition, because SwiftUI's TriggerSubmitAction environment value is not exposed to third party developers, FB9429770.

Conclusions

This year's changes on both view focus and submission are two very welcome quality of life improvements that will allow SwiftUI apps to create new flows that were not possible before (without bridging back to legacy frameworks).

What's your favorite change from this year? Let me know let me know via email or Twitter!

This article is part of a series exploring new SwiftUI features. We will cover many more during the rest of the summer: subscribe to Five Stars's feed RSS or follow @FiveStarsBlog on Twitter to never miss new content!

⭑⭑⭑⭑⭑

Explore SwiftUI

Browse all