Managing Focus in SwiftUI List Views

Make It So - Again!

November 05, 2021 - 5 min read

Managing focus is an important aspect for almost any sort of UI - getting this right helps your users navigate your app faster and more efficiently. In desktop UIs, we have come to expect being able to navigate through the input fields on a form by pressing the tab key, and on mobile it’s no less important. In Apple’s Reminders app, for example, the cursor will automatically be placed in any new reminder you create, and will advance to the next row when you tap the enter key. This way, you can add new elements very efficiently.

Apple added support for handling focus in the latest version of SwiftUI - this includes both setting and observing focus.

Most examples both in Apple’s own documentation and on other people’s blogs and videos only discuss how to use this in simple forms, such as a login form. Advanced use cases, such as managing focus in an editable list, aren’t covered.

In this article, I will show you how to manage focus state in an app that allows users to edit elements in a list. As an example, I am going to use Make It So, a to-do list app I am working on. Make It So is a replica of Apple’s Reminders app, and the idea is to figure out how close we can get to the original using only SwiftUI and Firebase.

How to manage focus in SwiftUI

A word of warning: the following will only work in SwiftUI 3 on iOS 15.2, so you will need Xcode 13.2 beta. At the time of this writing, there wasn’t a build of iOS 15.2 for physical devices, so you’ll only be able to use this on the Simulator - for now. I am confident Apple will make this available soon, and they might even ship a bug fix to current versions of iOS.

At WWDC 2021, Apple introduced @FocusState, a property wrapper that can be used to track and modify focus within a scene.

You can either use a Bool or an enum to track which element of your UI is focused.

The following example makes use of an enum with two cases to track focus for a simple user profile form. As you can see in the Button’s closure, we can programmatically set the focus, for example if the user forgot to fill out a mandatory field.

enum FocusableField: Hashable {
  case firstName
  case lastName
}

struct FocusUsingEnumView: View {
  @FocusState private var focus: FocusableField?
  
  @State private var firstName = ""
  @State private var lastName = ""
  
  var body: some View {
    Form {
      TextField("First Name", text: $firstName)
        .focused($focus, equals: .firstName)
      TextField("Last Name", text: $lastName)
        .focused($focus, equals: .lastName)
      
      Button("Save") {
        if firstName.isEmpty {
          focus = .firstName
        }
        else if lastName.isEmpty {
          focus = .lastName
        }
        else {
          focus = nil
        }
      }
    }
  }
}

This approach works fine for simple input forms that have all but a few input elements, but it’s not feasible for List views or other dynamic views that display an unbounded number of elements.

How to manage focus in Lists

To manage focus in List views, we can make use of the fact that Swift enums support associated values. This allows us to define an enum that can hold the id of a list element we want to focus:

enum Focusable: Hashable {
  case none
  case row(id: String)
}

With this in place, we can define a local variable focusedReminder that is an instance of the Focusable enum and wrap it using @FocusState.

struct Reminder: Identifiable {
  var id: String = UUID().uuidString
  var title: String
}

struct FocusableListView: View {
  @State var reminders: [Reminder] = Reminder.samples
  
  @FocusState var focusedReminder: Focusable? 
  
  var body: some View {
    List {
      ForEach($reminders) { $reminder in
        TextField("", text: $reminder.title)
          .focused($focusedReminder, equals: .row(id: reminder.id)) 
      }
    }
    .toolbar {
      ToolbarItemGroup(placement: .bottomBar) {
        Button(action: { createNewReminder() }) {
          Text("New Reminder")
        }
      }
    }
  }

  // ...
  
}

When the user taps the New Reminder toolbar button, we add a new Reminder to the reminders array. To set the focus into the row for this newly created reminder, all we need to do is create an instance of the Focusable enum using the new reminder’s id as the associated value, and assign it to the focusedReminder property:

struct FocusableListView: View {

  // ...

  func createNewReminder() {
    let newReminder = Reminder(title: "")
    reminders.append(newReminder)
    focusedReminder = .row(id: newReminder.id) 
  }

}

And that is pretty much everything you need to implement basic focus management in SwiftUI List views!

Handling the Enter Key

Let’s now turn our focus to another feature of Apple’s Reminder app that will improve the UX of our application: adding new elements (and focusing them) when the user hits the Enter key.

We can use the .onSubmit view modifier to run code when the user submits a value to a view. By default, this will be triggered when the user taps the Enter key:

... 
TextField("", text: $reminder.title)
  .focused($focusedTask, equals: .row(id: reminder.id))
  .onSubmit {
    createNewTask()
  }
...

This works fine, but all new elements will be added to the end of the list. This is a bit unexpected in case the user was just editing a to-do at the beginning or in the middle of the list.

Let’s update our code for inserting new items and make sure new items are inserted directly after the currently focused element:

...
func createNewTask() {
  let newReminder = Reminder(title: "")
  
  // if any row is focused, insert the new task after the focused row
  if case .row(let id) = focusedTask {
    if let index = reminders.firstIndex(where: { $0.id == id } ) {
      reminders.insert(newReminder, at: index + 1)
    }
  }
  // no row focused: append at the end of the list
  else {
    reminders.append(newReminder)
  }
  
  // focus the new task
  focusedTask = .row(id: newReminder.id)
}
...

This works great, but there is a small issue with this: if the user hits the Enter key several times in a row without entering any text, we will end up with a bunch of empty rows - not ideal. The Reminders app automatically removes empty rows, so let’s see if we can implement this as well.

