swift-composable-architecture icon indicating copy to clipboard operation
swift-composable-architecture copied to clipboard

Add ViewState binding support regarding to discusstion#842

Open yuanhang opened this issue 2 years ago • 2 comments

This is an implementation of the idea of BindableViewState proposed by @stephencelis in discussion #842.

yuanhang avatar Nov 17 '22 02:11 yuanhang

Hey @yuanhang, thanks for exploring this! We haven't had time to look over it yet, but hopefully in the next few days.

mbrandonw avatar Nov 18 '22 16:11 mbrandonw

Hey @yuanhang, thanks for exploring this! We haven't had time to look over it yet, but hopefully in the next few days.

Thanks, no rush. Your work has really helped me a lot and I'm happy to make my little contribution (if I can).

This is my first PR for TCA, and I haven't found a guide to contribute, so I'm happy to change anything that doesn't fit the TCA code requirements.

yuanhang avatar Nov 18 '22 16:11 yuanhang

This is possibly redundant. @tgrapperon in one of the Binding/ViewStore discussions earlier have mentioned his solution

public extension ViewStore {
  func binding<ParentState, Value>(
    _ parentKeyPath: WritableKeyPath<ParentState, BindableState<Value>>,
    as keyPath: KeyPath<ViewState, Value>
  ) -> Binding<Value>
  where ViewAction: BindableAction, ViewAction.State == ParentState, Value: Equatable {
    binding(
      get: { $0[keyPath: keyPath] },
      send: { .binding(.set(parentKeyPath, $0)) }
    )
  }
}

Which allows to elegantly bind to BindableState from a ViewStore, if needed, like:

PlanPicker(
   selected: self.viewStore.binding(\.$selectedPlanID, as: \.selectedPlanID),
)

Where \.$selectedPlanID is BindableState property, while \.selectedPlanID is a key path on a ViewState.

andreyz avatar Dec 08 '22 10:12 andreyz

This is possibly redundant. @tgrapperon in one of the Binding/ViewStore discussions earlier have mentioned his solution

public extension ViewStore {
  func binding<ParentState, Value>(
    _ parentKeyPath: WritableKeyPath<ParentState, BindableState<Value>>,
    as keyPath: KeyPath<ViewState, Value>
  ) -> Binding<Value>
  where ViewAction: BindableAction, ViewAction.State == ParentState, Value: Equatable {
    binding(
      get: { $0[keyPath: keyPath] },
      send: { .binding(.set(parentKeyPath, $0)) }
    )
  }
}

Which allows to elegantly bind to BindableState from a ViewStore, if needed, like:

PlanPicker(
   selected: self.viewStore.binding(\.$selectedPlanID, as: \.selectedPlanID),
)

Where \.$selectedPlanID is BindableState property, while \.selectedPlanID is a key path on a ViewState.

Thank you for your reply. To avoid misunderstanding:

  1. Would you post the link to the @tgrapperon discussion you mentioned so I can go through the context?
  2. It would be great if you could give a complete example (a unit test/gist maybe) that I can run to better understand the solution.

yuanhang avatar Dec 12 '22 22:12 yuanhang

@yuanhang I think that @andreyz referred to #769.

tgrapperon avatar Dec 12 '22 22:12 tgrapperon

@yuanhang I think that @andreyz referred to #769.

Thank you for your reply!

I went and read the whole discussion, however, I need to (embarrassingly) admit that I failed to understand how this works. I tried to write this code in the TCA source code, but there seem to be some other parts missing to make the compilation pass. It would be nice to have an example that runs.

yuanhang avatar Dec 12 '22 23:12 yuanhang

In this specific case, you have

struct State {
  @BindableState var text0: String // Added `0` or `1` suffix to make it clearer
}

struct MyView: View {
  …
  struct ViewState: Equatable {
    var text1: String // No @BindableState required here
	init(_ state: State) {
      self.text1 = state.text0
    }
  }
  var body: some View {
    WithViewStore(store, observe: ViewState.init) { viewStore
      TextField("Name", text: viewStore.binding(\.$text0, as: \.text1))
    }
  }
}

It merely reads \.text1, but writes on \.$text0, while hoping that you made the correct connection in your ViewState.init. The nice thing is that switching from observe: { $0 } to observe: ViewState.init only requires additive changes in your view (appending ,as: \.text1). It can be implemented as a third party, out of TCA. It also works as expected with case .binding(\.$text0): pattern matching in reducers. Of course, in real-life scenarios, you don't have the 0 or 1 suffixes, so you write viewStore.binding(\.$text, as: \.text).

But this is one solution among many others (including those described in #769. Until now, none is 100% satisfactory. I don't think it's possible to introduce seamless workaround without formalizing additional constraints (like introducing a protocol or a new property wrapper of some sort)

tgrapperon avatar Dec 13 '22 01:12 tgrapperon

Hi @yuanhang! Thanks again for taking the time to submit this after following previous discussions. One of the reasons we held off on bringing this particular solution into the library was we weren't entirely happy with the ergonomics. @mbrandonw and I have been experimenting with alternatives for awhile, but @tgrapperon recently cracked the code in #1790, which we think offers the nicest and most streamlined way of using @BindingState (previously @BindableState) with view state.

I'm going to close this out for now, but would love to hear your thoughts on the solution we came to!

stephencelis avatar Jan 05 '23 17:01 stephencelis