Skip to content

Commit

Permalink
Introduce KeyedCursorProtocol, remove ViewStore in favor of forward (#19
Browse files Browse the repository at this point in the history
)

Fixes #18.

This PR sketches out one potential solution to #18. It refactors our approach to sub-components by decomplecting action sending from state getting.

- Removes `ViewStore`
- Introduces `Address.forward(send:tag:)` which gives us an easy way to create tagged `send` functions. This solves one part of what ViewStore was solving.
- Introduces `Binding(get:send:tag:)` which gives us the binding equivalent to `Address.forward`
- Introduces `KeyedCursorProtocol` which offers an alternative cursor for subcomponents that need to be looked up within dynamic lists.

This refactor is in response to the awkwardness of the `ViewStore/Cursor` paradigm for components that are part of a dynamic list. Even if we had created a keyed cursor initializer for ViewStore, it necessarily would have had to hold an optional (nillable) state. This is because ViewStore lookup was dynamic, and this trips up the lifetime typechecking around the model. In practice, a view would not exist if its model did not exist, but this is not a typesafe guarantee for dynamic list lookups.

Anyway, the whole paradigm of looking up child from parent dynamically is a bit odd for list items. In SwiftUI the typical approach is to ForEach, and then pass the model data down as a static property to the view. This guarantees type safety, since a view holds its own copy of the data. What if we could do something more like that?

The approach in this PR leans into this approach. State can be passed to sub-components as plain old properties. `Address.forward` can be used to create view-local send functions that you can pass down to sub-views. `Binding` gets a similar form. In both cases, we can use a closure to capture additional parent-scoped state, such as an ID for lookup within the parent model.

Cursor sticks around, but mostly as a convenient way to create update functions for sub-components. We also introduce `KeyedCursorProtocol` which offers a keyed equivalent for dynamic lookup.

## Usage

Sub-components become more "vanilla", just using bare properties and closures.

```swift
struct ParentView: View {
    @StateObject = Store(
        AppModel(),
        AppEnvironment()
    )

    var body: some View {
        ChildModel(
            state: store.state.child,
            send: Address.forward(
                send: store.send,
                tag: ParentChildCursor.tag
            )
        )
    }
}

struct ChildView: View {
    var state: ChildModel
    var send: (ChildAction) -> Void

    var body: some View {
        Button(state.text) {
            send(.activate)
        }
    }
}
```

## Prior art

This approach is inspired by Reflex:

- Forward https://github.com/mozilla/reflex/blob/c5e75e98bc601e2315b6d43e5e347263cf67359e/src/signal.js#L5
- Cursor https://github.com/browserhtml/browserhtml/blob/master/src/Common/Cursor.js
  • Loading branch information