If you’ve followed along, you might notice another issue: the code for our view is getting more and more crowded, and we’re mixing declarative UI code with a lot of imperative code.

What about MVVM?

Now those of you who have been following my blog and my videos know that I am a fan of using the MVVM approach in SwiftUI, so let’s take a look at how we can introduce a view model to declutter the view code and implement a solution for removing empty rows at the same time.

Ideally, the view model should contain the array of Reminders, the focus state, and the code to create a new reminder:

class ReminderListViewModel: ObservableObject {
  @Published var reminders: [Reminder] = Reminder.samples
  
  @FocusState
  var focusedReminder: Focusable? 
  
  func createNewReminder() {
    let newReminder = Reminder(title: "")

    // if any row is focused, insert the new reminder after the focused row
    if case .row(let id) = focusedReminder { 
      if let index = reminders.firstIndex(where: { $0.id == id } ) {        
        reminders.insert(newReminder, at: index + 1)
      }
    }
    // no row focused: append at the end of the list
    else {
      reminders.append(newReminder)
    }
    
    // focus the new reminder
    focusedReminder = .row(id: newReminder.id) 
  }
}

Notice how we’re accessing the focusedReminder focus state inside of createNewReminder to find out where to insert the new reminder, and then set the focus on the newly added / inserted reminder.

Obviously, the FocusableListView view needs to be updated as well to reflect the fact that we’re no longer using a local @State variable, but an @ObservableObject instead:

struct FocusableListView: View {
  @StateObject var viewModel = ReminderListViewModel(). 
  
  var body: some View {
    List {
      ForEach($viewModel.reminders) { $reminder in 
        TextField("", text: $reminder.title)
          .focused(viewModel.$focusedReminder, equals: .row(id: reminder.id)) 
          .onSubmit {
            viewModel.createNewReminder() 
          }
      }
    }
    .toolbar {
      ToolbarItem(placement: .bottomBar) {
        Button(action: { viewModel.createNewReminder() }) { 
          Text("New Reminder")
        }
      }
    }
  } 
}

This all looks great, but when running this code, you will notice the focus handling no longer works, and instead we receive a SwiftUI runtime warning that says Accessing FocusState’s value outside of the body of a View. This will result in a constant Binding of the initial value and will not update:

focus error

This is because @FocusState conforms to DynamicProperty, which can only be used inside views.

So we need to find another way to synchronise the focus state between the view and the view model. One way to react to changes on properties of views is the .onChange(of:) view modifier.

To synchronise the focus state between the view model and the view, we can

  1. add the @FocusState back to the view
  2. mark focusedReminder as an @Published property on the view model
  3. and sync them using onChange(of:)

Like this:

class ReminderListViewModel: ObservableObject {
  @Published var reminders: [Reminder] = Reminder.samples
  
  @Published var focusedReminder: Focusable? 
  // ...
}

struct FocusableListView: View {
  @StateObject var viewModel = ReminderListViewModel()
  
  @FocusState var focusedReminder: Focusable? 
  
  var body: some View {
    List {
      ForEach($viewModel.reminders) { $reminder in
        // ...
      }
    }
    .onChange(of: focusedReminder)  { viewModel.focusedReminder = $0 } 
    .onChange(of: viewModel.focusedReminder) { focusedReminder = $0 } 
    // ...
  } 
}

Side note: this can be cleaned up even further by extracting the code for syncing into an extension on View.

And with this, we’ve cleaned up our implementation - the view focuses on the display aspects, whereas the view model handles updating the data model and translating between the view and the model

Eliminating empty elements

Using a view model gives us another nice benefit - since the focusedReminder property on the view model is a published property, we can attach a Combine pipeline to it and react to changes of the property. This will allow us to detect when the previously focused element is an empty element and consequently remove it.

To do this, we will need an additional property on the view model to keep track of the previously focused Reminder, and then install a Combine pipeline that removes empty Reminder s once their row loses focus:

class ReminderListViewModel: ObservableObject {
  @Published var reminders: [Reminder] = Reminder.samples
  
  @Published var focusedReminder: Focusable?
  var previousFocusedReminder: Focusable?
  
  private var cancellables = Set<AnyCancellable>()
  
  init() {
    $focusedReminder
      .compactMap { focusedReminder -> Int? in
        defer { self.previousFocusedReminder = focusedReminder }
        
        guard focusedReminder != nil else { return nil }
        guard case .row(let previousId) = self.previousFocusedReminder else { return nil }
        guard let previousIndex = self.reminders.firstIndex(where: { $0.id == previousId } ) else { return nil }
        guard self.reminders[previousIndex].title.isEmpty else { return nil }
        
        return previousIndex
      }
      .delay(for: 0.01, scheduler: RunLoop.main) // <-- this helps reduce the visual jank
      .sink { index in
        self.reminders.remove(at: index)
      }
      .store(in: &cancellables)
  }

  // ...
}

Conclusion

This was a whirlwind overview of how to implement focus management for SwiftUI Lists. The result looks pretty compelling:

To see how this code can be used in a larger context, check out the repo for MakeItSo. MakeItSo’s UI is much closer to the original - after all, it’s an attempt to replicate the Reminders app as closely as possible.

The code lives in the develop branch, and here are the two commits that contain the code we discussed in this blog post:

If you want to follow along as I continue developing MakeItSo, subscribe to my newsletter, or follow me on Twitter.

Thanks for reading! 🔥


Read next