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
@FocusState
and 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
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.
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
textContentType
, autoCapitalization
, keyboardType
, 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
TextField
automatically 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 UIHostingController
, UIViewRepresentable
, 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.

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.