Managing Focus in SwiftUI List Views
Make It So - Again!
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.
To synchronise the focus state between the view model and the view, we can
- add the
@FocusState
back to the view - mark
focusedReminder
as an@Published
property on the view model - and sync them using
onChange(of:)
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.
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 enum
s support associated values. This allows us to define an enum
that can hold the id
of a list element we want to focus:
With this in place, we can define a local variable focusedReminder
that is an instance of the Focusable
enum and wrap it using @FocusState
.
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:
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:
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:
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 Reminder
s, the focus state, and the code to create a new reminder:
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:
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:
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
- add the
@FocusState
back to the view - mark
focusedReminder
as an@Published
property on the view model - and sync them using
onChange(of:)
Like this:
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 (previousFocusedReminder
(1)) 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 (2):
Conclusion
This was a whirlwind overview of how to implement focus management for SwiftUI List
s. 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:
- ✨ Implement focus management · peterfriese/MakeItSo@fbcc56f
- ✨ Remove empty tasks when cell loses focus · peterfriese/MakeItSo@0dd0b72
If you want to follow along as I continue developing MakeItSo, subscribe to my newsletter, or follow me on Twitter.
Thanks for reading! 🔥
Improve your app's UX with SwiftUI's task view modifier
Mastering the art of the pause
SwiftUI Hero Animations with NavigationTransition
Replicating the App Store Hero Animation
Styling SwiftUI Views
How does view styling work?
Previewing Stateful SwiftUI Views
Interactive Previews for your SwiftUI views
Asynchronous programming with SwiftUI and Combine
The Future of Combine and async/await