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

  1. add the @FocusState back to the view
  2. mark focusedReminder every bit an @Published holding on the view model
  3. 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 enums 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 Reminders, 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

  1. add together the @FocusState back to the view
  2. mark focusedReminder every bit an @Published property on the view model
  3. 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 Reminders 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 Lists. 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! 🔥

moralezliamed.blogspot.com

Source: https://peterfriese.dev/posts/swiftui-list-focus

0 Response to "Focus Go Away When Clicked Again"

Post a Comment

Iklan Atas Artikel

Iklan Tengah Artikel 1

Iklan Tengah Artikel 2

Iklan Bawah Artikel