Simplifying Focus Management in SwiftUI with a Custom ViewModifier


Greetings, traveler!

SwiftUI’s @FocusState property wrapper is a powerful tool for managing focus in your app’s UI, but it comes with some limitations that can make it cumbersome to use in certain scenarios. For instance, it doesn’t integrate seamlessly with @Binding, can’t be used in view models, is tricky to pass between views, and behaves slightly differently from @Binding when it comes to propagating updates to parent views. To address these challenges, I’ve created a custom solution: a ViewModifier that allows you to manage focus using a @Binding in a more straightforward and flexible way. In this article, we’ll explore this solution, dive into the code, and see how it can streamline focus management in your SwiftUI projects.

The Problem with @FocusState

The @FocusState property wrapper was introduced in SwiftUI to handle focus for elements like text fields or custom views. While it’s great for simple use cases, it quickly becomes limiting in more complex scenarios. For example:

  • Lack of Interoperability with @Binding: You can’t directly tie @FocusState to a @Binding, which makes it harder to manage focus in a reactive, state-driven way.
  • Not Suitable for View Models: Since @FocusState is a view-specific property wrapper, it can’t be used in view models or other non-view contexts.
  • Passing Between Views: Sharing focus state between views often requires additional boilerplate, making your code less clean and harder to maintain.

These issues can lead to a clunky developer experience, especially in larger apps where focus management needs to be more dynamic and integrated with the app’s state.

A Better Solution: FocusModifier

To overcome these limitations, we can create a custom ViewModifier called FocusModifier that allows you to manage focus using a @Binding to a value. Let’s break down the solution.

The Code

Here’s the complete implementation:

extension View {
    func isFocused<T: Hashable>(
        _ binding: Binding<T?>,
        equals value: T
    ) -> some View {
        
        modifier(
            FocusModifier(
                binding: binding,
                value: value
            )
        )
    }
}

private struct FocusModifier<T: Hashable>: ViewModifier {
    @Binding var binding: T?
    let value: T
    @FocusState private var focused: Bool

    func body(content: Content) -> some View {
        content
        		.focused($focused)
            .onChange(of: binding) { _, newValue in
                focused = (newValue == value)
            }
            .onChange(of: focused) { _, newValue in
                if newValue {
                    binding = value
                } else if binding == value {
                    binding = nil
                }
            }
    }
}

How It Works

The isFocused Extension:

  • This is a convenience method added to the View protocol, allowing any SwiftUI view to use the FocusModifier.
  • It takes a Binding<T?> (a nullable value) and a value of type T that the binding will be compared against. The T type must conform to Hashable to ensure it can be used for comparisons.
  • The method applies the FocusModifier to the view, passing the binding and value as parameters.

The FocusModifier:

  • This struct conforms to the ViewModifier protocol, which allows it to modify the appearance or behavior of a SwiftUI view.
  • It has three properties:
    • @Binding var binding: T?: The binding to the value that controls focus.
    • let value: T: The specific value that, when matched with the binding, indicates that the view should be focused.
    • @FocusState private var focused: Bool: A private focus state to manage whether the view is currently focused.
  • The body method:
    • It uses .onChange(of: binding) to monitor changes to the binding. If the binding’s value matches the specified value, focused is set to true, focusing the view.
    • It uses .focused to handle focusing on the input view.
    • It also uses .onChange(of: focused) to update the binding based on the focus state. If the view becomes focused (focused is true), the binding is set to the specified value. If the view loses focus and the binding still matches the value, the binding is set to nil.

Why This Approach is More Convenient

This FocusModifier addresses the pain points of @FocusState in several ways:

  • Seamless Integration with @Binding: You can now manage focus using a @Binding, which is already a core part of SwiftUI’s state management system. This makes it easier to tie focus to your app’s state or view model.
  • Flexibility Across Views: You can pass the @Binding to any view in your hierarchy, making it easier to share focus state without additional boilerplate.
  • View Model Compatibility: Because the focus is managed via a @Binding, you can store the binding’s source in a view model or other external state, keeping your views lightweight and focused on presentation.

Example Usage

Let’s see how you can use this modifier in a real-world scenario. Imagine you have a form with multiple text fields, and you want to manage focus between them using a @Binding.

struct ExampleView: View {
    
    enum Field: String, CaseIterable, Identifiable {
        case first
        case second
        
        var id: String { rawValue }
    }
    
    @State private var focused: Field?
    @State private var firstText: String
    @State private var secondText: String
    
    var body: some View {
        ForEach(Field.allCases) { field in
            TextField(
                field.rawValue,
                text: field == .first ? $firstText : $secondText
            )
            .isFocused($focused, equals: field)
        }
    }
    
}

Conclusion

Managing focus in SwiftUI doesn’t have to be a headache. By using a custom ViewModifier like FocusModifier, you can overcome the limitations of @FocusState and create a more seamless, reactive focus management system that integrates naturally with @Binding. This approach not only simplifies your code but also makes it more maintainable and scalable for larger projects.