Skip to content

Commit b027b1d

Browse files
authored
Observable State for WorkflowSwiftUI (#283)
This is a complete implementation of Workflow-powered SwiftUI views using fine-grained observability to minimize render impact. It's based on the prototype in #276, and @square-tomb's previous prototype #260. Squares can learn more at go/workflow-swiftui. # Observation We're depending on Perception, Point-Free's backport of Observation. The approach to observable state is adapted from TCA's approach, with a custom `@ObservableState` macro that endows struct types with a concept of identity. In workflows, you must annotate your state with `@ObservableState`, and render a type conforming to `ObservableModel`, which wraps your state and sends mutations into the unidirectional flow. There are several built-in conveniences for rendering common cases, or you can create a custom type. On the view side, your `View` will consume a `Store<Model>` wrapper, which provides access to state, sinks, and any child stores from nested workflows. To wire things up, you can implement a trivial type conforming to `ObservableScreen` and map your rendering. It's *strongly* recommended to keep these layers separate: render a model, implement a view, and create a screen only where needed. This allows for compositions of workflows and views, which is difficult or impossible if rendering a screen directly. Check out the new ObservableScreen sample app for complete examples of most concepts being introduced here. I'll also write up an adoption guide that expands on each requirement. # SwiftPM The `@ObservableState` macro means we cannot ship `WorkflowSwiftUI` with CocoaPods. For now, this PR only removes WorkflowSwiftUI podspec, but it may be preferable to remove all podpsecs and migrate everything to SwiftPM to reduce the maintenance burden. To work on WorkflowSwiftUI locally, you can use [xcodegen](https://github.com/yonaskolb/XcodeGen) to create a project, similarly to how `pod gen` works.
1 parent 7d1fd1c commit b027b1d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+5344
-255
lines changed

.github/workflows/swift.yaml

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
-destination "$IOS_DESTINATION" \
5353
build test | bundle exec xcpretty
5454
55-
spm:
55+
xcodegen-apps:
5656
runs-on: macos-latest
5757

5858
steps:
@@ -61,18 +61,55 @@ jobs:
6161
- name: Switch Xcode
6262
run: sudo xcode-select -s /Applications/Xcode_${XCODE_VERSION}.app
6363

64-
- name: Swift Package Manager - iOS
64+
- name: Install XcodeGen
65+
run: brew install xcodegen
66+
67+
- name: Generate Xcode project
68+
run: xcodegen generate
69+
70+
- name: ObservableScreen
71+
run: |
72+
xcodebuild \
73+
-project Workflow.xcodeproj \
74+
-scheme "ObservableScreen" \
75+
-destination "$IOS_DESTINATION" \
76+
-skipMacroValidation \
77+
build
78+
79+
package-tests:
80+
runs-on: macos-latest
81+
82+
steps:
83+
- uses: actions/checkout@v4
84+
85+
- name: Switch Xcode
86+
run: sudo xcode-select -s /Applications/Xcode_${XCODE_VERSION}.app
87+
88+
- name: Install XcodeGen
89+
run: brew install xcodegen
90+
91+
- name: Generate Xcode project
92+
run: xcodegen generate
93+
94+
# Macros are only built for the compiler platform, so we cannot run macro tests on iOS. Instead
95+
# we target a scheme from project.yml which selectively includes all the other tests.
96+
- name: Tests - iOS
6597
run: |
6698
xcodebuild \
67-
-scheme "Workflow-Package" \
99+
-project Workflow.xcodeproj \
100+
-scheme "Tests-iOS" \
68101
-destination "$IOS_DESTINATION" \
102+
-skipMacroValidation \
69103
test
70104
71-
- name: Swift Package Manager - macOS
105+
# On macOS we can run all tests, including macro tests.
106+
- name: Tests - macOS
72107
run: |
73108
xcodebuild \
74-
-scheme "Workflow-Package" \
109+
-project Workflow.xcodeproj \
110+
-scheme "Tests-All" \
75111
-destination "platform=macOS" \
112+
-skipMacroValidation \
76113
test
77114
78115
tutorial:

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ xcuserdata/
2222
# Sample workspace
2323
SampleApp.xcworkspace
2424

25+
# XcodeGen
26+
Workflow.xcodeproj/
27+
/TestingSupport/AppHost/App/Info.plist
28+
2529
# ios-snapshot-test-case Failure Diffs
2630
FailureDiffs/
2731

2832
Samples/**/*Info.plist
2933
!Samples/Tutorial/AppHost/Configuration/Info.plist
3034
!Samples/Tutorial/AppHost/TutorialTests/Info.plist
31-
!Samples/AsyncWorker/AsyncWorker/Info.plist
35+
!Samples/AsyncWorker/AsyncWorker/Info.plist

.swiftformat

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# file config
22

3-
--swiftversion 5.7
3+
--swiftversion 5.9
44
--exclude Pods,Tooling,**Dummy.swift
55

66
# format config
@@ -24,6 +24,7 @@
2424
--enable spaceInsideBraces
2525
--enable specifiers
2626
--enable trailingSpace # https://google.github.io/swift/#horizontal-whitespace
27+
--enable wrapMultilineStatementBraces
2728

2829
--allman false
2930
--binarygrouping none

Development.podspec

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,6 @@ Pod::Spec.new do |s|
4949
test_spec.source_files = 'WorkflowTesting/Tests/**/*.swift'
5050
end
5151

52-
s.app_spec 'SampleSwiftUIApp' do |app_spec|
53-
app_spec.ios.deployment_target = WORKFLOW_IOS_DEPLOYMENT_TARGET
54-
app_spec.dependency 'WorkflowSwiftUI'
55-
app_spec.pod_target_xcconfig = {
56-
'IFNFOPLIST_FILE' => '${PODS_ROOT}/../Samples/SampleSwiftUIApp/SampleSwiftUIApp/Configuration/Info.plist'
57-
}
58-
app_spec.source_files = 'Samples/SampleSwiftUIApp/SampleSwiftUIApp/**/*.swift'
59-
end
60-
6152
s.app_spec 'SampleTicTacToe' do |app_spec|
6253
app_spec.source_files = 'Samples/TicTacToe/Sources/**/*.swift'
6354
app_spec.resources = 'Samples/TicTacToe/Resources/**/*'

NOTICE.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Part of the distributed code is also derived in part from
2+
https://github.com/pointfreeco/swift-composable-architecture, licensed under MIT
3+
(https://github.com/pointfreeco/swift-composable-architecture/blob/main/LICENSE).
4+
Copyright (c) 2020 Point-Free, Inc.
5+
6+
Part of the distributed code is also derived in part from
7+
https://github.com/apple/swift licensed under Apache
8+
(https://github.com/apple/swift/blob/main/LICENSE.txt). Copyright 2024 Apple,
9+
Inc.

Package.swift

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// swift-tools-version:5.9
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

4+
import CompilerPluginSupport
45
import PackageDescription
56

67
let package = Package(
@@ -58,7 +59,12 @@ let package = Package(
5859
dependencies: [
5960
.package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "7.1.1"),
6061
.package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.6.0"),
61-
.package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.44.14"),
62+
.package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.54.0"),
63+
.package(url: "https://github.com/apple/swift-syntax", from: "509.0.0"),
64+
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.1.0"),
65+
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"),
66+
.package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.4.0"),
67+
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.1.4"),
6268
],
6369
targets: [
6470
// MARK: Workflow
@@ -96,11 +102,42 @@ let package = Package(
96102
dependencies: ["WorkflowUI", "WorkflowReactiveSwift"],
97103
path: "WorkflowUI/Tests"
98104
),
105+
106+
// MARK: WorkflowSwiftUI
107+
99108
.target(
100109
name: "WorkflowSwiftUI",
101-
dependencies: ["Workflow"],
110+
dependencies: [
111+
"Workflow",
112+
"WorkflowUI",
113+
"WorkflowSwiftUIMacros",
114+
.product(name: "CasePaths", package: "swift-case-paths"),
115+
.product(name: "IdentifiedCollections", package: "swift-identified-collections"),
116+
.product(name: "Perception", package: "swift-perception"),
117+
],
102118
path: "WorkflowSwiftUI/Sources"
103119
),
120+
.testTarget(
121+
name: "WorkflowSwiftUITests",
122+
dependencies: ["WorkflowSwiftUI"],
123+
path: "WorkflowSwiftUI/Tests"
124+
),
125+
.macro(
126+
name: "WorkflowSwiftUIMacros",
127+
dependencies: [
128+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
129+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
130+
],
131+
path: "WorkflowSwiftUIMacros/Sources"
132+
),
133+
.testTarget(
134+
name: "WorkflowSwiftUIMacrosTests",
135+
dependencies: [
136+
"WorkflowSwiftUIMacros",
137+
.product(name: "MacroTesting", package: "swift-macro-testing"),
138+
],
139+
path: "WorkflowSwiftUIMacros/Tests"
140+
),
104141

105142
// MARK: WorkflowReactiveSwift
106143

RELEASING.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ For Squares, membership is managed through the `Workflow Swift Owners` registry
1717

1818
> ⚠️ [Optional] To avoid possible headaches when publishing podspecs, validation can be performed before updating the Workflow version number(s). To do this, run the following in the root directory of this repo:
1919
> ```bash
20-
> bundle exec pod lib lint Workflow.podspec ViewEnvironment.podspec ViewEnvironmentUI.podspec WorkflowTesting.podspec WorkflowReactiveSwift.podspec WorkflowUI.podspec WorkflowRxSwift.podspec WorkflowReactiveSwiftTesting.podspec WorkflowRxSwiftTesting.podspec WorkflowSwiftUI.podspec WorkflowSwiftUIExperimental.podspec WorkflowCombine.podspec WorkflowCombineTesting.podspec WorkflowConcurrency.podspec WorkflowConcurrencyTesting.podspec
20+
> bundle exec pod lib lint Workflow.podspec ViewEnvironment.podspec ViewEnvironmentUI.podspec WorkflowTesting.podspec WorkflowReactiveSwift.podspec WorkflowUI.podspec WorkflowRxSwift.podspec WorkflowReactiveSwiftTesting.podspec WorkflowRxSwiftTesting.podspec WorkflowSwiftUIExperimental.podspec WorkflowCombine.podspec WorkflowCombineTesting.podspec WorkflowConcurrency.podspec WorkflowConcurrencyTesting.podspec
2121
> ```
2222
> You may need to `--include-podspecs` for pods that have changed and are depended on by other of the pods.
2323
@@ -43,7 +43,6 @@ For Squares, membership is managed through the `Workflow Swift Owners` registry
4343
bundle exec pod trunk push WorkflowRxSwift.podspec --synchronous
4444
bundle exec pod trunk push WorkflowReactiveSwiftTesting.podspec --synchronous
4545
bundle exec pod trunk push WorkflowRxSwiftTesting.podspec --synchronous
46-
bundle exec pod trunk push WorkflowSwiftUI.podspec --synchronous
4746
bundle exec pod trunk push WorkflowSwiftUIExperimental.podspec --synchronous
4847
bundle exec pod trunk push WorkflowCombine.podspec --synchronous
4948
bundle exec pod trunk push WorkflowCombineTesting.podspec --synchronous
Lines changed: 21 additions & 0 deletions
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: MultiCounterWorkflow().mapRendering(MultiCounterScreen.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+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import SwiftUI
2+
import ViewEnvironment
3+
import WorkflowSwiftUI
4+
5+
struct CounterView: View {
6+
typealias Model = CounterModel
7+
8+
let store: Store<Model>
9+
let key: String
10+
11+
var body: some View {
12+
let _ = Self._printChanges()
13+
WithPerceptionTracking {
14+
let _ = print("Evaluated CounterView[\(key)] body")
15+
HStack {
16+
Text(store.info.name)
17+
18+
Spacer()
19+
20+
Button {
21+
store.send(.decrement)
22+
} label: {
23+
Image(systemName: "minus")
24+
}
25+
26+
Text("\(store.count)")
27+
.monospacedDigit()
28+
29+
Button {
30+
store.send(.increment)
31+
} label: {
32+
Image(systemName: "plus")
33+
}
34+
35+
if let maxValue = store.maxValue {
36+
Text("(max \(maxValue))")
37+
}
38+
}
39+
}
40+
}
41+
}
42+
43+
#if DEBUG
44+
45+
#Preview {
46+
CounterView(
47+
store: .preview(
48+
state: .init(
49+
count: 0,
50+
info: .init(
51+
name: "Preview counter",
52+
stepSize: 1
53+
)
54+
)
55+
),
56+
key: "preview"
57+
)
58+
.padding()
59+
}
60+
61+
#endif

0 commit comments

Comments
 (0)