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!
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.
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())
}
}