This article will explore the experiences, techniques, and considerations related to SwiftUI TextField events, focus switching, keyboard settings, and more.
Events
onEditingChanged
When the TextField gains focus (enters the editable state), onEditingChanged
calls the given method and passes the value true
; when the TextField loses focus, it calls the method again and passes the value false
.
struct OnEditingChangedDemo:View{
@State var name = ""
var body: some View{
List{
TextField("name:",text:$name,onEditingChanged: getFocus)
}
}
func getFocus(focused:Bool) {
print("get focus:\(focused ? "true" : "false")")
}
}
The name of this parameter is easy to cause ambiguity for users. Do not use onEditingChanged
to determine whether the user has entered content.
In iOS 15, the newly added constructor that supports ParseableFormatStyle does not provide this parameter. Therefore, for TextFields that use the new Formatter, other means need to be used to determine whether they have gained or lost focus.
onCommit
onCommit is triggered when the user presses (or clicks) the return
key during the input process (cannot be triggered by code simulation). If the user does not click the return
key (such as directly switching to other TextFields), onCommit will not be triggered. At the same time as onCommit is triggered, the TextField will also lose focus.
struct OnCommitDemo:View{
@State var name = ""
var body: some View{
List{
TextField("name:",text: $name,onCommit: {print("commit")})
}
}
}
If you need to check the user’s input after they enter it, it’s best to use onCommit and onEdtingChanged together. If you want to process the user’s input data in real time, please refer to Advanced SwiftUI TextField: Events, Focus, Keyboard.
onCommit also applies to SecureField.
In iOS 15, the newly added constructor that supports ParseableFormatStyle does not provide this parameter. You can use the newly added onSubmit to achieve the same effect.
onSubmit
onSubmit is a new feature in SwiftUI 3.0. While onCommit and onEditingChanged describe the state of each TextField individually, onSubmit allows for unified management and scheduling of multiple TextFields within a view.
// Definition of onSubmit
extension View {
public func onSubmit(of triggers: SubmitTriggers = .text, _ action: @escaping (() -> Void)) -> some View
}
The following code will implement the same behavior as onCommit:
struct OnSubmitDemo:View{
@State var name = ""
var body: some View{
List{
TextField("name:",text: $name)
.onSubmit {
print("commit")
}
}
}
}
The triggering condition of onSubmit is the same as onCommit, which requires the user to actively click “return”.
onSubmit is also applicable to SecureField.
Scope and Nesting
The implementation of onSubmit is based on setting the environment value TriggerSubmitAction
(not yet available to developers), so onSubmit has a scope (can be passed up the view tree) and can be nested.
struct OnSubmitDemo: View {
@State var text1 = ""
@State var text2 = ""
@State var text3 = ""
var body: some View {
Form {
Group {
TextField("text1", text: $text1)
.onSubmit { print("text1 commit") }
TextField("text2", text: $text2)
.onSubmit { print("text2 commit") }
}
.onSubmit { print("textfield in group commit") }
TextField("text3", text: $text3)
.onSubmit { print("text3 commit") }
}
.onSubmit { print("textfield in form commit1") }
.onSubmit { print("textfield in form commit2") }
}
}
When TextField (text1) is committed, the console output is:
textfield in form commit2
textfield in form commit1
textfield in group commit
text1 commit
Please note that the calling order is from outer to inner.
Limited scope
You can use submitScope
to block the scope (limit further propagation on the view tree). For example, in the code above, add submitScope
after Group
Group {
TextField("text1", text: $text1)
.onSubmit { print("text1 commit") }
TextField("text2", text: $text2)
.onSubmit { print("text2 commit") }
}
.submitScope() // block the scope
.onSubmit { print("textfield in group commit") }
When TextField1 commits, the console outputs:
text1 commit
At this point, the scope of onSubmit will be limited to within the Group.
When there are multiple TextFields in the view, a great user experience can be achieved by combining onSubmit and FocusState (introduced below).
Support for searchable
In iOS 15, the new search box also triggers onSubmit when “return” is clicked, but triggers need to be set to search:
struct OnSubmitForSearchableDemo:View{
@State var name = ""
@State var searchText = ""
var body: some View{
NavigationView{
Form{
TextField("name:",text:$name)
.onSubmit {print("textField commit")}
}
.searchable(text: $searchText)
.onSubmit(of: .search) { //
print("searchField commit")
}
}
}
}
It should be noted that SubmitTriggers is of type OptionSet. onSubmit will continuously pass the values contained in SubmitTriggers within the environment in the view tree. When the received SubmitTriggers value is not included in the SubmitTriggers set in onSubmit, the passing will be terminated. Simply put, onSubmit(of:.search)
will block the commit state generated by TextFiled, and vice versa.
For example, in the above code, if we add another onSubmt(of:.text)
after searchable, we won’t be able to respond to the commit event of the TextField.
.searchable(text: $searchText)
.onSubmit(of: .search) {
print("searchField commit1")
}
.onSubmit {print("textField commit")} //cannot be triggered, blocked by search
Therefore, when handling both TextField and search box, special attention needs to be paid to their calling order.
The following code can be used to support both TextField and search box in one onSubmit:
.onSubmit(of: [.text, .search]) {
print("Something has been submitted")
}
The following code won’t trigger either because onSubmit(of:search)
is placed before searchable
.
NavigationView{
Form{
TextField("name:",text:$name)
.onSubmit {print("textField commit")}
}
.onSubmit(of: .search) { // won't trigger
print("searchField commit1")
}
.searchable(text: $searchText)
}
Focus
Before iOS 15 (Monterey), SwiftUI did not provide a way to get focus for TextField (e.g., becomeFirstResponder
). For a long time, developers had to resort to non-SwiftUI methods to achieve similar functionality.
In SwiftUI 3.0, Apple has provided developers with a much better than expected solution, similar to onSubmit, which allows for a higher-level view hierarchy to uniformly determine and manage the focus of text fields within a view.
Basic Usage
SwiftUI provides a new FocusState property wrapper to help us determine if a TextField within this view has focus. We can associate FocusState with a specific TextField by using focused
.
struct OnFocusDemo:View{
@FocusState var isNameFocused:Bool
@State var name = ""
var body: some View{
List{
TextField("name:",text:$name)
.focused($isNameFocused)
}
.onChange(of:isNameFocused){ value in
print(value)
}
}
}
The above code will set isNameFocused
to true
when TextField gains focus and false
when it loses focus.
For multiple TextFields in the same view, you can create multiple FocusStates to associate with each corresponding TextField. For example:
struct OnFocusDemo:View{
@FocusState var isNameFocused:Bool
@FocusState var isPasswordFocused:Bool
@State var name = ""
@State var password = ""
var body: some View{
List{
TextField("name:",text:$name)
.focused($isNameFocused)
SecureField("password:",text:$password)
.focused($isPasswordFocused)
}
.onChange(of:isNameFocused){ value in
print(value)
}
.onChange(of:isPasswordFocused){ value in
print(value)
}
}
}
The above method becomes cumbersome when there are more TextFields in the view and is not conducive to unified management. Fortunately, FocusState not only supports Boolean values, but also supports any hash type. We can use an enum that conforms to the Hashable protocol to manage the focus of multiple TextFields in the view. The following code will achieve the same function as the above:
struct OnFocusDemo:View{
@FocusState var focus:FocusedField?
@State var name = ""
@State var password = ""
var body: some View{
List{
TextField("name:",text:$name)
.focused($focus, equals: .name)
SecureField("password:",text:$password)
.focused($focus,equals: .password)
}
.onChange(of: focus, perform: {print($0)})
}
enum FocusedField:Hashable{
case name,password
}
}
Make a specified TextField gain focus immediately after displaying a view
Using FocusState, it is easy to make a specified TextField gain focus and pop up the keyboard immediately after displaying a view:
struct OnFocusDemo:View{
@FocusState var focus:FocusedField?
@State var name = ""
@State var password = ""
var body: some View{
List{
TextField("name:",text:$name)
.focused($focus, equals: .name)
SecureField("password:",text:$password)
.focused($focus,equals: .password)
}
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5){
focus = .name
}
}
}
enum FocusedField:Hashable{
case name,password
}
}
Assigning values during view initialization is invalid. Even in onAppear
, there must be some delay to allow TextField to focus (no delay is required on iOS 16).
Switching focus between multiple TextFields
By combining focused
and onSubmit
, we can achieve the effect of automatically switching focus to the next TextField when the user finishes inputting in one TextField (by clicking return
).
struct OnFocusDemo:View{
@FocusState var focus:FocusedField?
@State var name = ""
@State var email = ""
@State var phoneNumber = ""
var body: some View{
List{
TextField("Name:",text:$name)
.focused($focus, equals: .name)
.onSubmit {
focus = .email
}
TextField("Email:",text:$email)
.focused($focus,equals: .email)
.onSubmit {
focus = .phone
}
TextField("PhoneNumber:",text:$phoneNumber)
.focused($focus, equals: .phone)
.onSubmit {
if !name.isEmpty && !email.isEmpty && !phoneNumber.isEmpty {
submit()
}
}
}
}
private func submit(){
// submit all infos
print("submit")
}
enum FocusedField:Hashable{
case name,email,phone
}
}
The above code can also be transformed into the following format by utilizing the onSubmit passing feature:
List {
TextField("Name:", text: $name)
.focused($focus, equals: .name)
TextField("Email:", text: $email)
.focused($focus, equals: .email)
TextField("PhoneNumber:", text: $phoneNumber)
.focused($focus, equals: .phone)
}
.onSubmit {
switch focus {
case .name:
focus = .email
case .email:
focus = .phone
case .phone:
if !name.isEmpty, !email.isEmpty, !phoneNumber.isEmpty {
submit()
}
default:
break
}
}
By combining screen buttons (such as the assistive keyboard view) or keyboard shortcuts, we can also change the focus forward or jump to another specific TextField.
Using Keyboard Shortcuts to Obtain Focus
When a view has multiple TextFields (including SecureFields), we can directly use the Tab
key to switch focus in the TextField in order. However, SwiftUI does not directly provide the ability to use keyboard shortcuts to make a certain TextField obtain focus. By combining FocusState
and keyboardShortcut
, this ability can be obtained on iPad and MacOS.
Create a focused
that supports keyboard shortcut binding:
public extension View {
func focused(_ condition: FocusState<Bool>.Binding, key: KeyEquivalent, modifiers: EventModifiers = .command) -> some View {
focused(condition)
.background(Button("") {
condition.wrappedValue = true
}
.keyboardShortcut(key, modifiers: modifiers)
.hidden()
)
}
func focused<Value>(_ binding: FocusState<Value>.Binding, equals value: Value, key: KeyEquivalent, modifiers: EventModifiers = .command) -> some View where Value: Hashable {
focused(binding, equals: value)
.background(Button("") {
binding.wrappedValue = value
}
.keyboardShortcut(key, modifiers: modifiers)
.hidden()
)
}
}
Code invocation:
struct ShortcutFocusDemo: View {
@FocusState var focus: FouceField?
@State private var email = ""
@State private var address = ""
var body: some View {
Form {
TextField("email", text: $email)
.focused($focus, equals: .email, key: "t")
TextField("address", text: $address)
.focused($focus, equals: .address, key: "a", modifiers: [.command, .shift,.option])
}
}
enum FouceField: Hashable {
case email
case address
}
}
When the user enters ⌘ + T, the TextField responsible for email will receive focus. When the user enters ⌘ + ⌥ + ⇧ + A, the TextField responsible for address will receive focus.
The above code does not perform well on the iPad simulator (sometimes unable to activate), please test it on a real device.
Create your own onEditingChanged
The best option for determining the focus status of a single TextField is still to use onEditingChanged
, but for some cases where onEditingChanged
cannot be used (such as with new Formatters), we can use FocusState
to achieve similar effects.
- Determine the focus status of a single TextField
public extension View {
func focused(_ condition: FocusState<Bool>.Binding, onFocus: @escaping (Bool) -> Void) -> some View {
focused(condition)
.onChange(of: condition.wrappedValue) { value in
onFocus(value == true)
}
}
}
Usage:
struct onEditingChangedFocusVersion:View{
@FocusState var focus:Bool
@State var price = 0
var body: some View{
Form{
TextField("Price:",value:$price,format: .number)
.focused($focus){ focused in
print(focused)
}
}
}
}
- Check multiple TextFields
To avoid multiple calls when a TextField loses focus, we need to save the FocusState value of the last focused TextField in the view hierarchy.
public extension View {
func storeLastFocus<Value: Hashable>(current: FocusState<Value?>.Binding, last: Binding<Value?>) -> some View {
onChange(of: current.wrappedValue) { _ in
if current.wrappedValue != last.wrappedValue {
last.wrappedValue = current.wrappedValue
}
}
}
func focused<Value>(_ binding: FocusState<Value>.Binding, equals value: Value, last: Value?, onFocus: @escaping (Bool) -> Void) -> some View where Value: Hashable {
return focused(binding, equals: value)
.onChange(of: binding.wrappedValue) { focusValue in
if focusValue == value {
onFocus(true)
} else if last == value { // only call once
onFocus(false)
}
}
}
}
Call:
struct OnFocusView: View {
@FocusState private var focused: Focus?
@State private var lastFocused: Focus?
@State private var name = ""
@State private var email = ""
@State private var address = ""
var body: some View {
List {
TextField("Name:", text: $name)
.focused($focused, equals: .name, last: lastFocused) {
print("name:", $0)
}
TextField("Email:", text: $email)
.focused($focused, equals: .email, last: lastFocused) {
print("email:", $0)
}
TextField("Address:", text: $address)
.focused($focused, equals: .address, last: lastFocused) {
print("address:", $0)
}
}
.storeLastFocus(current: $focused, last: $lastFocused) // save lastest focsed value
}
enum Focus {
case name, email, address
}
}
Keyboard
Using TextField inevitably involves dealing with the software keyboard. This section will introduce several examples related to the keyboard.
Keyboard Type
In iPhone, we can set the software keyboard type via keyboardType
to facilitate user input or restrict the input character range.
For example:
struct KeyboardTypeDemo:View{
@State var price:Double = 0
var body: some View{
Form{
TextField("Price:",value:$price,format: .number.precision(.fractionLength(2)))
.keyboardType(.decimalPad) //support decimal point numeric keyboard
}
}
}
Currently, there are a total of 11 supported keyboard types, which are:
- asciiCapable
ASCII character keyboard
- numbersAndPunctuation
Numbers and punctuation
- URL
Convenient for inputting URLs, including characters and ’.’, ’/’, ‘.com’
- numberPad
Uses the region’s set of digits (0-9, ۰-۹, ०-९, etc.). Suitable for positive integers or PINs.
- phonePad
Numbers and other symbols used in telephones, such as ’*#+’
- namePhonePad
Convenient for entering text and phone numbers. Character state is similar to asciiCapable, and numeric state is similar to numberPad.
- emailAddress
An asciiCapable keyboard convenient for entering ’@.’
- decimalPad
A numberPad that includes a decimal point. See the image above for details.
An asciiCapable keyboard that includes ’@#‘.
- webSearch
An asciiCapable keyboard that includes ’.’, with the ‘return’ key marked as ‘go’.
- asciiCapableNumberPad
An asciiCapable keyboard that includes numbers.
Although Apple provides many keyboard modes to choose from, in some cases they may not meet the needs of users.
For example, numberPad and decimalPad do not have ’-’ and ‘return’. Prior to SwiftUI 3.0, we had to draw separately on the main view or use non-SwiftUI methods to solve the problem. In SwiftUI 3.0, however, the addition of native setting keyboard auxiliary views (detailed below) makes it no longer difficult to solve the above problems.
Get suggestions through TextContentType
When using certain iOS apps, when entering text, the software keyboard will automatically suggest the content we need to enter, such as phone numbers, emails, verification codes, and so on. These are all effects obtained through textContentType
.
By setting the UITextContentType
for the TextField
, the system intelligently infers the content that may be wanted to be entered during input and displays suggestions.
The following code allows the use of the keychain when entering a password:
struct KeyboardTypeDemo: View {
@State var password = ""
var body: some View {
Form {
SecureField("", text: $password)
.textContentType(.password)
}
}
}
The following code will suggest similar email addresses from your contacts and emails when entering an email address:
struct KeyboardTypeDemo: View {
@State var email = ""
var body: some View {
Form {
TextField("", text: $email)
.textContentType(.emailAddress)
}
}
}
There are many types of UITextContentType that can be set, among which the commonly used ones include:
- password
- name options, such as name, givenName, middleName, etc.
- address options, such as addressCity, fullStreetAddress, postalCode, etc.
- telephoneNumber
- emailAddress
- oneTimeCode (verification code)
Testing textContentType is best done on a real device, as the simulator does not support certain items or provide enough information.
Dismissing the Keyboard
In some cases, after the user has finished entering text, we need to dismiss the software keyboard to make more display space available. Some keyboard types do not have a “return” button, so we need to use programming to make the keyboard disappear.
In addition, sometimes we may want the user to be able to dismiss the keyboard by clicking on other areas of the screen or scrolling the list, without having to click the “return” button, in order to improve the interaction experience. Again, we need to use programming to make the keyboard disappear.
- Using FocusState to dismiss the keyboard
If the corresponding FocusState is set for the TextField, the keyboard can be dismissed by setting the value to “false” or “nil”.
struct HideKeyboardView: View {
@State private var name = ""
@FocusState private var nameIsFocused: Bool
var body: some View {
Form {
TextField("Enter your name", text: $name)
.focused($nameIsFocused)
Button("dismiss Keyboard") {
nameIsFocused = false
}
}
}
}
- Other situations
In most cases, we can directly use the methods provided by UIkit to dismiss the keyboard.
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
For example, the following code will dismiss the keyboard when the user drags on the view:
struct ResignKeyboardOnDragGesture: ViewModifier {
var gesture = DragGesture().onChanged { _ in
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
func body(content: Content) -> some View {
content.gesture(gesture)
}
}
extension View {
func dismissKeyboard() -> some View {
return modifier(ResignKeyboardOnDragGesture())
}
}
struct HideKeyboardView: View {
@State private var name = ""
var body: some View {
Form {
TextField("Enter your name", text: $name)
}
.dismissKeyboard()
}
}
Keyboard assistant view
Created through toolbar
In SwiftUI 3.0, we can create a keyboard assistant view (inputAccessoryView) by using ToolbarItem(placement: .keyboard, content: View)
.
With the input assistant view, we can solve many problems that were difficult to deal with before, and provide more means for interaction.
The code below adds a positive/negative conversion and confirmation button when inputting a floating point number:
import Introspect
struct ToolbarKeyboardDemo: View {
@State var price = ""
var body: some View {
Form {
TextField("Price:", text: $price)
.keyboardType(.decimalPad)
.toolbar {
ToolbarItem(placement: .keyboard) {
HStack {
Button("-/+") {
if price.hasPrefix("-") {
price.removeFirst()
} else {
price = "-" + price
}
}
.buttonStyle(.bordered)
Spacer()
Button("Finish") {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
// do something
}
.buttonStyle(.bordered)
}
.padding(.horizontal, 30)
}
}
}
}
}
Unfortunately, there are still some limitations to setting input accessory views through ToolbarItem:
-
Limited display content
The height is fixed and cannot make use of the full display area of the accessory view. Similar to other types of toolbar, SwiftUI intervenes in the layout of the content.
-
Unable to set accessory views for multiple text fields separately within the same view
More complex conditional syntax cannot be used in ToolbarItem. If the settings are made separately for different text fields, SwiftUI will merge all the content together for display.
Currently, SwiftUI’s intervention and processing of toolbar content is too excessive. The original intention was good, to help developers easily organize buttons and automatically optimize and achieve the best display effect on different platforms. However, the limitations of toolbar and ToolbarItem’s ResultBuilder are too many, making it difficult to perform more complex logical judgments within them. The logic of integrating keyboard accessory views into the toolbar is also somewhat confusing.
Creating through UIKit
At this stage, creating keyboard accessory views through UIKit is still the optimal solution for SwiftUI. Not only can it obtain complete control over the view display, but it can also separately set multiple text fields within the same view.
extension UIView {
func constrainEdges(to other: UIView) {
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: other.leadingAnchor),
trailingAnchor.constraint(equalTo: other.trailingAnchor),
topAnchor.constraint(equalTo: other.topAnchor),
bottomAnchor.constraint(equalTo: other.bottomAnchor),
])
}
}
extension View {
func inputAccessoryView<Content: View>(@ViewBuilder content: @escaping () -> Content) -> some View {
introspectTextField { td in
let viewController = UIHostingController(rootView: content())
viewController.view.constrainEdges(to: viewController.view)
td.inputAccessoryView = viewController.view
}
}
func inputAccessoryView<Content: View>(content: Content) -> some View {
introspectTextField { td in
let viewController = UIHostingController(rootView: content)
viewController.view.constrainEdges(to: viewController.view)
td.inputAccessoryView = viewController.view
}
}
}
Usage:
struct OnFocusDemo: View {
@FocusState var focus: FocusedField?
@State var name = ""
@State var email = ""
@State var phoneNumber = ""
var body: some View {
Form {
TextField("Name:", text: $name)
.focused($focus, equals: .name)
.inputAccessoryView(content: accessoryView(focus: .name))
TextField("Email:", text: $email)
.focused($focus, equals: .email)
.inputAccessoryView(content: accessoryView(focus: .email))
TextField("PhoneNumber:", text: $phoneNumber)
.focused($focus, equals: .phone)
}
.onSubmit {
switch focus {
case .name:
focus = .email
case .email:
focus = .phone
case .phone:
if !name.isEmpty, !email.isEmpty, !phoneNumber.isEmpty {}
default:
break
}
}
}
}
struct accessoryView: View {
let focus: FocusedField?
var body: some View {
switch focus {
case .name:
Button("name") {}.padding(.vertical, 10)
case .email:
Button("email") {}.padding(.vertical, 10)
default:
EmptyView()
}
}
}
By the time of Swift UI 3.0, automatic keyboard avoidance for TextField has become quite mature. It can avoid covering the TextField being inputted in different types of views (such as List, Form, ScrollView), or in cases where auxiliary views or textContentType are used. If the height of the lift can be higher, perhaps the effect would be better, but it is currently somewhat cramped.
Custom SubmitLabel
By default, the submit behavior button on the keyboard for TextField (SecureField) is “return”. With the new “submitLabel” modifier introduced in SwiftUI 3.0, we can modify the “return” button to display text that is more relevant to the input context.
TextField("Username", text: $username)
.submitLabel(.next)
Currently supported types include:
- continue
- done
- go
- join
- next
- return
- route
- search
- send
For example, in the previous code, we can set different corresponding displays for name
, email
, and phoneNumber
.:
TextField("Name:", text: $name)
.focused($focus, equals: .name)
.submitLabel(.next)
TextField("Email:", text: $email)
.focused($focus, equals: .email)
.submitLabel(.next)
TextField("PhoneNumber:", text: $phoneNumber)
.focused($focus, equals: .phone)
.submitLabel(.return)
Conclusion
Since SwiftUI 1.0, Apple has been continuously improving the functionality of TextField. In version 3.0, SwiftUI not only provides more native modifiers, but also provides integrated management logic such as FocusState and onSubmit. I believe that in another 2-3 years, the native functionality of SwiftUI’s main controls will be able to match the corresponding UIKit controls.