How do I pass FocusState through a ViewModifier?

197 Views Asked by At

I'm trying to create a ViewModifier that moves a field to the top of the screen when it becomes focused. The effect I want is achieved when I pass through boolean binding that gets toggled using a button, like so:

import SwiftUI

let movableFieldID = "MyTextField"

struct TopMovable: ViewModifier {
    @Binding var text: String
    @Binding var active: Bool
    var namespace: Namespace.ID

    func body(content: Content) -> some View {
        if active {
            VStack {
                HStack {
                    Spacer()
                    Button("Dismiss") {
                        withAnimation {
                            active = false
                        }
                    }
                }
                TextField("Enter text", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .matchedGeometryEffect(id: movableFieldID, in: namespace)
                Spacer()
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        } else {
            content
        }
    }
}

extension View {
    func topMovable(text: Binding<String>,
                    active: Binding<Bool>,
                    namespace: Namespace.ID) -> some View {
        self.modifier(TopMovable(text: text,
                                 active: active,
                                 namespace: namespace))
    }
}

struct ContentView: View {
    @Namespace private var namespace
    @State var active: Bool = false
    @State var text: String = ""

    var body: some View {
        VStack {
            Text("Hello, world!")
            Text("Hi!")
            TextField("Enter text", text: $text)
                .matchedGeometryEffect(id: movableFieldID, in: namespace)
                .textFieldStyle(.roundedBorder)
            Text("Howdy!")
// State changes HERE
            Button("Move the field") {
                withAnimation {
                    active.toggle()
                }
            }
//
        }
        .topMovable(text: $text, active: $active, namespace: namespace)
        .padding()
    }
}

Correct animation

When I try to change this example to use FocusState to determine the value of active, the text field becomes completely unresponsive. With various incantations I can get the text field responding again, but it never animates like it does in the first example.

import SwiftUI

let movableFieldID = "MyTextField"

struct TopMovable: ViewModifier {
    @Binding var text: String
    @FocusState var active: Bool
    var namespace: Namespace.ID

    func body(content: Content) -> some View {
        if active {
            VStack {
                HStack {
                    Spacer()
                    Button("Dismiss") {
                        withAnimation {
                            active = false
                        }
                    }
                }
                TextField("Enter text", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .matchedGeometryEffect(id: movableFieldID, in: namespace)
                Spacer()
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        } else {
            content
        }
    }
}

extension View {
    func topMovable(text: Binding<String>,
                    active: FocusState<Bool>,
                    namespace: Namespace.ID) -> some View {
        self.modifier(TopMovable(text: text,
                                 active: active,
                                 namespace: namespace))
    }
}

struct ContentView: View {
    @Namespace private var namespace
    @FocusState var active: Bool
    @State var text: String = ""

    var body: some View {
        VStack {
            Text("Hello, world!")
            Text("Hi!")
            TextField("Enter text", text: $text)
// Ideally, state would change HERE when the field is tapped into
                .focused($active)
//
                .matchedGeometryEffect(id: movableFieldID, in: namespace)
                .textFieldStyle(.roundedBorder)
            Text("Howdy!")
        }
        .topMovable(text: $text, active: _active, namespace: namespace)
        .padding()
    }
}

incorrect animation

Any ideas how I can fix this?

1

There are 1 best solutions below

0
Tres On

I found a solution that works without resorting to NotificationCenter, although I'm still not totally happy with it. It feels like FocusState<Bool>.Binding ought to be enough without the wrapper, but this will do for now:

import SwiftUI

let movableFieldID = "MyTextField"

struct TopMovable: ViewModifier {
    @Binding var text: String
    @Binding var active: Bool
    var namespace: Namespace.ID
    @FocusState var focused: Bool

    func body(content: Content) -> some View {
        if active {
            VStack {
                HStack {
                    Spacer()
                    Button("Dismiss") {
                        withAnimation {
                            active = false
                        }
                    }
                }
                TextField("Enter text", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .focused($focused)
                    .matchedGeometryEffect(id: movableFieldID, in: namespace)
                Spacer()
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .onAppear {
                focused = true
            }
        } else {
            content
        }
    }
}

extension View {
    func topMovable(text: Binding<String>,
                    active: Binding<Bool>,
                    namespace: Namespace.ID) -> some View {
        self.modifier(TopMovable(text: text,
                                 active: active,
                                 namespace: namespace))
    }
}

struct ContentView: View {
    @Namespace private var namespace
    @FocusState var focused: Bool
    @State var showFocusedField: Bool = false
    @State var text: String = ""

    var body: some View {
        VStack {
            Text("Hello, world!")
            Text("Hi!")
            TextField("Enter text", text: $text)
                .focused($focused)
                .matchedGeometryEffect(id: movableFieldID, in: namespace)
                .textFieldStyle(.roundedBorder)
            Text("Howdy!")
        }
        .topMovable(text: $text, active: $showFocusedField, namespace: namespace)
        .onChange(of: focused) {
            guard $0 == true else { return }
            showFocusedField = $0
        }
        .animation(.easeInOut, value: showFocusedField)
        .padding()
    }
}

Animation of working solution