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.

5 (3) · 1 Review

Download materials
Save for later
Share

Learn how to manage focus in SwiftUI by improving the user experience for a checkout form.

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! :]

Swifty with shopping bags.

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.

The user selects a gift and then proceeds to the checkout process.

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.

Note: The app supports the model-view-viewmodel (MVVM) pattern. To learn more about various design patterns, check out Design Patterns by Tutorials.

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.

Shows how the focus shifts to the name textfield in checkout screen.

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:

  1. It uses the focused(_:equals:) modifier to bind the Name field’s focus to the checkoutInFocus property for the enum case name.
  2. 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.

A checkout form with focus on name field.

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.

When the user presses the return key, the focus shifts between checkout fields

Note: If you’re unable to see the keyboard on Simulator when selecting a field, press Command-K or select I/OKeyboardToggle Software Keyboard from the Simulator menu.

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.

Focus shifts to the first invalid field in checkout form.

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:

  1. Go to the Checkout Form. Notice how the focus is on the Address field, not the Name field.
  2. Type in the Name field, then tap return. Notice how the keyboard is dismissed and focus doesn’t shift to the Address field.
  3. 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.

Wrong behavior in checkout focus.

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:

  1. It binds the Gift Message field’s focus to the giftMessageInFocus property for the enum case message.
  2. 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.

Message screen starts with the focus on the message field

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:

  1. It captures which Email field is now in focus, if there is one.
  2. When a user taps return while focusing on the last Email field, it releases focus and dismisses the keyboard.
  3. 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:

  1. It shifts focus to the Gift Message field if it’s invalid.
  2. If any email from the email list is invalid, it shifts focus to that Email field.
  3. 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.

Focus shifts between emails fields when the user presses return button.

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.

Wrong behavior inside checkout page after applying MVVM

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.

Shifting focus in Checkout page after applying MVVM

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.

Gift Message screen in iPad with card preview on the right.

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:

  1. It creates a struct conforming to the FocusedValueKey protocol. You need to add the typealias for Value to fulfill this protocol. The type of Value is the type of content to observe. Because you want to observe the gift message, the correct type is String.
  2. It creates a variable to hold the FocusedValue called messageValue 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.

Preview text on the right reflects changes of the text on the left.

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.

Change the text in gift message to show matched 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!

Swifty celebrating.

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 the focused(_:) and focused(_:equals:) modifiers.
  • Manage focus in lists and with the MVVM design pattern.
  • Use the FocusedValue and FocusedBinding 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!