Skip to content

Commit c50ec9b

Browse files
authored
feat: nested observable state stores (#333)
This PR adds new scoping methods to `Store` that allow you to create child stores from nested observable state. These same scoping methods already exist for child _models_, typically created by rendering child workflows and composing their renderings, but not for nested state within a single workflow. I've added a new `ObservableCounter` sample to demonstrate. The existing `ObservableScreen` sample has been renamed to `ObservableComposition`. ## Example Consider a state like this: ```swift @ObservableState struct State { var children: [ChildState] @ObservableState struct ChildState: Identifiable { let id = UUID() var foo: Int } } ``` If one was trying to iterate over `children` and form bindings to the `foo` property, you might attempt this construction, which creates a `Binding<[ChildState]>` and then uses `ForEach` to map over individual `Binding<ChildState>`s. ```swift struct ExampleView: View { @Perception.Bindable var store: Store<StateAccessor<State>> var body: some View { ForEach($store.children) { child in FooView(foo: child.foo) } } } ``` Unfortunately this will compile and run, but writes to `child.foo` will not cause `FooView` to be invalidated. The exact mechanics behind this are opaque, but the bindings captured by the `ForEach` don't register observation at the right scope. Using the new scoping methods, you can iterate over child stores, and create a binding from each store instead. ```swift struct ExampleView: View { var store: Store<StateAccessor<State>> var body: some View { ForEach(store.scope(collection: \.children)) { child in // you can also inline this incantation to the capture list if you prefer @Perception.Bindable var child = child FooView(foo: $child.foo) } } } ```
1 parent 01389e2 commit c50ec9b

23 files changed

+900
-120
lines changed

Documentation/WorkflowSwiftUI Adoption Guide.md

+77
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ struct PersonView: View {
219219
}
220220
```
221221

222+
`WithPerceptionTracking` is necessary in any view that accesses a `Store`, as well as inside many containers that lazily evaluate their content, including `ForEach`, `GeometryReader`, `List`, `LazyVStack`, and `LazyHStack`.
223+
222224
If you forget to wrap your view's body in `WithPerceptionTracking`, a runtime warning will remind you.
223225

224226
## Screens
@@ -341,6 +343,81 @@ struct CoupleView: View {
341343

342344
Child store mapping works for plain `ObservableModel` properties, as well as optionals, collections, and identified collections.
343345

346+
## Nested observable state
347+
348+
If your state has properties with types that also conform to `ObservableState`, you can create a child `Store` for those properties, similarly to creating child stores of child models from child workflows. Use the `scope(keyPath:)` methods to create a store for regular properties and optionals, and the `scope(collection:)` methods to create stores for items in collections.
349+
350+
Scoping nested state to a child store is not required for simple properties, but is required to create an optional binding, and for collections in most cases. See the example below.
351+
352+
```swift
353+
@ObservableState
354+
struct State {
355+
356+
@ObservableState
357+
struct CounterState: Identifiable {
358+
let id = UUID()
359+
var count = 0
360+
}
361+
362+
var counter = CounterState()
363+
var optionalCounter: CounterState? = CounterState()
364+
var moreCounters: [CounterState] = [.init(), .init()]
365+
var evenMoreCounters: IdentifiedArrayOf<CounterState> = [.init(), .init()]
366+
}
367+
368+
struct VariousCounterView: View {
369+
@Perception.Bindable
370+
var store: Store<StateAccessor<CounterState>>
371+
372+
var body: some View {
373+
WithPerceptionTracking {
374+
// direct access OK
375+
CounterView(count: $store.counter.count)
376+
377+
// nested store, also OK
378+
@Perception.Bindable var counter = store.scope(keyPath: \.counter)
379+
CounterView(count: $counter.count)
380+
381+
// required to get `Binding<Int>?` instead of `Binding<Int?>`
382+
if let optionalCounter = store.scope(keyPath: \.optionalCounter) {
383+
@Perception.Bindable var optionalCounter = optionalCounter
384+
WithPerceptionTracking {
385+
CounterView(count: $optionalCounter.count)
386+
}
387+
}
388+
389+
// ❌ compiles but does not work!
390+
ForEach($store.moreCounters) { counter in
391+
WithPerceptionTracking {
392+
CounterView(count: counter.count)
393+
}
394+
}
395+
396+
// required for this collection
397+
ForEach(store.scope(collection: \.moreCounters)) { counter in
398+
@Perception.Bindable var counter = counter
399+
WithPerceptionTracking {
400+
CounterView(count: $counter.count)
401+
}
402+
}
403+
404+
// also required for this collection
405+
ForEach(store.scope(collection: \.evenMoreCounters)) { counter in
406+
@Perception.Bindable var counter = counter
407+
WithPerceptionTracking {
408+
CounterView(count: $counter.count)
409+
}
410+
}
411+
}
412+
}
413+
}
414+
415+
struct CounterView: View {
416+
@Binding var count: Int
417+
// <snip>
418+
}
419+
```
420+
344421
## Actions
345422

346423
If your model is a single-action `ActionModel` created by `context.makeActionModel()` , you can send actions by calling `send` on your `Store`.

Package.resolved

-104
This file was deleted.

Package.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ let package = Package(
6363
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.1.0"),
6464
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"),
6565
.package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.4.0"),
66-
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.1.4"),
66+
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.5.0"),
6767
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.2.1"),
6868
],
6969
targets: [
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ObservableComposition
2+
3+
This demo shows how to compose views using child workflows. `CounterWorkflow` renders a model to be used in `CounterView`. `MultiCounterWorkflow` renders multiple child `CounterWorkflow`s, and `MultiCounterView` composes multiple child `CounterView`s.

Samples/ObservableCounter/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ObservableCounter
2+
3+
This demo shows a single workflow with observable state, which contains an array of nested observable states. In `CounterListView`, the nested states are scoped into bindable stores. The `SimpleCounterView` takes a vanilla binding to an `Int` and has no knowledge of WorkflowSwiftUI.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import UIKit
2+
import Workflow
3+
import WorkflowUI
4+
5+
@main
6+
class AppDelegate: UIResponder, UIApplicationDelegate {
7+
var window: UIWindow?
8+
9+
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
10+
let root = WorkflowHostingController(
11+
workflow: CounterListWorkflow().mapRendering(CounterListScreen.init)
12+
)
13+
root.view.backgroundColor = .systemBackground
14+
15+
window = UIWindow(frame: UIScreen.main.bounds)
16+
window?.rootViewController = root
17+
window?.makeKeyAndVisible()
18+
19+
return true
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
import WorkflowSwiftUI
3+
4+
typealias CounterListModel = StateAccessor<CounterListState>
5+
6+
@ObservableState
7+
struct CounterListState {
8+
var counters: [Counter]
9+
10+
@ObservableState
11+
struct Counter: Identifiable {
12+
let id = UUID()
13+
var count: Int
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SwiftUI
2+
import WorkflowSwiftUI
3+
4+
struct CounterListScreen: ObservableScreen {
5+
let model: CounterListModel
6+
7+
static func makeView(store: Store<CounterListModel>) -> some View {
8+
CounterListView(store: store)
9+
}
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import SwiftUI
2+
import WorkflowSwiftUI
3+
4+
struct CounterListView: View {
5+
@Perception.Bindable
6+
var store: Store<CounterListModel>
7+
8+
var body: some View {
9+
// These print statements show the effect of state changes on body evaluations.
10+
let _ = print("CounterListView.body")
11+
WithPerceptionTracking {
12+
VStack {
13+
ForEach(store.scope(collection: \.counters)) { counter in
14+
@Perception.Bindable var counter = counter
15+
16+
WithPerceptionTracking {
17+
let _ = print("CounterListView.body.ForEach.body")
18+
SimpleCounterView(count: $counter.count)
19+
}
20+
}
21+
}
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
import SwiftUI
3+
import Workflow
4+
import WorkflowSwiftUI
5+
6+
struct CounterListWorkflow: Workflow {
7+
typealias State = CounterListState
8+
typealias Model = CounterListModel
9+
typealias Rendering = Model
10+
11+
func makeInitialState() -> State {
12+
State(counters: [.init(count: 0), .init(count: 0), .init(count: 0)])
13+
}
14+
15+
func render(
16+
state: State,
17+
context: RenderContext<CounterListWorkflow>
18+
) -> Rendering {
19+
print("State: \(state.counters.map(\.count))")
20+
return context.makeStateAccessor(state: state)
21+
}
22+
}
23+
24+
#Preview {
25+
CounterListWorkflow()
26+
.mapRendering(CounterListScreen.init)
27+
.workflowPreview()
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import SwiftUI
2+
3+
struct SimpleCounterView: View {
4+
@Binding
5+
var count: Int
6+
7+
var body: some View {
8+
let _ = print("SimpleCounterView.body")
9+
HStack {
10+
Button {
11+
count -= 1
12+
} label: {
13+
Image(systemName: "minus")
14+
}
15+
16+
Text("\(count)")
17+
.monospacedDigit()
18+
19+
Button {
20+
count += 1
21+
} label: {
22+
Image(systemName: "plus")
23+
}
24+
}
25+
}
26+
}

Samples/Project.swift

+8-2
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,14 @@ let project = Project(
6767
),
6868

6969
.app(
70-
name: "ObservableScreen",
71-
sources: "ObservableScreen/Sources/**",
70+
name: "ObservableCounter",
71+
sources: "ObservableCounter/Sources/**",
72+
dependencies: [.external(name: "WorkflowSwiftUI")]
73+
),
74+
75+
.app(
76+
name: "ObservableComposition",
77+
sources: "ObservableComposition/Sources/**",
7278
dependencies: [.external(name: "WorkflowSwiftUI")]
7379
),
7480

Samples/Tuist/Package.resolved

+4-4
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,17 @@
6868
"kind" : "remoteSourceControl",
6969
"location" : "https://github.com/pointfreeco/swift-perception",
7070
"state" : {
71-
"revision" : "1552c8f722ac256cc0b8daaf1a7073217d4fcdfb",
72-
"version" : "1.3.4"
71+
"revision" : "21811d6230a625fa0f2e6ffa85be857075cc02c4",
72+
"version" : "1.5.0"
7373
}
7474
},
7575
{
7676
"identity" : "swift-syntax",
7777
"kind" : "remoteSourceControl",
7878
"location" : "https://github.com/swiftlang/swift-syntax",
7979
"state" : {
80-
"revision" : "0687f71944021d616d34d922343dcef086855920",
81-
"version" : "600.0.1"
80+
"revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82",
81+
"version" : "510.0.3"
8282
}
8383
},
8484
{

0 commit comments

Comments
 (0)