gordonbrander authored Jan 26, 2023
1 parent e54ae1d commit 332458e
Show file tree
Hide file tree
Showing 5 changed files with 350 additions and 221 deletions.
82 changes: 37 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,40 +191,27 @@ Button("Set color to red") {

## Bindings

`Binding(store:get:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of the store state. The `get` closure reads the state into a value, and the `tag` closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, but changes state only through deterministic updates.
`Binding(get:send:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of the store state. The `get` closure reads the state into a value, and the `tag` closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, but changes state only through deterministic updates.

```swift
TextField(
"Username"
text: Binding(
store: store,
get: { state in state.username },
get: { store.state.username },
send: store.send,
tag: { username in .setUsername(username) }
)
)
```

Or, shorthand:

```swift
TextField(
"Username"
text: Binding(
store: store,
get: \.username,
tag: .setUsername
)
)
```

Bottom line, because Store is just an ordinary [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, [@Binding](https://developer.apple.com/documentation/swiftui/binding), [@ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject), [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject) and [@EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject) all work as you would expect.


## ViewStore
## Scoping store for child components

ViewStore lets you create component-scoped stores from a shared root store. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store. You can think of ViewStore as like a binding, except that it exposes the same StoreProtocol API that Store does.
We can also create component-scoped state and send callbacks from a shared root store. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a stand-alone child component that looks something like this:
Imagine we have a vanilla SWiftUI child view that looks something like this:

```swift
enum ChildAction {
Expand All @@ -249,11 +236,12 @@ struct ChildModel: ModelProtocol {
}

struct ChildView: View {
var store: ViewStore<ChildModel>
var state: ChildModel
var send: (ChildAction) -> Void

var body: some View {
VStack {
Text("Count \(store.state.count)")
Text("Count \(state.count)")
Button(
"Increment",
action: {
Expand All @@ -265,12 +253,39 @@ struct ChildView: View {
}
```

Now we want to integrate this child component with a parent component. To do this, we can create a ViewStore from the parent's root store. We just need to specify a way to map from this child component's state and actions to the root store state and actions. This is where `CursorProtocol` comes in. It defines three things:
Now we want to integrate this child component with a parent component. To do this, we just need to specify a way to map from this child component's state and actions to the root store state and actions. First, we pass down the part of the state the child uses. Then we create a scoped `send` function using `Address.forward`. It maps child actions to parent actions using a `tag` closure we provide.

```swift
struct ContentView: View {
@StateObject private var store: Store<AppModel>

var body: some View {
ChildView(
state: store.state.child,
send: Address.forward(
send: store.send,
tag: {
switch action {
default:
return .child(action)
}
}
)
)
}
}
```

Now we just need to integrate our child component's update function with the root update function. This is where `CursorProtocol` comes in.

It defines three things:

- A way to `get` a local state from the root state
- A way to `set` a local state on a root state
- A way to `tag` a local action so it becomes a root action

...and synthesizes an `update` function that automatically maps child state and actions to parent state and actions.

```swift
struct AppChildCursor: CursorProtocol {
/// Get child state from parent
Expand All @@ -295,27 +310,6 @@ struct AppChildCursor: CursorProtocol {
}
```

...This gives us everything we need to map from a local scope to the global store. Now we can create a scoped ViewStore from the shared app store and pass it down to our ChildView.

```swift
struct ContentView: View {
@ObservedObject private var store: Store<AppModel>

var body: some View {
ChildView(
store: ViewStore(
store: store,
cursor: AppChildCursor.self
)
)
}
}
```

ViewStores can also be created from other ViewStores, allowing for hierarchical nesting of components.

Now we just need to integrate our child component's update function with the root update function. Cursors gives us a handy shortcut by synthesizing an `update` function that automatically maps child state and actions to parent state and actions.

```swift
enum AppAction {
case child(ChildAction)
Expand All @@ -342,5 +336,3 @@ struct AppModel: ModelProtocol {
```

This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

That's it! You can get state and send actions from the ViewStore, just like any other store, and it will translate local state changes and fx into app-level state changes and fx. Using ViewStore you can compose an app from multiple stand-alone components that each describe their own domain model and update logic.
151 changes: 89 additions & 62 deletions Sources/ObservableStore/ObservableStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,18 @@ where Model: ModelProtocol
}
}

public struct Address {
/// Forward transform an address (send function) into a local address.
/// View-scoped actions are tagged using `tag` before being forwarded to
/// `send.`
public static func forward<Action, ViewAction>(
send: @escaping (Action) -> Void,
tag: @escaping (ViewAction) -> Action
) -> (ViewAction) -> Void {
{ viewAction in send(tag(viewAction)) }
}
}

/// A cursor provides a complete description of how to map from one component
/// domain to another.
public protocol CursorProtocol {
Expand Down Expand Up @@ -302,84 +314,99 @@ extension CursorProtocol {
}
}

/// ViewStore is a local projection of a Store that can be passed down to
/// a child view.
// NOTE: ViewStore works like Binding. It reads state at runtime using a
// getter closure that you provide. It is important that we
// read the state via a closure, like Binding does, rather than
// storing the literal value as a property of the instance.
// If you store the literal value as a property, you will have "liveness"
// issues with the data in views, especially around things like text editors.
// Letters entered out of order, old states showing up, etc.
// I suspect this has something to do with either the guts of SwiftUI or the
// guts of UIViewRepresentable.
// 2022-06-12 Gordon Brander
public struct ViewStore<ViewModel: ModelProtocol>: StoreProtocol {
private let _get: () -> ViewModel
private let _send: (ViewModel.Action) -> Void
public protocol KeyedCursorProtocol {
associatedtype Key
associatedtype Model: ModelProtocol
associatedtype ViewModel: ModelProtocol

/// Initialize a ViewStore using a get and send closure.
public init(
get: @escaping () -> ViewModel,
send: @escaping (ViewModel.Action) -> Void
) {
self._get = get
self._send = send
}
/// Get an inner state from an outer state
static func get(state: Model, key: Key) -> ViewModel?

/// Get current state
public var state: ViewModel { self._get() }
/// Set an inner state on an outer state, returning an outer state
static func set(state: Model, inner: ViewModel, key: Key) -> Model

/// Send an action
public func send(_ action: ViewModel.Action) {
self._send(action)
}
/// Tag an inner action, transforming it into an outer action
static func tag(action: ViewModel.Action, key: Key) -> Model.Action
}

extension ViewStore {
/// Initialize a ViewStore from a store of some type, and a cursor.
/// - Store can be any type conforming to `StoreProtocol`
/// - Cursor can be any type conforming to `CursorProtocol`
public init<Store, Cursor>(store: Store, cursor: Cursor.Type)
where
Store: StoreProtocol,
Cursor: CursorProtocol,
Store.Model == Cursor.Model,
ViewModel == Cursor.ViewModel
{
self.init(
get: { Cursor.get(state: store.state) },
send: { action in store.send(Cursor.tag(action)) }
extension KeyedCursorProtocol {
/// Update an inner state within an outer state through a keyed cursor.
/// This cursor type is useful when looking up children in dynamic lists
/// such as arrays or dictionaries.
///
/// - `state` the outer state
/// - `action` the inner action
/// - `environment` the environment for the update function
/// - `key` a key uniquely representing this model in the parent domain
/// - Returns an update for a new outer state or nil
public static func update(
state: Model,
action viewAction: ViewModel.Action,
environment viewEnvironment: ViewModel.Environment,
key: Key
) -> Update<Model>? {
guard let viewModel = get(state: state, key: key) else {
return nil
}
let next = ViewModel.update(
state: viewModel,
action: viewAction,
environment: viewEnvironment
)
return Update(
state: set(state: state, inner: next.state, key: key),
fx: next.fx
.map({ viewAction in Self.tag(action: viewAction, key: key) })
.eraseToAnyPublisher(),
transaction: next.transaction
)
}
}

extension ViewStore {
/// Create a ViewStore for a constant state that swallows actions.
/// Convenience for view previews.
public static func constant(
state: ViewModel
) -> ViewStore<ViewModel> {
ViewStore<ViewModel>(
get: { state },
send: { action in }
)
/// Update an inner state within an outer state through a keyed cursor.
/// This cursor type is useful when looking up children in dynamic lists
/// such as arrays or dictionaries.
///
/// This version of update always returns an `Update`. If the child model
/// cannot be found at key, then it returns an update for the same state
/// (noop), effectively ignoring the action.
///
/// - `state` the outer state
/// - `action` the inner action
/// - `environment` the environment for the update function
/// - `key` a key uniquely representing this model in the parent domain
/// - Returns an update for a new outer state or nil
public static func update(
state: Model,
action viewAction: ViewModel.Action,
environment viewEnvironment: ViewModel.Environment,
key: Key
) -> Update<Model> {
guard let next = update(
state: state,
action: viewAction,
environment: viewEnvironment,
key: key
) else {
return Update(state: state)
}
return next
}
}

extension Binding {
/// Initialize a Binding from a store.
/// - `get` reads the store state to a binding value.
/// - `tag` transforms the value into an action.
/// - `get` reads the binding value.
/// - `send` sends actions to some address.
/// - `tag` tags the value, turning it into an action for `send`
/// - Returns a binding suitable for use in a vanilla SwiftUI view.
public init<Store: StoreProtocol>(
store: Store,
get: @escaping (Store.Model) -> Value,
tag: @escaping (Value) -> Store.Model.Action
public init<Action>(
get: @escaping () -> Value,
send: @escaping (Action) -> Void,
tag: @escaping (Value) -> Action
) {
self.init(
get: { get(store.state) },
set: { value in store.send(tag(value)) }
get: get,
set: { value in send(tag(value)) }
)
}
}
71 changes: 71 additions & 0 deletions Tests/ObservableStoreTests/BindingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// BindingTests.swift
//
//
// Created by Gordon Brander on 9/21/22.
//

import XCTest
import SwiftUI
@testable import ObservableStore

final class BindingTests: XCTestCase {
enum Action: Hashable {
case setText(String)
}

struct Model: ModelProtocol {
var text = ""
var edits: Int = 0

static func update(
state: Model,
action: Action,
environment: Void
) -> Update<Model> {
switch action {
case .setText(let text):
var model = state
model.text = text
model.edits = model.edits + 1
return Update(state: model)
}
}
}

struct SimpleView: View {
@Binding var text: String

var body: some View {
Text(text)
}
}

/// Test creating binding for an address
func testBinding() throws {
let store = Store(
state: Model(),
environment: ()
)

let binding = Binding(
get: { store.state.text },
send: store.send,
tag: Action.setText
)

let view = SimpleView(text: binding)

view.text = "Foo"
view.text = "Bar"

XCTAssertEqual(
store.state.text,
"Bar"
)
XCTAssertEqual(
store.state.edits,
2
)
}
}
Loading

0 comments on commit 332458e

Please sign in to comment.