Skip to content

Commit 2ae7450

Browse files
Reintroduce ViewStore, remove CursorProtocol (#25)
1 parent 63b6079 commit 2ae7450

5 files changed

Lines changed: 339 additions & 175 deletions

File tree

README.md

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ A simple Elm-like Store for SwiftUI, based on [ObservableObject](https://develop
44

55
ObservableStore helps you craft more reliable apps by centralizing all of your application state into one place, and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used [Elm](https://guide.elm-lang.org/architecture/) or [Redux](https://redux.js.org/), you get the gist.
66

7-
Because `Store` is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), and can be used anywhere in SwiftUI that ObservableObject would be used.
7+
Because `Store` is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), it can be used anywhere in SwiftUI that ObservableObject would be used.
88

9-
You can centralize all application state in a single Store, use the Store as an [`EnvironmentObject`](https://developer.apple.com/documentation/swiftui/environmentobject), or create multiple `@StateObject` stores. You can also pass scoped parts of a store down to sub-views as `@Bindings` or as ordinary bare properties of `store.state`.
9+
You can centralize all application state in a single Store, use the Store as an [`EnvironmentObject`](https://developer.apple.com/documentation/swiftui/environmentobject), or create multiple `@StateObject` stores. You can also pass scoped parts of a store down to sub-views as `@Bindings`, as scoped `ViewStores`, or as ordinary bare properties of `store.state`.
1010

1111
## Example
1212

@@ -187,14 +187,13 @@ Button("Set color to red") {
187187

188188
## Bindings
189189

190-
`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.
190+
`StoreProtocol.binding(get:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of a 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.
191191

192192
```swift
193193
TextField(
194194
"Username"
195-
text: Binding(
196-
get: { store.state.username },
197-
send: store.send,
195+
text: store.binding(
196+
get: { state in state.username },
198197
tag: { username in .setUsername(username) }
199198
)
200199
)
@@ -205,9 +204,9 @@ Bottom line, because Store is just an ordinary [ObservableObject](https://develo
205204

206205
## Creating scoped child components
207206

208-
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.
207+
We can also create `ViewStore`s that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a `StoreProtocol` interface, instead of a binding interface. 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.
209208

210-
Imagine we have a vanilla SWiftUI child view that looks something like this:
209+
Imagine we have a SWiftUI child view that looks something like this:
211210

212211
```swift
213212
enum ChildAction {
@@ -232,12 +231,11 @@ struct ChildModel: ModelProtocol {
232231
}
233232

234233
struct ChildView: View {
235-
var state: ChildModel
236-
var send: (ChildAction) -> Void
234+
var store: ViewStore<ChildModel>
237235

238236
var body: some View {
239237
VStack {
240-
Text("Count \(state.count)")
238+
Text("Count \(store.state.count)")
241239
Button(
242240
"Increment",
243241
action: {
@@ -249,23 +247,23 @@ struct ChildView: View {
249247
}
250248
```
251249

252-
Let's integrate this child component with a parent component. This is where `CursorProtocol` comes in. It defines three things:
250+
To integrate this child component with a parent component, we're going to need 3 functions:
253251

254-
- A way to `get` a local state from the root state
255-
- A way to `set` a local state on a root state
256-
- A way to `tag` a local action so it becomes a root action
252+
- A function to `get` a local state from the root state
253+
- A function to `set` a local state on a root state
254+
- A function to `tag` a local action so it becomes a root action
257255

258-
Together, these functions give us everything we need to map from child to parent.
256+
Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions so we have them all in one place.
259257

260258
```swift
261-
struct AppChildCursor: CursorProtocol {
259+
struct AppChildCursor {
262260
/// Get child state from parent
263-
static func get(state: ParentModel) -> ChildModel {
261+
static func get(_ state: ParentModel) -> ChildModel {
264262
state.child
265263
}
266264

267265
/// Set child state on parent
268-
static func set(state: ParentModel, inner child: ChildModel) -> ParentModel {
266+
static func set(_ state: ParentModel, _ child: ChildModel) -> ParentModel {
269267
var model = state
270268
model.child = child
271269
return model
@@ -281,25 +279,28 @@ struct AppChildCursor: CursorProtocol {
281279
}
282280
```
283281

284-
Let's start by integrating the child view with the parent view. We pass down part of the parent state to the child, along with a scoped `send` callback that will map child actions to parent actions. We can create this scoped `send` with `Address.forward`, passing it the store's send method, and the cursor's tag function.
282+
Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.
283+
284+
We call the `store.viewStore(get:tag:)` method to create a scoped ViewStore from our store, and pass it the appropriate cursor functions.
285285

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

290290
var body: some View {
291291
ChildView(
292-
state: store.state.child,
293-
send: Address.forward(
294-
send: store.send,
292+
store: store.viewStore(
293+
get: AppChildCursor.get,
295294
tag: AppChildCursor.tag
296295
)
297296
)
298297
}
299298
}
300299
```
301300

302-
Next, we want to integrate the child's update function into the parent update function. Luckily, `CursorProtocol` synthesizes an `update` function that automatically maps child state and actions to parent state and actions.
301+
Note that `.viewStore(get:tag:)` is an extension of `StoreProtocol`, so you can call it on `Store` or `ViewStore` to create arbitrarily nested components!
302+
303+
Next, we want to integrate the child's update function into the parent update function. Luckily, `ModelProtocol` synthesizes an `update(get:set:tag:state:action:environment)` function that automatically maps child state and actions to parent state and actions.
303304

304305
```swift
305306
enum AppAction {
@@ -316,7 +317,10 @@ struct AppModel: ModelProtocol {
316317
) -> Update<AppModel> {
317318
switch {
318319
case .child(let action):
319-
return AppChildCursor.update(
320+
return update(
321+
get: AppChildCursor.get,
322+
set: AppChildCursor.set,
323+
tag: AppChildCursor.tag,
320324
state: state,
321325
action: action,
322326
environment: ()
@@ -326,4 +330,4 @@ struct AppModel: ModelProtocol {
326330
}
327331
```
328332

329-
This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.
333+
And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

Sources/ObservableStore/ObservableStore.swift

Lines changed: 94 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,45 @@ extension ModelProtocol {
5454
}
5555
}
5656

57+
extension ModelProtocol {
58+
/// Update a child state within a parent state.
59+
/// This update offers a convenient way to call child update functions
60+
/// from the parent domain, and get parent-domain states and actions
61+
/// back from it.
62+
///
63+
/// - `get` gets the child's state
64+
/// - `set` sets the child's state within the parent state
65+
/// - `tag` tags child actions, turning them into parent actions
66+
/// - `state` the outer state
67+
/// - `action` the inner action
68+
/// - `environment` the environment for the update function
69+
/// - Returns a new outer state
70+
public static func update<ViewModel: ModelProtocol>(
71+
get: (Self) -> ViewModel?,
72+
set: (Self, ViewModel) -> Self,
73+
tag: @escaping (ViewModel.Action) -> Self.Action,
74+
state: Self,
75+
action viewAction: ViewModel.Action,
76+
environment: ViewModel.Environment
77+
) -> Update<Self> {
78+
// If getter returns nil (as in case of a list item that no longer
79+
// exists), do nothing.
80+
guard let inner = get(state) else {
81+
return Update(state: state)
82+
}
83+
let next = ViewModel.update(
84+
state: inner,
85+
action: viewAction,
86+
environment: environment
87+
)
88+
return Update(
89+
state: set(state, next.state),
90+
fx: next.fx.map(tag).eraseToAnyPublisher(),
91+
transaction: next.transaction
92+
)
93+
}
94+
}
95+
5796
/// Update represents a state change, together with an `Fx` publisher,
5897
/// and an optional `Transaction`.
5998
public struct Update<Model: ModelProtocol> {
@@ -258,138 +297,59 @@ where Model: ModelProtocol
258297
}
259298
}
260299

261-
public struct Address {
262-
/// Forward transform an address (send function) into a local address.
263-
/// View-scoped actions are tagged using `tag` before being forwarded to
264-
/// `send.`
265-
public static func forward<Action, ViewAction>(
266-
send: @escaping (Action) -> Void,
267-
tag: @escaping (ViewAction) -> Action
268-
) -> (ViewAction) -> Void {
269-
{ viewAction in send(tag(viewAction)) }
270-
}
271-
}
272-
273-
/// A cursor provides a complete description of how to map from one component
274-
/// domain to another.
275-
public protocol CursorProtocol {
276-
associatedtype Model: ModelProtocol
277-
associatedtype ViewModel: ModelProtocol
278-
279-
/// Get an inner state from an outer state
280-
static func get(state: Model) -> ViewModel
300+
public struct ViewStore<ViewModel: ModelProtocol>: StoreProtocol {
301+
private var _send: (ViewModel.Action) -> Void
302+
public var state: ViewModel
281303

282-
/// Set an inner state on an outer state, returning an outer state
283-
static func set(state: Model, inner: ViewModel) -> Model
284-
285-
/// Tag an inner action, transforming it into an outer action
286-
static func tag(_ action: ViewModel.Action) -> Model.Action
304+
public init(
305+
state: ViewModel,
306+
send: @escaping (ViewModel.Action) -> Void
307+
) {
308+
self.state = state
309+
self._send = send
310+
}
311+
312+
public func send(_ action: ViewModel.Action) {
313+
self._send(action)
314+
}
287315
}
288316

289-
extension CursorProtocol {
290-
/// Update an outer state through a cursor.
291-
/// CursorProtocol.update offers a convenient way to call child
292-
/// update functions from the parent domain, and get parent-domain
293-
/// states and actions back from it.
294-
///
295-
/// - `state` the outer state
296-
/// - `action` the inner action
297-
/// - `environment` the environment for the update function
298-
/// - Returns a new outer state
299-
public static func update(
300-
state: Model,
301-
action viewAction: ViewModel.Action,
302-
environment: ViewModel.Environment
303-
) -> Update<Model> {
304-
let next = ViewModel.update(
305-
state: get(state: state),
306-
action: viewAction,
307-
environment: environment
308-
)
309-
return Update(
310-
state: set(state: state, inner: next.state),
311-
fx: next.fx.map(tag).eraseToAnyPublisher(),
312-
transaction: next.transaction
317+
extension ViewStore {
318+
public init<Action>(
319+
state: ViewModel,
320+
send: @escaping (Action) -> Void,
321+
tag: @escaping (ViewModel.Action) -> Action
322+
) {
323+
self.init(
324+
state: state,
325+
send: { action in send(tag(action)) }
313326
)
314327
}
315328
}
316329

317-
public protocol KeyedCursorProtocol {
318-
associatedtype Key
319-
associatedtype Model: ModelProtocol
320-
associatedtype ViewModel: ModelProtocol
321-
322-
/// Get an inner state from an outer state
323-
static func get(state: Model, key: Key) -> ViewModel?
324-
325-
/// Set an inner state on an outer state, returning an outer state
326-
static func set(state: Model, inner: ViewModel, key: Key) -> Model
327-
328-
/// Tag an inner action, transforming it into an outer action
329-
static func tag(action: ViewModel.Action, key: Key) -> Model.Action
330-
}
331-
332-
extension KeyedCursorProtocol {
333-
/// Update an inner state within an outer state through a keyed cursor.
334-
/// This cursor type is useful when looking up children in dynamic lists
335-
/// such as arrays or dictionaries.
336-
///
337-
/// - `state` the outer state
338-
/// - `action` the inner action
339-
/// - `environment` the environment for the update function
340-
/// - `key` a key uniquely representing this model in the parent domain
341-
/// - Returns an update for a new outer state or nil
342-
public static func update(
343-
state: Model,
344-
action viewAction: ViewModel.Action,
345-
environment viewEnvironment: ViewModel.Environment,
346-
key: Key
347-
) -> Update<Model>? {
348-
guard let viewModel = get(state: state, key: key) else {
349-
return nil
350-
}
351-
let next = ViewModel.update(
352-
state: viewModel,
353-
action: viewAction,
354-
environment: viewEnvironment
355-
)
356-
return Update(
357-
state: set(state: state, inner: next.state, key: key),
358-
fx: next.fx
359-
.map({ viewAction in Self.tag(action: viewAction, key: key) })
360-
.eraseToAnyPublisher(),
361-
transaction: next.transaction
330+
extension StoreProtocol {
331+
/// Create a viewStore from a StoreProtocol
332+
public func viewStore<ViewModel: ModelProtocol>(
333+
get: (Self.Model) -> ViewModel,
334+
tag: @escaping (ViewModel.Action) -> Self.Model.Action
335+
) -> ViewStore<ViewModel> {
336+
ViewStore(
337+
state: get(self.state),
338+
send: self.send,
339+
tag: tag
362340
)
363341
}
342+
}
364343

365-
/// Update an inner state within an outer state through a keyed cursor.
366-
/// This cursor type is useful when looking up children in dynamic lists
367-
/// such as arrays or dictionaries.
368-
///
369-
/// This version of update always returns an `Update`. If the child model
370-
/// cannot be found at key, then it returns an update for the same state
371-
/// (noop), effectively ignoring the action.
372-
///
373-
/// - `state` the outer state
374-
/// - `action` the inner action
375-
/// - `environment` the environment for the update function
376-
/// - `key` a key uniquely representing this model in the parent domain
377-
/// - Returns an update for a new outer state or nil
378-
public static func update(
379-
state: Model,
380-
action viewAction: ViewModel.Action,
381-
environment viewEnvironment: ViewModel.Environment,
382-
key: Key
383-
) -> Update<Model> {
384-
guard let next = update(
385-
state: state,
386-
action: viewAction,
387-
environment: viewEnvironment,
388-
key: key
389-
) else {
390-
return Update(state: state)
391-
}
392-
return next
344+
public struct Address {
345+
/// Forward transform an address (send function) into a local address.
346+
/// View-scoped actions are tagged using `tag` before being forwarded to
347+
/// `send.`
348+
public static func forward<Action, ViewAction>(
349+
send: @escaping (Action) -> Void,
350+
tag: @escaping (ViewAction) -> Action
351+
) -> (ViewAction) -> Void {
352+
{ viewAction in send(tag(viewAction)) }
393353
}
394354
}
395355

@@ -410,3 +370,15 @@ extension Binding {
410370
)
411371
}
412372
}
373+
374+
extension StoreProtocol {
375+
public func binding<Value>(
376+
get: @escaping (Self.Model) -> Value,
377+
tag: @escaping (Value) -> Self.Model.Action
378+
) -> Binding<Value> {
379+
Binding(
380+
get: { get(self.state) },
381+
set: { value in self.send(tag(value)) }
382+
)
383+
}
384+
}

0 commit comments

Comments
 (0)