Implementing a custom ViewModifier that wraps an existing SwiftUI ViewModifier (.focused)

1.4k views Asked by At

My app collects a significant amount of data via a form that comprises text entry controls and Pickers.

In order to improve UX I would like to track the current user focus within the form to allow UI feedback (e.g. highlighting the currently focused input field), progress tracking and, as needed, programmatic control of focus should any element in the form fail validation.

The @FocusState property wrapper (and associated .focused(:) view modifier) would appear to be a good fit but it only works with text entry control and this would lead to a rather 'lumpy' UX (keyboard not responding to a shift of focus to graphical controls, etc).

I have worked around this by creating a ViewModifier that conditionally adds wraps a view with the .focused(:) modifier in response to a programmatic flag. It works, but seems a bit verbose and there is potential for bugs if the flag is incorrectly set (although the precondition shown in the example would mitigate this)

e.g.

struct ContentView: View {
    enum FieldIdentifier: String {
        case disclosablePicker, textField, text
    }

    enum PickerValues: String, CaseIterable {
        case one, two, three
    }

    @FocusState private var focusState: FieldIdentifier?

    @State private var fieldFocusState: FieldIdentifier?

    @State var value: PickerValues? = nil
    @State var textField: String = ""
    @State var disabled: String = ""

    var body: some View {
        Form {
            Section("Graphical input field") {
                VStack(alignment: .leading) {
                    HStack {
                        Text("Picker selection: \(value?.rawValue ?? "None")")
                        Spacer()
                    }
                    .customFocus(
                        fieldFocusState: $fieldFocusState,
                        focusState: $focusState,
                        identifier: .disclosablePicker,
                        isTextElement: false
                        )

                    if fieldFocusState == .disclosablePicker {
                        Picker("Picker", selection: $value) {
                            Text("None").tag(nil as PickerValues?)
                            ForEach(PickerValues.allCases, id: \.self) { value in
                                Text(value.rawValue).tag(value as PickerValues?)
                            }
                        }
                        .pickerStyle(SegmentedPickerStyle())
                    }
                }
            }

            Section("Text entry field") {
                TextField("TextField", text: .constant(""))
                    .customFocus(
                        fieldFocusState: $fieldFocusState,
                        focusState: $focusState,
                        identifier: .textField,
                        isTextElement: true
                        )
            }

            Section("Non-interactive elements") {
                Text("Text, Label, background, etc")
                    .customFocus(
                    fieldFocusState: $fieldFocusState,
                    focusState: $focusState
                    )
            }

            Section("Focus states") {
                Text("@FocusState: \(focusState?.rawValue ?? "None")")
                Text("Custom focus: \(fieldFocusState?.rawValue ?? "None")")
            }
        }
        .onChange(of: focusState) { newValue in
            if (newValue != nil) { fieldFocusState = newValue }
        }
    }
}


struct CustomFocus<T: Hashable>: ViewModifier {
    var fieldFocusState: Binding<T?>
    var focusState: FocusState<T?>.Binding
    var identifier: T?
    var isTextElement: Bool

    @ViewBuilder func body(content: Content) -> some View {
        if isTextElement {
            content
                .focused(focusState, equals: identifier)
        } else {
            content
                .onTapGesture {
                    focusState.wrappedValue = nil
                    fieldFocusState.wrappedValue = identifier
                }
        }
    }

    init(
        fieldFocusState: Binding<T?>,
        focusState: FocusState<T?>.Binding,
        identifier: T? = nil,
        isTextElement: Bool = false
    ) {
        if (identifier == nil && isTextElement == true) {
            preconditionFailure("Text input elements must have an identifier")
        }

        self.fieldFocusState = fieldFocusState
        self.focusState = focusState
        self.identifier = identifier
        self.isTextElement = isTextElement
    }
}

extension View {
    func customFocus<T: Hashable>(
        fieldFocusState: Binding<T?>,
        focusState: FocusState<T?>.Binding,
        identifier: T? = nil,
        isTextElement: Bool = false
    ) -> some View {
        if (identifier == nil && isTextElement == true) {
            preconditionFailure("Text input elements must have an identifier")
        }

        return modifier(
            CustomFocus(
                fieldFocusState: fieldFocusState,
                focusState: focusState,
                identifier: identifier,
                isTextElement: isTextElement
            )
        )
    }
}

Above code in action

This got me to wondering if it would be possible to write a custom ViewModifier that would add .focused(:) or .onTapGesture by checking the concrete type of the View to be modified thus avoiding the need for a programmatic flag. However, I quickly discovered that ViewModifiers use an internal Content type that hides the concrete type of the View it is wrapping making type inference / casting impossible although Cristik helped massively in showing me how to work around this in a basic proof of concept (see original post).

This solution works nicely for simple modifiers (e.g. background colour) but my ultimate needs (as shown above) are more complex; the ViewModifier needs to be passed bindings to keep the custom focus state and @FocusState wrapped properties up to date and the .focused(:) modifier itself should be applied conditionally (as opposed to a changing a single parameter passed to the modifier).

My attempts to implement this using the pattern suggested (protocol abstraction) have failed due to an inability (again) to handle the internal (and private) Content type alias associated with the ViewModifier protocol.

Code example and errors as follows:

enum FocusIdentifier: String {
    case textField, text, picker
}

protocol CustomModifiable: View {
    associatedtype FocusedField: View

    static func formFocused(content: Self, focusState: FocusState<FocusIdentifier?>.Binding) -> FocusedField
}

