7

I'm using view models for my SwiftUI app and would like to have the focus state also in the view model as the form is quite complex.

This implementation using @FocusState in the view is working as expected, but not want I want:

import Combine
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()
    @FocusState private var hasFocus: Bool

    var body: some View {
        Form {
            TextField("Text", text: $viewModel.textField)
                .focused($hasFocus)
            Button("Set Focus") {
                hasFocus = true
            }
        }
    }
}

class ViewModel: ObservableObject {
    @Published var textField: String = ""
}

How can I put the @FocusState into the view model?

G. Marc
  • 4,987
  • 4
  • 32
  • 49

3 Answers3

8

Assuming you have in ViewModel as well

class ViewModel: ObservableObject {
  @Published var hasFocus: Bool = false

  ...
}

you can use it like

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()
    @FocusState private var hasFocus: Bool

    var body: some View {
        Form {
            TextField("Text", text: $viewModel.textField)
                .focused($hasFocus)
        }
        .onChange(of: hasFocus) {
           viewModel.hasFocus = $0     // << write !!
        }
        .onAppear {
           self.hasFocus = viewModel.hasFocus    // << read !!
        }
    }
}

as well as the same from Button if any needed.

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thanks, this is basically working. But there's an issue which is not related to the view model solution per se. Setting hasFocus to true in the onAppear handler does not put the cursor into the text field at startup. Is this a SwiftUI bug? – G. Marc Nov 28 '21 at 07:30
  • Alright, found a thread targeting the issue mentioned in my previous comment: https://stackoverflow.com/questions/68073919/swiftui-focusstate-how-to-give-it-initial-value. It's a bit annoying that we need so many ugly workaround with SwiftUI... – G. Marc Nov 28 '21 at 07:39
  • Excellent solution! Thank you @Asperi – ixany Jan 24 '22 at 13:42
  • This solution is pretty ugly, albeit working. – heyfrank Jul 04 '22 at 08:51
  • This does not work very well. `.OnAppear` is only called when this view appears on screen. If the view is already on screen, and the `viewModel.hasFocus` got changed, the `hasFocus` in the view is not updated. – Simon Jan 26 '23 at 10:02
6

I faced the same problem and ended up writing an extension that can be reused to sync both values. This way the focus can also be set from the view model side if needed.

class ViewModel: ObservableObject {
    @Published var hasFocus: Bool = false
}

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()
    @FocusState private var hasFocus: Bool
    var body: some View {
        Form {
            TextField("Text", text: $viewModel.textField)
                .focused($hasFocus)
        }
        .sync($viewModel.hasFocus, with: _hasFocus)
    }
}

extension View {
    func sync<T: Equatable>(_ binding: Binding<T>, with focusState: FocusState<T>) -> some View {
        self
            .onChange(of: binding.wrappedValue) {
                focusState.wrappedValue = $0
            }
            .onChange(of: focusState.wrappedValue) {
                binding.wrappedValue = $0
            }
    }
}
yannxou
  • 71
  • 2
  • 4
  • I found that this solution will not work as expected if there's a `.keyboard` toolbar in play. Only fix I found is to wrap the value change in a `Task`. – Martin May 21 '23 at 08:20
2

Create a @Published var hasFocus: Bool in the ViewModel and sync it:

    Form
        ...
        .onAppear { self.hasFocus = viewModel.hasFocus}
        .onChange(of: hasFocus) { viewModel.hasFocus = $0 }
        .onChange(of: viewModel.hasFocus) { hasFocus = $0 }
Jano
  • 62,815
  • 21
  • 164
  • 192