4

I'm looking for a clean solution to resolve this SwiftUI challenge.

The following code compiles but do not work since @State property is outside the ContentView scope.

import SwiftUI

struct ContentView: View {
  var state: LocalState?
  
  var body: some View {
    if let state = state {
      Toggle("Toggle", isOn: state.$isOn)
    }
  }
}

extension ContentView {
  struct LocalState {
    @State var isOn: Bool
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    VStack {
      ContentView(
        state: .init(isOn: false)
      )
      .border(Color.red)
      
      ContentView()
        .border(Color.red)
    }
    
  }
}

The following code doesn't compile since the following reasons:

Value of optional type 'ContentView.LocalState?' must be unwrapped to refer to member 'isOn' of wrapped base type 'ContentView.LocalState'

It seems that $ in $state.isOn refer to the original state and not to the unwrapped one.

import SwiftUI

struct ContentView: View {
  @State var state: LocalState!
  
  var body: some View {
    if let state = state {
      Toggle("Toggle", isOn: $state.isOn)
    }
  }
}

extension ContentView {
  struct LocalState {
    var isOn: Bool
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    VStack {
      ContentView(
        state: .init(isOn: false)
      )
      .border(Color.red)
      
      ContentView()
        .border(Color.red)
    }
  }
}

What I do NOT want is:

  • use of failable initializer in ContentView.
  • move isOn property outside LocalState.

How can I achieve those?

mfaani
  • 33,269
  • 19
  • 164
  • 293
  • Just to be clear, what failable initialiser are you talking about here? – Sweeper Aug 25 '22 at 11:00
  • State wrapped variables can only exist at a View struct level. LocalState is not a valid place to put them in. – lorem ipsum Aug 25 '22 at 11:01
  • 2
    Optionals and SwiftUI wrappers are an uphill battle. I suggest giving LocalState an initial value or don’t show the view at all. Why have active toggles if local state doesn’t have a value? – lorem ipsum Aug 25 '22 at 11:04
  • 1
    Avoid implicit unwrapped optionals in Swift as much as possible and don't use them in SwiftUI at all. A `@State` is supposed to be owned be the current view and to have a default value (unless it's connected to something like a `selection`) – vadian Aug 25 '22 at 11:27
  • My intention is to have an empty body view in case I can't satisfy the initialization condition like init(_ model: T?) where LocalState can't be defined due to possible nullity of the model). – Giuseppe Mazzilli Aug 25 '22 at 13:09

3 Answers3

4

I believe this can be solved with two techniques. 1. using the Binding constructor that can create a non-optional binding from an optional. And 2. use of a constant binding in previews, e.g.

import SwiftUI

struct Config {
    var isOn: Bool
}

struct ContentView: View {
    @State var config: Config?
    
    var body: some View {
        if let config = Binding($config) { // technique 1
            ContentView2(config: config)
        }
    }
}

struct ContentView2: View {
    @Binding var config: Config
    
    var body: some View {
        Toggle("Toggle", isOn: $config.isOn)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView2(config: .constant(Config(isOn: false))) // technique 2
    }
}
malhal
  • 26,330
  • 7
  • 115
  • 133
1

This works for me:

var body: some View {
    if let isOn = Binding($state)?.isOn {
        Toggle("Toggle", isOn: isOn)
    }
}

Breaking it down: $state is a Binding<LocalState?>, and we use the Binding initialiser (hopefully that's not the failable initialiser that you don't want to use) to convert it to a Binding<LocalState>?. Then we can use optional chaining and if let to get a Binding<Bool> out of it.

Related: How can I unwrap an optional value inside a binding in Swift?

Sweeper
  • 213,210
  • 22
  • 193
  • 313
-1

$state is syntactic sugar for _state.projectedValue, which gives you a Binding<LocalState?>. And from here on things are ugly.

You might be able to get away with a wrapped binding:

var wrappedIsOn: Binding<Bool> {
    let stateBinding = $state
    return Binding {
        stateBinding.wrappedValue?.isOn ?? false
    } set: {
        stateBinding.wrappedValue?.isOn = $0
    }
}

And then:

Toggle("Toggle", isOn: wrappedIsOn)

And alternative, inspired by @Sweeper's answer:

Toggle("Toggle", isOn: Binding($state)?.isOn ?? Binding.constant(false))
DarkDust
  • 90,870
  • 19
  • 190
  • 224