SwiftUI TextField: Handling Keyboard the Right Way

author
Ivo Leko
date
Mar 19, 2025
slug
swiftui-keyboard-textfield-done-right
status
Published
tags
iOS
SwiftUI
summary
Managing multiple text fields in SwiftUI can be frustrating due to the default keyboard behavior. By using @FocusStateand Introspect library, developers can access the underlying UITextField to gain better control, customize the return button's action, and avoid keyboard animation glitches.
type
Post
avatar
featured_image
keyboard-form.jpg
Everyone developing applications in SwiftUI may have noticed that Apple consistently refuses to fix the keyboard issues when there are multiple textfields on the screen. However, there is a solution!

Initial Problem

If you've ever developed a sign-up form or any other type of form that contains two or more text fields, you might find SwiftUI's solution frustrating. SwiftUI is great, and in most cases, it's a much faster approach than the older UIKit. The idea of using @FocusState is also a great aspect of SwiftUI's declarative programming, but it doesn’t work as smoothly as expected.
 
notion image
 
Let’s take a look at the screen above. We have a sign-up form with fields for a username, email, and two password entries (you can imagine the gray circle as an avatar, but it's only there to demonstrate the scroll behavior). When the user enters their full name and presses the return/next button on the keyboard, the email field should automatically become focused. That’s the expected behavior, right?
 
 
Well, default behavior of a SwiftUI TextField is to close the keyboard when the user presses the return/next button. This is actually an improvement over UIKit, where the default behavior was... nothing. However, with delegate methods and first responders, UIKit provided much more flexibility for customization. So, how do we handle this in SwiftUI?
 

@FocusState and keyboard glitch

Let’s take a look at the SwiftUI code below.
@State var email = "" @State var fullname = "" @State var password = "" @State var repeatPassword = ""
ScrollView(showsIndicators: false) { Circle() //avatar imitation .fill(.gray) .frame(width: 200, height: 200) VStack(spacing: 20) { TextField("Full Name", text: $fullname) .textContentType(.name) .autocapitalization(.words) .keyboardType(.alphabet) .customField() TextField("Email", text: $email) .textContentType(.emailAddress) .keyboardType(.emailAddress) .customField() SecureField("Password", text: $password) .textContentType(.newPassword) .customField() SecureField("Repeat Password", text: $repeatPassword) .textContentType(.newPassword) .submitLabel(.done) .customField() } .padding() .textFieldStyle(.plain) .autocorrectionDisabled() .autocapitalization(.none) .submitLabel(.next) }
 
It’s a simple collection of four text fields, but we’re using all the fancy modifiers like textContentTypeautoCapitalizationkeyboardType, and submitLabel to define the keyboard type and the behavior of the text fields when the user starts typing. What’s great is that common modifiers like autocorrectionDisabled() can be applied to the parent VStack rather than being set individually on each text field. We also have a custom modifier, customField, to change the default appearance of the TextField. However, that's not the focus of this blog post. Now, let's introduce a new enum where each case represents one of the text fields.
enum FocusedField { case fullname case email case password case repeatPassword }
 
Since simple enums automatically conform to Hashable, we can use them as the value for the new @FocusState property to track (and change) the current focus. In SwiftUI, focus is similar to the first responder in UIKit. Essentially, in iOS, the focused view is the one that takes responsibility for the keyboard. In macOS and tvOS, for example, the focused view is visually distinct.
@FocusState var focusField: FocusedField? // new @State var email = "" @State var fullname = "" @State var password = "" @State var repeatPassword = ""
 
You’ll notice that this property is optional. If no text field is selected (focused), then this property should be nil. We can use the focused modifier to assign this state and value to each text field [1]. Now we are tracking the current focus. However, we want to change focus when the user presses the next button on the keyboard. To achieve this, we can use the onSubmit modifier to add a custom action that changes the current focus to the next field [2]. For the last text field, we want to remove focus (close the keyboard) and eventually submit the form [3].
ScrollView(showsIndicators: false) { Circle() //avatar imitation .fill(.gray) .frame(width: 200, height: 200) VStack(spacing: 20) { TextField("Full Name", text: $fullname) .onSubmit { focusField = .email // 2. } .textContentType(.name) .autocapitalization(.words) .keyboardType(.alphabet) .focused($focusField, equals: .fullname) // 1. .customField() TextField("Email", text: $email) .onSubmit { focusField = .password // 2. } .textContentType(.emailAddress) .keyboardType(.emailAddress) .focused($focusField, equals: .email) // 1. .customField() SecureField("Password", text: $password) .onSubmit { focusField = .repeatPassword // 2. } .textContentType(.newPassword) .focused($focusField, equals: .password) // 1. .customField() SecureField("Repeat Password", text: $repeatPassword) .onSubmit { focusField = nil // 3. //TO DO: submit } .textContentType(.newPassword) .submitLabel(.done) .focused($focusField, equals: .repeatPassword) // 1. .customField() } .padding() .textFieldStyle(.plain) .autocorrectionDisabled() .autocapitalization(.none) .submitLabel(.next) }
 
Alternatively, we could use a single onSubmit on the parent VStack and then inspect the current focus using a switch-case statement. It’s up to you which approach to choose. Now, let’s take a look at what we’ve achieved.
 
So, it’s functional. It’s working. But it looks quite bad. The keyboard is jumping up and down along with the scroll view content, making it feel more like a poorly designed web or hybrid app rather than a native application using Apple’s latest technologies. For iOS developers with experience in UIKit, this can be particularly frustrating, as everything works seamlessly with UIKit.
 

Solution

Unfortunately, there’s no proper solution to avoid this bouncing keyboard using pure SwiftUI. The issue arises because the TextFieldautomatically loses focus when the user presses the return/next button. Although we reset the focus to the next TextField, it seems the process isn’t “fast enough” for the UI. However, the most important thing to note is that SwiftUI is built on UIKit. Apple provides APIs to mix and match SwiftUI and UIKit using UIHostingControllerUIViewRepresentable, and UIViewControllerRepresentable. Yet, Apple does not offer any API to directly access the UIKit components that underlie system SwiftUI views.
 
However, it’s quite easy to access UITextField, the UIKit component that the SwiftUI TextField is based on. We can do this manually by creating a custom UIViewRepresentable and placing it in the background of the TextField. By traversing the superview and subviews, we can reach the UIKit component we need. But there's no need to go through that, because there's a great SwiftUI library called Introspect that simplifies this process.
 
With Introspect, it is quite easy to get UITextField from TextField:
TextField("Email", text: $vm.email) .introspect(.textField, on: .iOS(.v13, .v14, .v15, .v16, .v17, .v18)) { textField in // do something with UITextField }
If this is your first encounter with Introspect, you might find the iOS version parameters a bit confusing. However, they’re a safety measure. The SwiftUI TextField is currently based on UITextField, but in future iOS versions, that might not be the case.
 
Once we have access to the UITextField, we can set a custom object as its delegate. With control over its delegation, we can customize the behavior of the return button. However, introspecting every TextField in every view is quite ugly, so let’s create a custom modifier with a customSubmit action.
 
import SwiftUIIntrospect fileprivate struct TextFieldSubmit: ViewModifier { // private class that conforms UITextFieldDelegate private class TextFieldKeyboardBehavior: UIView, UITextFieldDelegate { var submitAction: (() -> Void)? func textFieldShouldReturn(_ textField: UITextField) -> Bool { submitAction?() // called when keyboard return button is pressed return false //cancel default behavior of return button } } //instance to UITextFieldDelegate private var textFieldKeyboardBehavior = TextFieldKeyboardBehavior() init(submitAction: @escaping () -> Void) { self.textFieldKeyboardBehavior.submitAction = submitAction } func body(content: Content) -> some View { content.introspect(.textField, on: .iOS(.v13, .v14, .v15, .v16, .v17, .v18)) { textField in //UITextField reached with Introspect textField.delegate = textFieldKeyboardBehavior } } } //make the modifier publicly available for use with TextField. extension TextField { func customSubmit(submitAction: @escaping (() -> Void)) -> some View { self.modifier(TextFieldSubmit(submitAction: submitAction)) } } //make the modifier publicly available for use with SecureField. extension SecureField { func customSubmit(submitAction: @escaping (() -> Void)) -> some View { self.modifier(TextFieldSubmit(submitAction: submitAction)) } }
 
Let's explain the code above. We created a custom TextFieldSubmit modifier that is fileprivate (meaning it cannot be used directly outside this file). In the modifier's body, we use Introspect to access the underlying UITextField. Once we have access, we inject our custom TextFieldKeyboardBehavior object as the delegate to the UITextField. When the user presses the return button, the textFieldShouldReturn function is called. This is where we can trigger our custom action and stop the default behavior by returning false. The return false is crucial, as it overrides the default behavior of the TextField, preventing it from losing focus and closing the keyboard. Finally, we need to create public extensions to expose new customSubmit modifiers to TextField and SecureField.
 
To use the newly created modifiers, simply replace the onSubmit modifier with the new customSubmit modifier on every TextField and SecureField, as shown in the example:
TextField("Full Name", text: $fullname) .customSubmit { // new focusField = .email } .textContentType(.name) .autocapitalization(.words) .keyboardType(.alphabet) .focused($focusField, equals: .fullname) .customField()
 
And that’s it! Let’s take a look at the result.
notion image
 

Conclusion

SwiftUI is great, but it’s still limited compared to UIKit—and that’s understandable. This is why Apple continues to develop UIKit, allowing developers to build custom components. However, it’s somewhat surprising that there’s no built-in mechanism to control focused keyboard behavior. While this solution provides flexibility and removes animation glitch, it still feels a bit like a workaround. But that's all we have for now, at least until Apple expands its API and makes it more flexible.