Focus Go Away When Clicked 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 central, and on mobile it'due south no less important. In Apple's Reminders app, for instance, the cursor will automatically be placed in whatever new reminder you create, and will accelerate to the next row when you tap the enter key. This way, you can add together 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 tree's own documentation and on other people's blogs and videos merely discuss how to use this in elementary forms, such as a login grade. Advanced utilise cases, such as managing focus in an editable list, aren't covered.
In this commodity, I will show yous how to manage focus state in an app that allows users to edit elements in a list. Every bit an example, I am going to use Make It So, a to-practice listing app I am working on. Make Information technology So is a replica of Apple's Reminders app, and the idea is to figure out how close nosotros can get to the original using only SwiftUI and Firebase.
How to manage focus in SwiftUI
A word of alert: the following will only work in SwiftUI 3 on iOS xv.2, so you lot will need Xcode 13.2 beta. At the fourth dimension of this writing, there wasn't a build of iOS 15.2 for physical devices, then you'll only be able to apply this on the Simulator - for now. I am confident Apple volition brand this available soon, and they might even send a bug fix to current versions of iOS.
To synchronise the focus land betwixt the view model and the view, nosotros tin
- add the
@FocusState
back to the view- mark
focusedReminder
every bit an@Published
holding 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 inside a scene.
Yous tin either employ 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 rail focus for a simple user profile form. Equally you lot can run into in the Button
'southward closure, we can programmatically set the focus, for case if the user forgot to fill out a mandatory field.
enum FocusableField: Hashable { case firstName instance lastName } struct FocusUsingEnumView: View { @FocusState private var focus: FocusableField? @State individual var firstName = "" @State individual var lastName = "" var body: some View { Form { TextField("Outset Name", text: $firstName) .focused($focus, equals: .firstName) TextField("Concluding 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 accept all but a few input elements, only it's not feasible for Listing
views or other dynamic views that display an unbounded number of elements.
How to manage focus in Lists
To manage focus in Listing
views, nosotros tin make use of the fact that Swift enum
s support associated values. This allows us to ascertain an enum
that can concur the id
of a list element nosotros want to focus:
enum Focusable: Hashable { case none case row(id: String) }
With this in identify, nosotros can ascertain a local variable focusedReminder
that is an case of the Focusable
enum and wrap information technology 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
assortment. To set the focus into the row for this newly created reminder, all we need to practise is create an instance of the Focusable
enum using the new reminder's id
equally 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 yous need to implement bones focus management in SwiftUI List
views!
Handling the Enter Key
Let's at present turn our focus to some other 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 tin can use the .onSubmit
view modifier to run code when the user submits a value to a view. Past default, this will be triggered when the user taps the Enter primal:
... 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 listing. This is a fleck unexpected in example the user was just editing a to-do at the start or in the middle of the list.
Allow's update our lawmaking for inserting new items and make certain new items are inserted direct later the currently focused element:
... func createNewTask() { permit newReminder = Reminder(title: "") // if any row is focused, insert the new task after the focused row if example .row(permit id) = focusedTask { if let index = reminders.firstIndex(where: { $0.id == id } ) { reminders.insert(newReminder, at: index + 1) } } // no row focused: append at the terminate of the list else { reminders.suspend(newReminder) } // focus the new task focusedTask = .row(id: newReminder.id) } ...
This works neat, but there is a small consequence with this: if the user hits the Enter key several times in a row without entering whatever text, we will finish up with a bunch of empty rows - not ideal. The Reminders app automatically removes empty rows, and so permit's see if we can implement this also.
If you lot've followed forth, you lot might discover 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 y'all who take been following my blog and my videos know that I am a fan of using the MVVM arroyo in SwiftUI, and then allow'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 aforementioned time.
Ideally, the view model should incorporate the array of Reminder
s, the focus country, and the code to create a new reminder:
form ReminderListViewModel: ObservableObject { @Published var reminders: [Reminder] = Reminder.samples @FocusState var focusedReminder: Focusable? func createNewReminder() { allow newReminder = Reminder(title: "") // if any row is focused, insert the new reminder later on the focused row if instance .row(let id) = focusedReminder { if permit index = reminders.firstIndex(where: { $0.id == id } ) { reminders.insert(newReminder, at: index + i) } } // no row focused: append at the end of the list else { reminders.suspend(newReminder) } // focus the new reminder focusedReminder = .row(id: newReminder.id) } }
Notice how nosotros're accessing the focusedReminder
focus country inside of createNewReminder
to find out where to insert the new reminder, and then fix the focus on the newly added / inserted reminder.
Plainly, the FocusableListView
view needs to be updated likewise to reflect the fact that nosotros'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) { TextField("", text: $reminder.championship) .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, merely when running this lawmaking, you volition notice the focus handling no longer works, and instead nosotros receive a SwiftUI runtime warning that says Accessing FocusState'due south value outside of the torso of a View. This will result in a abiding Binding of the initial value and volition non update:
This is considering @FocusState
conforms to DynamicProperty
, which can only be used inside views.
Then we demand to detect some other fashion to synchronise the focus state between the view and the view model. 1 way to react to changes on backdrop of views is the .onChange(of:)
view modifier.
To synchronise the focus state between the view model and the view, we can
- add together the
@FocusState
back to the view - mark
focusedReminder
every bit an@Published
property on the view model - and sync them using
onChange(of:)
Like this:
form ReminderListViewModel: ObservableObject { @Published var reminders: [Reminder] = Reminder.samples @Published var focusedReminder: Focusable? // ... } struct FocusableListView: View { @StateObject var viewModel = ReminderListViewModel() @FocusState var focusedReminder: Focusable? var trunk: some View { Listing { 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 farther past extracting the code for syncing into an extension on
View
.
And with this, nosotros've cleaned upwardly our implementation - the view focuses on the brandish aspects, whereas the view model handles updating the information model and translating between the view and the model
Eliminating empty elements
Using a view model gives united states some other nice do good - 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 holding. This volition allow us to discover when the previously focused element is an empty element and consequently remove information technology.
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 (ii):
class ReminderListViewModel: ObservableObject { @Published var reminders: [Reminder] = Reminder.samples @Published var focusedReminder: Focusable? var previousFocusedReminder: Focusable? ane private var cancellables = Set<AnyCancellable>() init() { $focusedReminder .compactMap { focusedReminder -> Int? in defer { self.previousFocusedReminder = focusedReminder } guard focusedReminder != zip else { return nil } baby-sit case .row(let previousId) = self.previousFocusedReminder else { return nil } guard let previousIndex = self.reminders.firstIndex(where: { $0.id == previousId } ) else { render nothing } guard self.reminders[previousIndex].title.isEmpty else { return nil } render previousIndex } .delay(for: 0.01, scheduler: RunLoop.main) // <-- this helps reduce the visual jank .sink { index in self.reminders.remove(at: alphabetize) 2 } .store(in: &cancellables) } // ... }
Conclusion
This was a whirlwind overview of how to implement focus management for SwiftUI List
s. The consequence 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 - later all, it's an attempt to replicate the Reminders app every bit closely as possible.
The code lives in the develop branch, and here are the two commits that contain the lawmaking we discussed in this blog post:
- ✨ Implement focus management · peterfriese/MakeItSo@fbcc56f
- ✨ Remove empty tasks when cell loses focus · peterfriese/MakeItSo@0dd0b72
If you lot want to follow along as I continue developing MakeItSo, subscribe to my newsletter, or follow me on Twitter.
Cheers for reading! 🔥
Source: https://peterfriese.dev/posts/swiftui-list-focus
0 Response to "Focus Go Away When Clicked Again"
Post a Comment