swiftui

How to use FocusState with Observable?


I'm curious, does anyone know how to use FocusState from within Observable types?

From what I can find, @FocusState can only be applied to properties on views. But I would like to push all state and business logic from my view to a dedicated Observable type, including validation and focus setting.

I have written some code to show what I'm talking about. This is the version where state is stored in view-local State properties:

struct StateVersion: View {
    @State private var name = ""
    @State private var answer = ""
    @State private var showAlert = false
    @State private var alertMessage = ""
    @FocusState var focus: Field?
    enum Field: Hashable, Codable {
        case name, answer
    }

    func validateForm() -> Bool {
        if name == "" {
            focus = .name
            alertMessage = "Please enter your name."
            showAlert = true
            return false
        } else if answer == "" {
            focus = .answer
            alertMessage = "Please give your answer."
            showAlert = true
            return false
        } else if answer != "Rush" {
            focus = .answer
            alertMessage = "Incorrect! Try again!\n(Hint: It's not Metallica)."
            showAlert = true
            return false
        } else {
            return true
        }
    }
    
    var body: some View {
        Form {
            TextField("Name", text: $name)
                .focused($focus, equals: .name)

            Section("Best Metal Band In the World?") {
                TextField("Band Name", text: $answer)
                    .focused($focus, equals: .answer)
            }
            
            Button("OK") {
                if !validateForm() {
                    return
                } else {
                    alertMessage = "Rock on, \(name)!\nYou are correct!"
                    showAlert = true
                }
            }
            .alert(alertMessage, isPresented: $showAlert) {
                Button("OK") {}
            }
        }
    }
}

And here is the version where I've pushed everything but FocusState into an Observable type:

@Observable class FormState {
    var name = ""
    var answer = ""
    var showAlert = false
    var alertMessage = ""
}

struct ObservableVersion: View {
    @Environment(FormState.self) var state
    @FocusState var focus: Field?
    enum Field: Hashable, Codable {
        case name, answer
    }

    func validateForm() -> Bool {
        if state.name == "" {
            focus = .name
            state.alertMessage = "Please enter your name."
            state.showAlert = true
            return false
        } else if state.answer == "" {
            focus = .answer
            state.alertMessage = "Please give your answer."
            state.showAlert = true
            return false
        } else if state.answer != "Rush" {
            focus = .answer
            state.alertMessage = "Incorrect! Try again!\n(Hint: It's not Metallica)."
            state.showAlert = true
            return false
        } else {
            return true
        }
    }

    var body: some View {
        Form {
            @Bindable var state = state
            
            TextField("Name", text: $state.name)
                .focused($focus, equals: .name)
            
            Section("Best Metal Band In the World?") {
                TextField("Band Name", text: $state.answer)
                    .focused($focus, equals: .answer)
            }
            
            Button("OK") {
                if !validateForm() {
                    return
                } else {
                    state.alertMessage = "Rock on, \(state.name)!\nYou are correct!"
                    state.showAlert = true
                }
            }
            .alert(state.alertMessage, isPresented: $state.showAlert) {
                Button("OK") {}
            }
        }
    }
}

#Preview {
    ObservableVersion()
        .environment(FormState())
}

I would like to move validateForm from ObservableVersion to FormState, as might seem sensible. I haven't been able to think of anything (eg using FocusState.Binding to overcome this.

Does anyone have any ideas? Ideally, I'm looking for a version where FormState is set up as an environmentObject, as I have done in the example, but other solutions are also good!


