Skip to content

Commit

Permalink
Observable State for WorkflowSwiftUI (#283)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
watt authored Aug 16, 2024
1 parent 7d1fd1c commit b027b1d
Show file tree
Hide file tree
Showing 44 changed files with 5,344 additions and 255 deletions.
47 changes: 42 additions & 5 deletions .github/workflows/swift.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
-destination "$IOS_DESTINATION" \
build test | bundle exec xcpretty
spm:
xcodegen-apps:
runs-on: macos-latest

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

- name: Swift Package Manager - iOS
- name: Install XcodeGen
run: brew install xcodegen

- name: Generate Xcode project
run: xcodegen generate

- name: ObservableScreen
run: |
xcodebuild \
-project Workflow.xcodeproj \
-scheme "ObservableScreen" \
-destination "$IOS_DESTINATION" \
-skipMacroValidation \
build
package-tests:
runs-on: macos-latest

steps:
- uses: actions/checkout@v4

- name: Switch Xcode
run: sudo xcode-select -s /Applications/Xcode_${XCODE_VERSION}.app

- name: Install XcodeGen
run: brew install xcodegen

- name: Generate Xcode project
run: xcodegen generate

# Macros are only built for the compiler platform, so we cannot run macro tests on iOS. Instead
# we target a scheme from project.yml which selectively includes all the other tests.
- name: Tests - iOS
run: |
xcodebuild \
-scheme "Workflow-Package" \
-project Workflow.xcodeproj \
-scheme "Tests-iOS" \
-destination "$IOS_DESTINATION" \
-skipMacroValidation \
test
- name: Swift Package Manager - macOS
# On macOS we can run all tests, including macro tests.
- name: Tests - macOS
run: |
xcodebuild \
-scheme "Workflow-Package" \
-project Workflow.xcodeproj \
-scheme "Tests-All" \
-destination "platform=macOS" \
-skipMacroValidation \
test
tutorial:
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ xcuserdata/
# Sample workspace
SampleApp.xcworkspace

# XcodeGen
Workflow.xcodeproj/
/TestingSupport/AppHost/App/Info.plist

# ios-snapshot-test-case Failure Diffs
FailureDiffs/

Samples/**/*Info.plist
!Samples/Tutorial/AppHost/Configuration/Info.plist
!Samples/Tutorial/AppHost/TutorialTests/Info.plist
!Samples/AsyncWorker/AsyncWorker/Info.plist
!Samples/AsyncWorker/AsyncWorker/Info.plist
3 changes: 2 additions & 1 deletion .swiftformat
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# file config

--swiftversion 5.7
--swiftversion 5.9
--exclude Pods,Tooling,**Dummy.swift

# format config
Expand All @@ -24,6 +24,7 @@
--enable spaceInsideBraces
--enable specifiers
--enable trailingSpace # https://google.github.io/swift/#horizontal-whitespace
--enable wrapMultilineStatementBraces

--allman false
--binarygrouping none
Expand Down
9 changes: 0 additions & 9 deletions Development.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,6 @@ Pod::Spec.new do |s|
test_spec.source_files = 'WorkflowTesting/Tests/**/*.swift'
end

s.app_spec 'SampleSwiftUIApp' do |app_spec|
app_spec.ios.deployment_target = WORKFLOW_IOS_DEPLOYMENT_TARGET
app_spec.dependency 'WorkflowSwiftUI'
app_spec.pod_target_xcconfig = {
'IFNFOPLIST_FILE' => '${PODS_ROOT}/../Samples/SampleSwiftUIApp/SampleSwiftUIApp/Configuration/Info.plist'
}
app_spec.source_files = 'Samples/SampleSwiftUIApp/SampleSwiftUIApp/**/*.swift'
end

s.app_spec 'SampleTicTacToe' do |app_spec|
app_spec.source_files = 'Samples/TicTacToe/Sources/**/*.swift'
app_spec.resources = 'Samples/TicTacToe/Resources/**/*'
Expand Down
9 changes: 9 additions & 0 deletions NOTICE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Part of the distributed code is also derived in part from
https://github.com/pointfreeco/swift-composable-architecture, licensed under MIT
(https://github.com/pointfreeco/swift-composable-architecture/blob/main/LICENSE).
Copyright (c) 2020 Point-Free, Inc.

Part of the distributed code is also derived in part from
https://github.com/apple/swift licensed under Apache
(https://github.com/apple/swift/blob/main/LICENSE.txt). Copyright 2024 Apple,
Inc.
41 changes: 39 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// swift-tools-version:5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import CompilerPluginSupport
import PackageDescription

let package = Package(
Expand Down Expand Up @@ -58,7 +59,12 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "7.1.1"),
.package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.6.0"),
.package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.44.14"),
.package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.54.0"),
.package(url: "https://github.com/apple/swift-syntax", from: "509.0.0"),
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.1.0"),
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.4.0"),
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.1.4"),
],
targets: [
// MARK: Workflow
Expand Down Expand Up @@ -96,11 +102,42 @@ let package = Package(
dependencies: ["WorkflowUI", "WorkflowReactiveSwift"],
path: "WorkflowUI/Tests"
),

// MARK: WorkflowSwiftUI

.target(
name: "WorkflowSwiftUI",
dependencies: ["Workflow"],
dependencies: [
"Workflow",
"WorkflowUI",
"WorkflowSwiftUIMacros",
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "IdentifiedCollections", package: "swift-identified-collections"),
.product(name: "Perception", package: "swift-perception"),
],
path: "WorkflowSwiftUI/Sources"
),
.testTarget(
name: "WorkflowSwiftUITests",
dependencies: ["WorkflowSwiftUI"],
path: "WorkflowSwiftUI/Tests"
),
.macro(
name: "WorkflowSwiftUIMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
],
path: "WorkflowSwiftUIMacros/Sources"
),
.testTarget(
name: "WorkflowSwiftUIMacrosTests",
dependencies: [
"WorkflowSwiftUIMacros",
.product(name: "MacroTesting", package: "swift-macro-testing"),
],
path: "WorkflowSwiftUIMacros/Tests"
),

// MARK: WorkflowReactiveSwift

Expand Down
3 changes: 1 addition & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ For Squares, membership is managed through the `Workflow Swift Owners` registry

> ⚠️ [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:
> ```bash
> 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
> 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
> ```
> You may need to `--include-podspecs` for pods that have changed and are depended on by other of the pods.
Expand All @@ -43,7 +43,6 @@ For Squares, membership is managed through the `Workflow Swift Owners` registry
bundle exec pod trunk push WorkflowRxSwift.podspec --synchronous
bundle exec pod trunk push WorkflowReactiveSwiftTesting.podspec --synchronous
bundle exec pod trunk push WorkflowRxSwiftTesting.podspec --synchronous
bundle exec pod trunk push WorkflowSwiftUI.podspec --synchronous
bundle exec pod trunk push WorkflowSwiftUIExperimental.podspec --synchronous
bundle exec pod trunk push WorkflowCombine.podspec --synchronous
bundle exec pod trunk push WorkflowCombineTesting.podspec --synchronous
Expand Down
21 changes: 21 additions & 0 deletions Samples/ObservableScreen/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import UIKit
import Workflow
import WorkflowUI

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let root = WorkflowHostingController(
workflow: MultiCounterWorkflow().mapRendering(MultiCounterScreen.init)
)
root.view.backgroundColor = .systemBackground

window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = root
window?.makeKeyAndVisible()

return true
}
}
61 changes: 61 additions & 0 deletions Samples/ObservableScreen/Sources/CounterView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import SwiftUI
import ViewEnvironment
import WorkflowSwiftUI

struct CounterView: View {
typealias Model = CounterModel

let store: Store<Model>
let key: String

var body: some View {
let _ = Self._printChanges()
WithPerceptionTracking {
let _ = print("Evaluated CounterView[\(key)] body")
HStack {
Text(store.info.name)

Spacer()

Button {
store.send(.decrement)
} label: {
Image(systemName: "minus")
}

Text("\(store.count)")
.monospacedDigit()

Button {
store.send(.increment)
} label: {
Image(systemName: "plus")
}

if let maxValue = store.maxValue {
Text("(max \(maxValue))")
}
}
}
}
}

#if DEBUG

#Preview {
CounterView(
store: .preview(
state: .init(
count: 0,
info: .init(
name: "Preview counter",
stepSize: 1
)
)
),
key: "preview"
)
.padding()
}

#endif
Loading

0 comments on commit b027b1d

Please sign in to comment.