Focus management is a usability feature that is especially important for productivity apps. Let’s face it, users don’t use a to-do list app because they enjoy planning their day so much - they’d probably much rather read a book, watch a movie, or go for a walk. The ideal user experience in a productivity app lets the user get their job done as quickly as possible. And this is why focus management is so essential.
In Apple’s Reminders app, the user can create a new reminder by tapping on a button. This will add a new, empty reminder, and place the cursor into the new reminder, allowing the users to start entering the title for the new item straight away. Once the user has finished entering the reminder, they can just tap the [Enter] key to advance. This will create a new reminder in the next line.
If you watch closely, you will also notice that, once they’ve entered the text for a to-do item, the user can tap Enter again to create a new to-do item below. This makes entering items very efficient: the user doesn’t have to tap on any buttons to create new items - everything can be done by just using the keyboard.
Apple introduced APIs for managing focus in iOS 15, and there are a bunch of great articles out there that cover how to use this feature in input forms. Unfortunately, there is little information about how to use this feature in List views, and my own experimentation showed that focus management doesn’t work in List views. Or so I thought - until I brought this up in this Twitter thread that I use as a sort of development journal. @erithacus_ and @thecraftybrit mentioned they had been bothered by this as well, and shortly after @thecraftybrit mentioned he had solved this for Xcode 13.1 beta and iOS 15.2 beta, which allowed me to implement this in MakeItSo.
So even though this doesn’t work on physical devices just yet, let me walk you through the individual pieces of the solution.
Indicating which UI element should be focused is easy for UIs that have a predefined number of elements - we can define an enum with a value for each of the UI elements. This doesn’t work for lists, as the number of rows is dynamic. To solve this, we will use an enum with an associated value. This associated value will contain the id of the selected element, which will allows us to easily focus an element or track which element the user focused.
As we want to keep as much of the applications logic outside of the actual views, we use view models to hold the code that is closely related to each individual view. This means that the code for adding new elements also lives in a view model. So, when we add a new to-do element (e.g. when the user tapped the New Reminder button, or when they placed the cursor in the list and hit the Enter key), we need to set the focus from inside a method on the view model. Unfortunately,
@FocusState can only be used on View s, so we cannot use it on our view model (which is a class conforming to
ObservableObject). We can solve this by implementing a mechanism that syncs between a property on the view model and the view. I know… this sounds a lot like an
@Published property, but since property wrappers cannot be composed (yet?), we need to use a different approach, and sync using the
.onChangeOf view modifier. Trust me, this works brilliantly.
When the user taps the Enter key while one of the to-do items is focused, we want to create a new item below and place the focus inside this new element. Detecting the Enter key is actually one of the things that is pretty straight forward - we can use the
.onSubmit view modifier.
And finally, we want to remove empty elements from the list once they lose focus. To achieve this, we will use a Combine pipeline (on the view model) that tracks the previously focused element and removes it from the list of reminders if it is empty. As you will see, building this pipeline has its own challenges to make sure the animations on the list look smooth.
To learn more about how all of this works in detail and see all the code snippets, head over to my blog to read my article Managing Focus in SwiftUI List Views. You can also check out this commit on the repository - it contains all the relevant bits and pieces.
Thanks to @thecraftybrit and @erithacus_ for helping me implement this feature and convincing me to not give up when I thought this wasn’t possible in pure SwiftUI. Check out our conversation on Twitter if you’re interested - it’s great to be part of a community that supports each other.