Focus Management in SwiftUI: Getting Started
Learn how to manage focus in SwiftUI by improving the user experience for a checkout form. By Mina H. Gerges.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Focus Management in SwiftUI: Getting Started
25 mins
- Getting Started
- Applying Auto-Focus
- Improving Focus Implementation for Multiple Views
- Switching Focus Between Views
- Avoiding Ambiguous Focus Bindings
- Managing Focus in Lists
- Improving Focus Implementation With MVVM
- Observing Values From Focused Views
- Modifying Values From Focused Views
- Where to Go From Here?
Remember the last time you logged in, completed a checkout process, or sent feedback? Each of these interactions likely included a form. Navigating a form can be tedious if the app doesn’t assist with focus. When a view is focused, it’s visually activated and ready for interaction. A view type you might associate with focus is a text field: Often, focus is applied to text fields to bring up the keyboard and tip off the user to type in that field next.
To simplify focus implementation, Apple introduced FocusState
at WWDC 2021. FocusState
is a property wrapper that tracks and edits the focus location in the scene.
In this tutorial, you’ll learn all about focus management in SwiftUI by using modifiers and wrappers like FocusState
to help users navigate forms more effectively. You’ll do so by filling out a checkout form and gift card for a friend. How nice of you! :]
While finding the perfect gift, you’ll learn how to:
- Switch focus between views.
- Handle focus in a list while using the MVVM pattern.
- Recognize and edit a focused view from another view.
Getting Started
Download the starter project by clicking Download Materials at the top or bottom of this tutorial. Open the project in the starter directory in Xcode. Build and run on an iPhone simulator.
The app displays a list of available gifts. Select a gift, then enter shipping information. Finally, write a gift message along with the recipient’s email addresses.
You may notice that some focus improvements could be made. For example, focus should shift seamlessly between shipping fields when the user taps the return key. And, when trying to proceed to the next step, focus should draw the user to invalid entries.
In the next section, you’ll learn about FocusState
and how it can help the user start filling out your form quickly.
If you’re looking for model-view-viewmodel (MVVM) pattern specifically, checkout Design Patterns by Tutorials: MVVM.
Applying Auto-Focus
You’ll start improving the app by implementing auto-focus. Auto-focus is the effect where the first relevant view automatically receives the focus upon loading the screen. Though subtle, it’s an experience users expect.
You’ll use the FocusState
property wrapper to achieve this effect. Generally, FocusState
covers many things:
- Keeping track of which view is currently focused.
- Changing focus to a desired view.
- Removing focus from all views, resulting in keyboard dismissal.
Two modifiers complement FocusState
:
- focused(_:): A modifier that binds the view’s focus to a single Boolean state value.
- focused(_:equals:): A modifier that binds the view’s focus with any given state value.
FocusState
and focused(_:)
are perfect to fix the first UX bug in your app.
Open CheckoutFormView.swift. Below the struct
declaration, add the following line:
@FocusState private var nameInFocus: Bool
This code creates a property to control focus for the name field.
Inside recipientDataView
, add the following modifier to the first EntryTextField
:
.focused($nameInFocus)
This code binds focus for the name field to the nameInFocus
property. nameInFocus
‘s value changes to true
each time the user sets focus on this field.
Add the following under the code you just added:
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
self.nameInFocus = true
}
}
This code programmatically applies focus to the Name field when the checkout view appears on screen. The 0.75
second delay is to ensure the view has already appeared.
Build and run. Select a gift, then tap the Checkout button. Once checkout appears, notice how focus shifts to the Name field.
Auto-focus achieved! Doesn’t that make filling out the Checkout Form a bit easier?
In a form with multiple fields, you’ll invariably want to support focus for most fields to assist the user in navigating quickly by tapping return on the keyboard.
With your current implementation, you’d have to add another Bool
property for every field requiring focus. In the next section, you’ll use a better technique to avoid cluttering your view.
Improving Focus Implementation for Multiple Views
Open CheckoutFormView.swift. At the top, add the following code, right before CheckoutFormView
:
enum CheckoutFocusable: Hashable {
case name
case address
case phone
}
This code creates an enum listing all the focusable fields.
Next, inside CheckoutFormView
, replace:
@FocusState private var nameInFocus: Bool
With:
@FocusState private var checkoutInFocus: CheckoutFocusable?
This code creates a property with the type of the enum you just created. This single property holds which field is in focus instead of requiring three different Boolean properties.
Inside recipientDataView
, after the first EntryTextField
, replace the following code:
// 1
.focused($nameInFocus)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
// 2
self.nameInFocus = true
}
}
With:
// 1
.focused($checkoutInFocus, equals: .name)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
// 2
self.checkoutInFocus = .name
}
}
Here’s what this code does:
- It uses the
focused(_:equals:)
modifier to bind the Name field’s focus to thecheckoutInFocus
property for the enum casename
. - When this view appears on screen, it shifts focus to the Name field by setting the value of
checkoutInFocus
to the corresponding enum case.
The CheckoutFocusable
enum was declared as Hashable
because that’s a requirement for the type used with focused(_:equals:)
.
Build and run. You’ll find the app does exactly as before: When the Checkout screen loads, the Name field is auto-focused.
Now, for those other fields. Inside recipientDataView
, add the following modifier to the second EntryTextField
:
.focused($checkoutInFocus, equals: .address)
This code binds the Address field’s focus to the checkoutInFocus
property for the enum case address
.
You might’ve guessed what you’ll do with the Phone field! Inside recipientDataView
, add the following modifier to the final EntryTextField
:
.focused($checkoutInFocus, equals: .phone)
This code binds the Phone field’s focus to the checkoutInFocus
property for the enum case phone
.
At this point, you’ve set up all the form fields on the checkout screen to handle focus. Next, you’ll use this setup to switch focus between the fields and improve the validation experience.
Switching Focus Between Views
Open CheckoutFormView.swift. Inside body
, add the following code after Form
:
.onSubmit {
if checkoutInFocus == .name {
checkoutInFocus = .address
} else if checkoutInFocus == .address {
checkoutInFocus = .phone
} else if checkoutInFocus == .phone {
checkoutInFocus = nil
}
}
This code defines what happens when the user taps return on the keyboard. If focus is on the Name field, it’ll shift to the Address field. If focus is on the Address field, it’ll shift to the Phone field. Finally, if focus is on the Phone field, it’ll release focus and dismiss the keyboard.
Build and run. Type any name inside the Name field and tap return. Check how focus shifts to the Address field. When you tap return again, focus shifts to the Phone field.
Inside CheckoutFormView
, find the validateAllFields
function. Add the following code below TODO: Shift focus to the invalid field
:
if !isNameValid {
checkoutInFocus = .name
} else if !isAddressValid {
checkoutInFocus = .address
} else if !isPhoneValid {
checkoutInFocus = .phone
}
This code contains logic to shift focus to the first invalid field. validateAllFields
is called when the user attempts to proceed to the next checkout step.
Build and run. On the Checkout screen, fill out the Name field, leave the Address field empty, and fill out the Phone field, then tap Proceed to Gift Message. Notice how focus shifts to the first invalid field, which is the Address field, in this case.
You made some great enhancements to focus management in the app so far… but now it’s time to make a mistake. In the next section, you’ll explore what happens when you aren’t careful with focus bindings.
Avoiding Ambiguous Focus Bindings
Everyone makes mistakes. In your case, it’ll be intentional! :] As the number of fields in a form grows, it can be easy to apply focus to the wrong field.
Inside recipientDataView
, after the address EntryTextField
, replace:
.focused($checkoutInFocus, equals: .address)
With:
.focused($checkoutInFocus, equals: .name)
This code binds the same checkoutInFocus
key for both the Name and Address fields.
Build and run. Follow the steps below, and you’ll soon encounter issues with the Checkout Form:
- Go to the Checkout Form. Notice how the focus is on the Address field, not the Name field.
- Type in the Name field, then tap return. Notice how the keyboard is dismissed and focus doesn’t shift to the Address field.
- Finally, set the focus on the Phone field, then tap Proceed to Gift Message. Notice how focus doesn’t shift to the Address field despite it being invalid.
All the strange behavior above is because you bound the same value to two views. Make sure to bind each key of your CheckoutFocusable
enum only once to avoid ambiguous focus bindings.
Before you continue, undo the last change you made. Inside recipientDataView
, after the address EntryTextField
, set focused(_:equals:)
back to:
.focused($checkoutInFocus, equals: .address)
In this section, you learned how to move focus between many views. Now, you’ll take your focus management skills further by handling focus in lists.
Managing Focus in Lists
On the Gift Message screen, the user can add multiple emails for the recipient. You’ll manage the focus inside that list of emails.
Open GiftMessageView.swift. At the top, add the following code, right before GiftMessageView
:
enum GiftMessageFocusable: Hashable {
case message
case row(id: UUID)
}
This code creates an enum to interact with FocusState
inside GiftMessageView
. The message
key is to focus on the Gift Message field. The row
key is to focus on the email list. You choose which item in the email list to focus on by using the id
associated value.
Inside GiftMessageView
, add the following property:
@FocusState private var giftMessageInFocus: GiftMessageFocusable?
This code creates a property to control focus between fields.
Next, in body
, add the following after TextEditor
:
// 1
.focused($giftMessageInFocus, equals: .message)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
// 2
self.giftMessageInFocus = .message
}
}
If you’re thinking “auto-focus”, you’re right! Here’s what this code does:
- It binds the Gift Message field’s focus to the
giftMessageInFocus
property for the enum casemessage
. - When this view appears on screen, it shifts focus to the Gift Message field.
Build and run. Fill out the Checkout Form with valid entries and proceed to the Gift Message screen. Notice how the screen focuses on the Gift Message field when it appears.
Similarly, find the recipientEmailsView
definition. Inside ForEach
, after EntryTextField
, add the following line:
.focused($giftMessageInFocus, equals: .row(id: recipientEmail.id))
This code binds each email’s focus to giftMessageFocusable
for the enum case row
. To determine which email is in focus, you use the id
of each email as the associated value.
Inside body
, add the following code after Form
:
.onSubmit {
let emails = giftMessageViewModel.recipientEmails
// 1
guard let currentFocus = emails.first(where: { giftMessageInFocus == .row(id: $0.id) }) else { return }
for index in emails.indices where currentFocus.id == emails[index].id {
if index == emails.indices.count - 1 {
// 2
giftMessageInFocus = nil
} else {
// 3
giftMessageInFocus = .row(id: emails[index + 1].id)
}
}
}
Here’s what this code does:
- It captures which Email field is now in focus, if there is one.
- When a user taps return while focusing on the last Email field, it releases focus and dismisses the keyboard.
- When a user taps return while focusing on any Email field besides the last one, it shifts focus to the Email field after it.
Find the validateFields
function. Replace it with:
func validateFields() -> Bool {
if !giftMessageViewModel.validateMessagePrompt.isEmpty {
// 1
giftMessageInFocus = .message
return false
} else {
for (key, value) in giftMessageViewModel.validateEmailsPrompts where !value.isEmpty {
// 2
giftMessageInFocus = .row(id: key)
return false
}
// 3
giftMessageInFocus = nil
return true
}
}
Here’s what this code does:
- It shifts focus to the Gift Message field if it’s invalid.
- If any email from the email list is invalid, it shifts focus to that Email field.
- If all fields are valid, it releases focus and dismisses the keyboard.
Build and run. Go to the Gift Message screen, then tap Add new email twice. In the first Email field, type a valid email, then tap return to see how focus shifts to the second Email field. In the third Email field, type a valid email address, then tap Send the Gift to see how focus shifts to the invalid Email field.
You may notice that the code for both CheckoutFormView
and GiftMessageView
is a bit crowded. Moreover, both contain logic that should be in the ViewModel. In the next section, you’ll fix this and learn how to handle FocusState
with MVVM.
Improving Focus Implementation With MVVM
Open CheckoutViewModel.swift, and add the following property:
@Published var checkoutInFocus: CheckoutFocusable?
This code creates a property to control the focus of checkout from CheckoutViewModel
.
Add the following lines below TODO: Toggle Focus
:
func toggleFocus() {
if checkoutInFocus == .name {
checkoutInFocus = .address
} else if checkoutInFocus == .address {
checkoutInFocus = .phone
} else if checkoutInFocus == .phone {
checkoutInFocus = nil
}
}
This code handles what happens when the user taps the return key on the Checkout screen. If it looks familiar, that’s because it’s extracted from onSubmit(of:_:)
in CheckoutFormView
.
Next, add the following lines below TODO: Validate all fields
:
func validateAllFields() {
let isNameValid = validateNamePrompt.isEmpty
let isAddressValid = validateAddressPrompt.isEmpty
let isPhoneValid = validatePhonePrompt.isEmpty
allFieldsValid = isNameValid && isAddressValid && isPhoneValid
if !isNameValid {
checkoutInFocus = .name
} else if !isAddressValid {
checkoutInFocus = .address
} else if !isPhoneValid {
checkoutInFocus = .phone
}
}
Again, this is extracted from CheckoutFormView
into the ViewModel. Now, you’ll update CheckoutFormView
to use these functions.
Open CheckoutFormView.swift. Inside body
, replace onSubmit(of:_:)
with the following:
.onSubmit {
checkoutViewModel.toggleFocus()
}
Instead of keeping the logic inline, you use the toggleFocus
function you just created in the ViewModel.
Next, find giftMessageButton
. Inside CustomButton
, replace:
validateAllFields()
With:
checkoutViewModel.validateAllFields()
Again, inline logic is replaced with logic now in the ViewModel. To clean up, remove the validateAllFields
function from CheckoutFormView
.
Build and run. Go to the Checkout screen. In the Name field, type a name and tap return. Did you encounter strange behavior? The focus doesn’t shift to the Address field. Also, if you tap Proceed to Gift Message, focus doesn’t shift to the first invalid field.
You’ve simply copy-pasted which object your logic lives in. So, what causes this wrong behavior?
The reason is that you have two checkoutInFocus
properties. One is inside CheckoutFormView
, while the other is inside CheckoutViewModel
. But, neither of them is aware of the other’s changes. When validating fields, for example, you only update checkoutInFocus
inside CheckoutViewModel
. You’ll fix this now.
Open CheckoutFormView.swift. Inside recipientDataView
, after Section
, add the following lines:
.onChange(of: checkoutInFocus) { checkoutViewModel.checkoutInFocus = $0 }
.onChange(of: checkoutViewModel.checkoutInFocus) { checkoutInFocus = $0 }
This code syncs the changes that happen to checkoutInFocus
, so both know the current state.
Build and run. Go to the Checkout screen. In the Name field, type a name and tap return. Notice that behavior is back to working as expected.
It’s your turn! Extract the logic for managing focus state from GiftMessageView
to GiftMessageViewModel
. You have all the necessary knowledge, but if you need some help, feel free to refer to the final project.
Now that you’ve improved the app’s user experience for iPhone users, it’s time to attend to iPad users. Your app has an additional feature for larger layouts that would benefit from some focus.
Observing Values From Focused Views
Build and run on an iPad simulator. Go to the Gift Message screen. On the right, you’ll notice a new view available on the iPad layout. It displays a preview of the gift card.
While the user types a gift message, your code observes that text. You’ll show the user a live update of the card’s appearance with the message in it. To do so, you’ll use FocusedValue
, another property wrapper introduced to manage focus state.
Create a new file inside the ViewModel folder. Name it FocusedMessage.swift. Add these lines inside this new file:
import SwiftUI
// 1
struct FocusedMessage: FocusedValueKey {
typealias Value = String
}
// 2
extension FocusedValues {
var messageValue: FocusedMessage.Value? {
get { self[FocusedMessage.self] }
set { self[FocusedMessage.self] = newValue }
}
}
Here’s what this code does:
- It creates a
struct
conforming to theFocusedValueKey
protocol. You need to add thetypealias
forValue
to fulfill this protocol. The type ofValue
is the type of content to observe. Because you want to observe the gift message, the correct type isString
. - It creates a variable to hold the
FocusedValue
calledmessageValue
with a getter and setter.
The FocusedValueKey
protocol and the extension of FocusedValues
is how you can extend the focused values that SwiftUI propagates through the view hierarchy. If you’ve ever added values to the SwiftUI Environment
, you’ll recognize it’s a very similar dance.
Next, you’ll use the messageValue
variable to observe changes in the user’s gift message.
Open GiftMessagePreview.swift, and add the following property:
@FocusedValue(\.messageValue) var messageValue
This code creates a property to observe the newly created messageValue
.
Inside body
, after GeometryReader
, add the following lines:
Text(messageValue ?? "There is no message")
.padding()
This code uses messageValue
to show a live update of the user’s message over the background image.
Finally, open GiftMessageView.swift. Find TextEditor
inside body
, and add the following modifier to it:
.focusedValue(\.messageValue, giftMessageViewModel.checkoutData.giftMessage)
This code binds the changes that happen to the giftMessage
property in the ViewModel to messageValue
. But, what changes giftMessage
?
Notice the initialization for the text field: TextEditor(text: $giftMessageViewModel.checkoutData.giftMessage)
. The binding passed into TextEditor
triggers updates to the giftMessage
property as the user types in the text field. In turn, messageValue
is updated because it’s now bound to giftMessage
. Lastly, messageValue
is observed and displayed on a different view. The result is that any text typed in the Gift Message field will reflect in the preview.
Build and run. Go to the Gift Message screen. Notice how the preview on the right shows the same text as the message field on the left. Change the text inside the Gift Message field, and notice how a live update occurs in the preview on the right even though they’re two different views.
Just like that, you’re now reading the value of a focusable view in one view from another. In the next section, you’ll take it one step further by modifying a focused value between views.
Modifying Values From Focused Views
You’ll add a little personality to the gift card by replacing plain text with emojis! :]
Open FocusedMessage.swift. Replace the FocusedMessage
struct with the following lines:
struct FocusedMessage: FocusedValueKey {
typealias Value = Binding<String>
}
In this code, you change the type of Value
from String
to Binding<String>
to enable updating its value in addition to observing it.
Open GiftMessagePreview.swift. Replace FocusedValue
with:
@FocusedBinding(\.messageValue) var messageValue
Again, you change the type to be a binding — in this case FocusedBinding
— to enable modification.
Inside body
, find ZStack
. Add the following modifier to Text
, after padding(_:_:)
:
.onChange(of: messageValue) { _ in
giftMessageViewModel.checkTextToEmoji()
}
This code tracks the changes in the message, then checks if the last word can be converted to an emoji.
Next, inside emojiSuggestionView
, add the following code within the first parameter of Button
:
if let message = messageValue {
messageValue = TextToEmojiTranslator.replaceLastWord(from: message)
}
This code modifies messageValue
directly. It replaces the last word with the matched emoji if the user taps the emoji button.
Finally, open GiftMessageView.swift. Inside body
, replace focusedValue(_:_:)
of TextEditor
with:
.focusedValue(\.messageValue, $giftMessageViewModel.checkoutData.giftMessage)
The addition of that little $
keeps the compiler happy now that the type for messageValue
is Binding
.
Build and run. Go to the Gift Message screen. In the message field, type :D. Notice how the Suggested Emoji on the right shows 😃. Tap this emoji, and watch how the message text on the left changes to show the emoji.
Now, both views are able to effect change on one another.
You’ve come a long way in improving the experience of your app by being thoughtful with focus management. Time to get yourself a gift!
Where to Go From Here?
Download the final project using the Download Materials button at the top or bottom of this tutorial.
You’ve learned a lot about managing the focus in SwiftUI apps. This includes how to:
- Use the
FocusState
property wrapper with both thefocused(_:)
andfocused(_:equals:)
modifiers. - Manage focus in lists and with the MVVM design pattern.
- Use the
FocusedValue
andFocusedBinding
property wrappers to track and change the wrapped values of focused views from other scenes.
To learn more about dealing with text fields and forms, watch Handling Keyboard & Pointer Interactions in SwiftUI. In this course, you’ll learn how to use the iOS keyboard, external hardware keyboards and pointer interactions in a SwiftUI app.
To get more stylish with your forms, check out AttributedString Tutorial for Swift: Getting Started. In this article, you’ll learn how to format text and create custom styles with iOS 15’s new AttributedString value type.
I hope you found this tutorial useful. If you have any comments or questions, feel free to join in the forum discussion below!