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?
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!