Solution

  • So, while I don't think you can move the @FocusState to an observable as it needs to be in a View, you can coordinate the value of it with an observable, which I believe may achieve what you're looking for.

    Your @Observable can gain a var focus: Field? property, and even the validateForm() function:

    @Observable
    class FormState {
        
        //Form values
        var name = ""
        var answer = ""
        var text = ""
        var password = ""
        var longAnswer = ""
        
        //Alert
        var alertMessage = ""
        var showAlert = false
    
        //Focused field
        var focus: Field?
        
        //Validation function
        func validateForm() {   
            ...
        }
    }
    

    Because @FocusState can't really have a value like @Bindable would, you need a way to coordinate changes between the observable and the focus state:

    .onChange(of: focus) {
        formState.focus = focus
    }
    .onChange(of: formState.focus) {
        focus = formState.focus
    }
    

    Like this, the form will have its bindings to the observable, changes in FocusState will be reflected in the observable and changes in the observable will be reflected in the FocusState. Basically, like a Binding, but without the actual binding.

    The example I put together below, includes some additional views to show that you can not only monitor the changes in the FocusState but also set the focus from different views, via the observable from environment.

    To make things easier for applying all this to basically any views that may need it, there is a View extension (.formStateObserver() and a ViewModifier that bundle all the necessary logic and modifiers mentioned so far.

    So other than building the form with the appropriate .focused modifiers, all you really need to do is add a @FocusState and the .formStateObserver(focus: _focus) modifier to the form view.

    Here's the full code:

    import SwiftUI
    
    @Observable
    class FormState {
        
        //Form values
        var name = ""
        var answer = ""
        var text = ""
        var password = ""
        var longAnswer = ""
        
        //Alert
        var alertMessage = ""
        var showAlert = false
    
        //Focused field
        var focus: Field?
        
        //Validation function
        func validateForm() {
            
            guard name != "" else {
                focus = .name
                alertMessage = "Please enter your name."
                showAlert = true
                
                return
            }
            
            guard !answer.isEmpty else {
                focus = .shortAnswer
                alertMessage = "Please enter an answer."
                showAlert = true
        
                return
            }
            
            guard answer == "Rush" else {
                focus = .shortAnswer
                alertMessage = "Incorrect! Try again!\n(Hint: It's not Metallica)."
                showAlert = true
        
                return
            }
            
            //Validate OK (since none of the guards failed
            alertMessage = "Rock on, \(name)!\nYou are correct!"
            showAlert = true
        }
    }
    
    enum Field: String, Hashable, Codable, CaseIterable {
        case name = "Name"
        case shortAnswer = "Short Answer"
        case longAnswer = "Long Answer"
        case password = "Password Field"
        
        var icon: String {
            switch self {
                case .name: "person.fill.questionmark"
                case .shortAnswer: "rectangle.and.pencil.and.ellipsis"
                case .longAnswer: "bubble.and.pencil"
                case .password: "lock.square"
            }
        }
    }
    
    struct ObservableVersion: View {
        
        //Environment values
        @Environment(FormState.self) var formState
        
        //Focus states
        @FocusState var focus: Field?
    
        //Body
        var body: some View {
            
            //Binding to observable from environment
            @Bindable var formState = formState
    
            Form {
                //Name
                Section("Name") {
                    TextField("Name", text: $formState.name)
                        .focused($focus, equals: .name)
                }
                
                //Short answer
                Section("Best Metal Band In the World?") {
                    TextField("Band Name", text: $formState.answer)
                        .focused($focus, equals: .shortAnswer)
                }
                
                //Password input
                Section("Password") {
                    SecureField("Password", text: $formState.password)
                        .focused($focus, equals: .password)
                }
                
                //Text editor - long answer
                Section {
                    TextEditor(text: $formState.longAnswer)
                        .focused($focus, equals: .longAnswer)
                        .frame(minHeight: 100)
                } header: {
                    Text("Comments")
                } footer: {
                    Text("Tell us why you wrote Metallica as your first answer")
                }
                
                Button("OK") {
                    formState.validateForm()
                }
                
            }
            .navigationTitle("FocusMaster")
            .formStateObserver(focus: _focus) // <- call the view modifier
        }
    }
    
    struct FormFocusControlsView: View {
        
        //Environment values
        @Environment(FormState.self) var formState
        
        //Body
        var body: some View {
            
            //Binding to observable from environment
            @Bindable var formState = formState
            
            Picker("Field", selection: $formState.focus) {
                ForEach(Field.allCases, id: \.self) { field in
                    Image(systemName: field.icon)
                        .tag(field)
                        .tint(formState.focus == field ? Color.green : Color.primary)
                }
            }
            .pickerStyle(.palette)
            .menuActionDismissBehavior(.enabled)
        }
    }
    
    struct FocusStateStatus: View {
        
        //Environment values
        @Environment(FormState.self) var formState
        
        //Computed properties
        private var focusedField: String {
            formState.focus?.rawValue ?? "None"
        }
        
        private var focusedStatusColor: Color {
            formState.focus == nil ? .red : .green
        }
        
        //Body
        var body: some View {
            HStack {
                Text("Currently focused:")
                    .foregroundStyle(.secondary)
                Text("\(focusedField)")
                    .foregroundStyle(focusedStatusColor)
            }
        }
    }
    
    struct FormStateObserverModifier: ViewModifier {
        
        //Parameters
        @FocusState var focus: Field?
        
        //Environment values
        @Environment(FormState.self) var formState
        
        //Body
        func body(content: Content) -> some View {
            
            //Binding to observable from environment
            @Bindable var formState = formState
            
            content
                .toolbarTitleDisplayMode(.inline)
                .alert(formState.alertMessage, isPresented: $formState.showAlert, presenting: formState) { formState in
                    Button("Submit") {}
                }
                .toolbar {
                    ToolbarItem(placement: .status) {
                        FocusStateStatus()
                    }
                    
                    ToolbarTitleMenu {
                        FormFocusControlsView()
                    }
                }
                .onChange(of: focus) {
                    formState.focus = focus
                }
                .onChange(of: formState.focus) {
                    focus = formState.focus
                }
            
        }
    }
    
    extension View {
        
        func formStateObserver(focus: FocusState<Field?>) -> some View {
            self
                .modifier(FormStateObserverModifier(focus: focus))
        }
        
    }
    
    #Preview("Observable version") {
        NavigationStack{
            ObservableVersion()
                .environment(FormState())
        }
    }
    

    enter image description here