Search code examples
swiftui

SwiftUI: dismiss keyboard on button click for TextField in deep hierarchy


I have a TextField somewhere deep in the hierarchy, and a Button that need to dismiss the TextField's keyboard upon tapping, the example I found so far are:

  1. Use FocusState and set it to dismiss keyboard in button's action
  2. Use UIApplication.shared.sendActio(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) in button action

If I prefer method 1, do I have to pass a focused state variable all the way to the TextField, or is there a better approach? Thanks!


Solution

  • If you don't want to pass the focus state binding through all those intermediate views, an alternative would be to pass the focus information with environment (from parent to child) and preference (from child to parent).

    This is quite a lot of boilerplate, and might be more effort than passing the focus state binding, depending on how deeply nested the text fields are.

    Suppose we have some text fields that we want to control focus, that is nested in a view called Nested.

    enum FocusedTextField {
        case one, two
    }
    
    struct Nested: View {
        var body: some View {
            TextFields()
        }
    }
    
    struct TextFields: View {
        @State var text1 = "Foo"
        @State var text2 = "Bar"
        @FocusState var focus: FocusedTextField?
        
        var body: some View {
            Group {
                TextField("One", text: $text1)
                    .focused($focus, equals: .one)
                TextField("Two", text: $text2)
                    .focused($focus, equals: .two)
            }
        }
    }
    

    We can now add a FocusKey that is both an environment key and a preference key:

    struct FocusKey: EnvironmentKey, PreferenceKey {
        static func reduce(value: inout FocusedTextField?, nextValue: () -> FocusedTextField?) {
            // "??", because we only want to find the focused view across the sibling views
            value = nextValue() ?? value
        }
        
        static let defaultValue: FocusedTextField? = nil
    }
    
    extension EnvironmentValues {
        var focus: FocusedTextField? {
            get { self[FocusKey.self] }
            set { self[FocusKey.self] = newValue }
        }
    }
    

    Now in TextFields, we can add the \.focus environment, set focus when the environment changes, and also set the preference to focus.

    @Environment(\.focus) var focusFromEnvironment
    
    // ...
    
    Group {
        ...
    }
    .onChange(of: focusFromEnvironment) { _, new in
        focus = new
    }
    .preference(key: FocusKey.self, value: focus)
    

    Now, in the root view, we can have an unfocus button like this:

    struct ContentView: View {
        @State var focus: FocusedTextField? = nil
        var body: some View {
            Nested()
                .environment(\.focus, focus)
                .onPreferenceChange(FocusKey.self) { new in
                    // when something is focused, this gets called
                    focus = new
                }
            Button("Unfocus") {
                // setting this changes the environment
                focus = nil
            }
        }
    }