@State messing with initializer flow

I'm trying to use initializer injection for my custom SwiftUI view:

struct SignInView {
  @State private var viewModel: SignInViewModel
}

extension SignInView: View { ... }

The private modifier on the model forces me to explicitly expose the initializer:

init(viewModel: SignInViewModel) {
  self.viewModel = viewModel
}

I get the error self used before all stored properties are initialized. If I delete @State, it'll work fine. Anyone know what's going on here?

init(viewModel: SignInViewModel) {
  self.viewModel = viewModel
  $$self.viewModel = State<SignInViewModel>(initialValue: viewModel) // It should be this. @State use delegateValue, so it's $$self.viewModel. Unfortunately it dose not work.
}

Although that will compile, @State variables in SwiftUI should not be initialized from data you pass down through the initializer; since the model is maintained outside of the view, there is no guarantee that the value will really be used. The correct thing to do is to set your initial state values inline:

@State private var viewModel = SignInViewModel(...)

5 Likes

A requirement to set it inline prevents dependency injection by constructor and impacts testability. I want to be able to inject a mock state in my view tests.

7 Likes

An @ObjectBinding might work better then; you can then bind to any class conforming to the BindableObject protocol, and provide a mock implementation for your tests. State is primarily intended for small-scale UI state.

For dependency injection, it might be better to do it at the type level, something like this:

struct MyView<Model: MyModelProtocol>: View {
  @state private var injectedModel = Model(...)
  ...
}
2 Likes

For more context, here's how I am conforming to the View:

var body: some View {
    VStack {
        TextField($viewModel.email, placeholder: Text("Email"))
        SecureField($viewModel.password, placeholder: Text("Password"))
        Button(action: viewModel.signInTapped) { Text("Sign In") }
    }
}

@State is allowing me to bind the TextField changes and Button actions to my view model. Here's what my view model looks like:

final class SignInViewModel {
  var email = ""
  var password = ""
  func signInTapped() { print("sign in tapped") }
}

It's working nicely - typing in the text fields would update the stored properties in the view model, and the button action maps perfectly to the signInTapped function.

Came up with another way of doing this:

protocol SignInModel {
  var email: State<String> { get }
  var password: State<String> { get }
  
  func signInTapped()
}

The view can depend on any type that conforms to this protocol. In the view's body:

var body: some View {
  VStack {
    TextField(viewModel.email.binding)
    SecureField(viewModel.password.binding)
  }
}

@Joe_Groff how does the above look?

Nvm. Above code compiles, but I get a runtime error:

Accessing State<String> outside View.body

Updated to:

protocol SignInViewModel {
    var email: Binding<String> { get }
    var password: Binding<String> { get }
    
    func signInTapped()
}

I'm running into the same issue. Anyone have any thoughts on how to fix this? In particular I'm trying to do a binding at the tail end of a publisher:
// .assign(to: \StoryListView.stories, on: listView).
I get the error:

Accessing State ... outside View.body

hi @Joe_Groff,
Is there a chance that you can provide a simple functional code snippet that demonstrates initialising a custom structure with a State set from outside? This is the first stumbling block as there is not a lot of Good basic documentation on how to use and understand SwiftUI and Bindings with State or Binding, etc.

Thanks,

1 Like

You shouldn't be doing that. You can use a Binding to provide access to state that isn't local to your view.

3 Likes

Quick update on this thread. I came up with a satisfying pattern that allows views to depend on an abstract definition of the view model that allows you to inject from the outside:

protocol SignInModel: ObservableObject {
  var email: String { get set }
  var password: String { get set }
  
  func signIn() -> Future<Void, Error>
}

struct SignInView<Model: SignInModel>: View {
  @ObservedObject private(set) var model: Model
}

Everything defined in the protocol has the ability to signal the dependents to re-render their view, assuming that's what the view subscribes to.

But the compiler does generate a default initialiser for @State variables (Xcode 11.3.1). I suspect this is why people think they should be able to write them themselves (at least that's what it was like for me). This seems like potentially dangerous behaviour - should i file a bug?

But what if you have two cases, one where you want to pass data from a preceding view and another where you want to initialize with default data values (ie using a view as both a creation and edit page)? Bindings do not allow that.

They ought to be able to. You can write the default value to the binding in the second case. Can you give an example of why this doesn't work?

Is my workaround by changing the view's id kosher?

You mean to have values in both views decoupled until you press save or something? I've been using a combination of State, Binding and onAppear:

struct Form: View {
  @Binding var value: ...
  @State var state: ...
  var body: some View {
    ...
      .onAppear { self.state = self.value }
  }
}

but of course you need custom logic to save, by writing back to value. Note that onAppear is a method of View, so any view will do.

PS

Lol, I'm far from authoritative. Just an experimentation lunatic.

only work one time at view insert, but if the view is simply re-initialized by the parent, .onAppear() is not called anymore. By giving the view a new .id(...), the view is brand new.

Hmm, I see to it that the view (forces the user to) close the form before linking it to a new one. You'd allow accidental switch otherwise. Different design I guess, but yes, if you want to update on re-link, you can use .id to invalidate the old view.

...
  .onAppear { ... }
  .id(...)

Wow what a trip. For now I've gone ahead with @Lantua's suggestion of using .onAppear as it seems more kosher. Hoping this behavior is resolved or at least throws an error by WWDC.

Thanks for the link! I've been digging the forums for so long to research this issue so this helped a lot.