SwiftUI's new onSubmit modifier
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 mainTextField
valueonEditingChanged
was called when theTextField
gained or lost the focus (theBool
parameter wastrue
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 newonSubmit
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 TextField
s 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 byonSubmit
, which includes theonSubmit
closure - in the latter example, the
onSubmit
modifier is applied after/underneath thesearchable(...)
modifier. In this case, theonSubmit
closure is not part ofsearchable(...)
'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!