extension Text: CustomModifiable {
    static func formFocused(content: Self, focusState: FocusState<FocusIdentifier?>.Binding) -> some View {
        // focus state changes omitted for clarity
        content
    }
}

extension TextField: CustomModifiable {
    static func formFocused(content: Self, focusState: FocusState<FocusIdentifier?>.Binding) -> some View {
        // focus state changes omitted for clarity
        content.focused(focusState, equals: .textField)
    }
}

struct CustomModifier<T: CustomModifiable>: ViewModifier {

    let formFocusState: Binding<FocusIdentifier?>
    let focusState: FocusState<FocusIdentifier?>.Binding

    @ViewBuilder func body(content: Content) -> some View {
        T.formFocused(content: content, focusState: focusState)
    }
}

struct ContentView: View {
    @State private var formFocus: FocusIdentifier?
    @FocusState private var focusState: FocusIdentifier?

    var body: some View {
        Form {
            Text("Hello")

            TextField("Text", text: .constant(""))
                .modifier(CustomModifier(formFocusState: $formFocus, focusState: $focusState))
        }
    }
}

Xcode compiler errors

Attempts to implement the ViewModifier fail as I can't constrain the Content associated type and T generic type to a common protocol (View) and the compiler can't infer the type of T at the call site. I must admit mixing generics and internal types within SwiftUI views is something I find very confusing!

Any help untangling the problem would be much appreciated.

1

There are 1 answers

0
rustproofFish On

Following on from the guidance provided by Cristik, I've come up with an implementation which improves the call site slightly and should reduce the chances of bugs.

Views that need to focus functionality are tagged by extending them with the FocusableField protocol. A static Bool value is used to indicate whether that View supports text entry - it's still a manual process but should be less error-prone than the previous implementation.

As an aside, taps on non-focus aware Views are tracked by using the .formFieldFocus ViewModifier without an identifier which will nil out both the customised field focus property and SwiftUI's focus state.

struct ContentView: View {
    enum FieldIdentifier: String {
        case disclosablePicker, textField, text
    }
    
    enum PickerValues: String, CaseIterable {
        case one, two, three
    }
    
    @FocusState private var focusState: FieldIdentifier?
    @State private var fieldFocusState: FieldIdentifier?
    @State var value: PickerValues? = nil
    @State var textField: String = ""
    
    var body: some View {
        Form {
            Section("Graphical input field") {
                VStack(alignment: .leading) {
                    HStack {
                        Text("Picker selection: \(value?.rawValue ?? "None")")
                        Spacer()
                    }
                    .formFieldFocus(
                        fieldFocusState: $fieldFocusState, 
                        focusState: $focusState, 
                        identifier: .disclosablePicker
                    )
                    
                    if fieldFocusState == .disclosablePicker {
                        Picker("Picker", selection: $value) {
                            Text("None").tag(nil as PickerValues?)
                            ForEach(PickerValues.allCases, id: \.self) { value in
                                Text(value.rawValue).tag(value as PickerValues?)
                            }
                        }
                        .pickerStyle(SegmentedPickerStyle())
                    }
                }
            }
            
            Section("Text entry field") {
                TextField("TextField", text: .constant(""))
                    .formFieldFocus(
                        fieldFocusState: $fieldFocusState, 
                        focusState: $focusState, 
                        identifier: .textField
                    )
            }
            
            Section("Non-interactive elements") {
                Text("Text, Label, background, etc")
                    .formFieldFocus(
                        fieldFocusState: $fieldFocusState, 
                        focusState: $focusState
                    )
            }
            
            Section("Focus states") {
                Text("@FocusState: \(focusState?.rawValue ?? "None")")
                Text("Custom focus: \(fieldFocusState?.rawValue ?? "None")")
            }
        }
        .onChange(of: focusState) { newValue in
            if (newValue != nil) { fieldFocusState = newValue }
        }
    }
}


struct FormFieldFocus<I: Hashable, V: FocusableField>: ViewModifier {
    var fieldFocusState: Binding<I?>
    var focusState: FocusState<I?>.Binding
    var identifier: I?
    
    @ViewBuilder func body(content: Content) -> some View {
        if V.isTextControl {
            content
                .focused(focusState, equals: identifier)
        } else {
            content
                .onTapGesture {
                    focusState.wrappedValue = nil
                    fieldFocusState.wrappedValue = identifier
                }
        }
    }
    
    init(
        fieldFocusState: Binding<I?>,
        focusState: FocusState<I?>.Binding,
        identifier: I? = nil
    ) {
        self.fieldFocusState = fieldFocusState
        self.focusState = focusState
        self.identifier = identifier
    }
}

extension View {
    func formFieldFocus<I: Hashable>(
        fieldFocusState: Binding<I?>,
        focusState: FocusState<I?>.Binding,
        identifier: I? = nil
    ) -> some View where Self: FocusableField {
        return modifier(
            FormFieldFocus<I, Self>(
                fieldFocusState: fieldFocusState,
                focusState: focusState,
                identifier: identifier
            )
        )
    }
}

protocol FocusableField: View {
    static var isTextControl: Bool { get }
}

extension TextField: FocusableField {
    static var isTextControl: Bool { true }
}

extension TextEditor: FocusableField {
    static var isTextControl: Bool { true }
}

extension Text: FocusableField {
    static var isTextControl: Bool { false }
}

extension HStack: FocusableField {
    static var isTextControl: Bool { false }
}