ReSwift / Recombine

MIT License
70 stars 3 forks source link

Extending BaseStore with a function which creates a binding from a state property and a refined action #2

Closed nikitamounier closed 3 years ago

nikitamounier commented 3 years ago

Hi, I've been playing around with Recombine, and really enjoying it, but I've noticed a hole: creating bindings. Take this example:


struct ContentView: View {
    @EnvironmentObject var store: Store

    var body: some View {
        TextField("Username", text: ??)
    }
}

What should be put as a binding? One might first think this:

TextField("Username", text: $store.state.username) // ERROR - Cannot assign to property: 'state' setter is inaccessible

But, no, we can't - and thankfully so, since here we'd be bypassing the reducer. To solve this problem, I offer that this function be added in an extension to BaseStore:

extension BaseStore {
    /// Create a SwiftUI Binding from a state property and a refined action
    /// - Parameters:
    ///   - keyPath: A keypath to the state property.
    ///   - action: The refined action which will be called when the value is changed.
    /// - Returns: A Binding, who's getter is the property and who's setter is the refined action taking the value of the property.
    func binding<Value>(for keyPath: KeyPath<SubState, Value>, action: @escaping (Value) -> SubRefinedAction) -> Binding<Value> {
        Binding<Value>(
            get: { self.state[keyPath: keyPath]},
            set: { self.dispatch(refined: action($0)) }
        )
    }

Now, we can do this:

TextField("Username", text: store.binding(for: \.username, action: { .setUsername($0) }))

or this:

TextField("Username", text: store.binding(for: \.username, action: Redux.Action.Refined.setUsername)

And for a cleaner body declaration, factor out the binding:

struct ContentView: View {
    @EnvironmentObject var store: Store

    private var usernameBinding: Binding<String> {
        store.binding(for: \.username, action: { .setUsername($0) })
    }

    var body: some View {
        TextField("Username", text: usernameBinding)
    }
}

I've tested this out, and it works like a charm. We could also do the same for raw actions, but that's for you to choose.

I haven't found a way to implement this for lensed stores. Optimally, we'd want a function on StoreProtocol, so that it works for both.

Let me know what you think!

Qata commented 3 years ago

This is fantastic, my original plan for bindings was to have them be user managed. Did you run into some kind of issue by using this on StoreProtocol? I tried extending StoreProtocol instead of BaseStore with your function and it compiles.

nikitamounier commented 3 years ago

Nope, I just hadn't tried it out! It does indeed seem to work with StoreProtocol.

Now that it's an extension on StoreProtocol, I tested it out with lensed stores, it works great too - like this, for example:

struct ContentView: View {
    @EnvironmentObject var store: Store

    var body: some View {
        UsernameEditView()
            .environmentObject(store.lensing(state: \.username, actions: { .userModification($0) }))
    }
}

struct UsernameEditView: View {
    @EnvironmentObject var usernameStore: Substore<String, Redux.Action.Refined.UserModification>

    private var usernameBinding: Binding<String> {
        usernameStore.binding(for: \.self, action:  { .setUsername($0) })
    }

    var body: some View {
        TextField("Change Username", text: usernameBinding)
    }
}
Qata commented 3 years ago

Thanks for this. I'll be putting 4 different variations of the binding function in the 1.1 release.