From b027b1d8ee43095ad133c56d347910560bd886ce Mon Sep 17 00:00:00 2001 From: Andrew Watt <100192+watt@users.noreply.github.com> Date: Fri, 16 Aug 2024 15:52:43 -0700 Subject: [PATCH] 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` 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. --- .github/workflows/swift.yaml | 47 +- .gitignore | 6 +- .swiftformat | 3 +- Development.podspec | 9 - NOTICE.txt | 9 + Package.swift | 41 +- RELEASING.md | 3 +- .../Sources/AppDelegate.swift | 21 + .../Sources/CounterView.swift | 61 ++ .../Sources/CounterWorkflow.swift | 119 +++ .../Sources/MultiCounterScreen.swift | 14 + .../Sources/MultiCounterView.swift | 108 +++ .../Sources/MultiCounterWorkflow.swift | 125 +++ Samples/SampleSwiftUIApp/.gitignore | 1 - Samples/SampleSwiftUIApp/Podfile | 7 - .../SampleSwiftUIApp/AppDelegate.swift | 39 - .../SampleSwiftUIApp/CounterView.swift | 90 -- .../SampleSwiftUIApp/SceneDelegate.swift | 67 -- .../AppHost/Sources/AppDelegate.swift | 20 + WorkflowSwiftUI.podspec | 25 - WorkflowSwiftUI/Sources/ActionModel.swift | 24 + WorkflowSwiftUI/Sources/Bindable+Store.swift | 124 +++ .../Derived/AreOrderedSetsDuplicates.swift | 17 + .../Sources/Derived/ObservableState.swift | 184 ++++ .../Derived/ObservationStateRegistrar.swift | 186 ++++ WorkflowSwiftUI/Sources/Exports.swift | 6 + WorkflowSwiftUI/Sources/Macros.swift | 20 + WorkflowSwiftUI/Sources/ObservableModel.swift | 150 ++++ .../Sources/ObservableScreen+Preview.swift | 173 ++++ .../Sources/ObservableScreen.swift | 208 +++++ .../RenderContext+ObservableModel.swift | 32 + WorkflowSwiftUI/Sources/StateAccessor.swift | 25 + WorkflowSwiftUI/Sources/Store+Preview.swift | 83 ++ WorkflowSwiftUI/Sources/Store.swift | 453 ++++++++++ .../Sources/Workflow+Preview.swift | 151 ++++ WorkflowSwiftUI/Sources/WorkflowView.swift | 18 +- .../Tests/Derived/ObservableStateTests.swift | 335 +++++++ WorkflowSwiftUI/Tests/StoreTests.swift | 821 ++++++++++++++++++ .../Sources/Derived/Availability.swift | 109 +++ .../Sources/Derived/Extensions.swift | 302 +++++++ .../Derived/ObservableStateMacro.swift | 599 +++++++++++++ WorkflowSwiftUIMacros/Sources/Plugins.swift | 11 + .../Derived/ObservableStateMacroTests.swift | 651 ++++++++++++++ project.yml | 102 +++ 44 files changed, 5344 insertions(+), 255 deletions(-) create mode 100644 NOTICE.txt create mode 100644 Samples/ObservableScreen/Sources/AppDelegate.swift create mode 100644 Samples/ObservableScreen/Sources/CounterView.swift create mode 100644 Samples/ObservableScreen/Sources/CounterWorkflow.swift create mode 100644 Samples/ObservableScreen/Sources/MultiCounterScreen.swift create mode 100644 Samples/ObservableScreen/Sources/MultiCounterView.swift create mode 100644 Samples/ObservableScreen/Sources/MultiCounterWorkflow.swift delete mode 100644 Samples/SampleSwiftUIApp/.gitignore delete mode 100644 Samples/SampleSwiftUIApp/Podfile delete mode 100644 Samples/SampleSwiftUIApp/SampleSwiftUIApp/AppDelegate.swift delete mode 100644 Samples/SampleSwiftUIApp/SampleSwiftUIApp/CounterView.swift delete mode 100644 Samples/SampleSwiftUIApp/SampleSwiftUIApp/SceneDelegate.swift create mode 100644 TestingSupport/AppHost/Sources/AppDelegate.swift delete mode 100644 WorkflowSwiftUI.podspec create mode 100644 WorkflowSwiftUI/Sources/ActionModel.swift create mode 100644 WorkflowSwiftUI/Sources/Bindable+Store.swift create mode 100644 WorkflowSwiftUI/Sources/Derived/AreOrderedSetsDuplicates.swift create mode 100644 WorkflowSwiftUI/Sources/Derived/ObservableState.swift create mode 100644 WorkflowSwiftUI/Sources/Derived/ObservationStateRegistrar.swift create mode 100644 WorkflowSwiftUI/Sources/Exports.swift create mode 100644 WorkflowSwiftUI/Sources/Macros.swift create mode 100644 WorkflowSwiftUI/Sources/ObservableModel.swift create mode 100644 WorkflowSwiftUI/Sources/ObservableScreen+Preview.swift create mode 100644 WorkflowSwiftUI/Sources/ObservableScreen.swift create mode 100644 WorkflowSwiftUI/Sources/RenderContext+ObservableModel.swift create mode 100644 WorkflowSwiftUI/Sources/StateAccessor.swift create mode 100644 WorkflowSwiftUI/Sources/Store+Preview.swift create mode 100644 WorkflowSwiftUI/Sources/Store.swift create mode 100644 WorkflowSwiftUI/Sources/Workflow+Preview.swift create mode 100644 WorkflowSwiftUI/Tests/Derived/ObservableStateTests.swift create mode 100644 WorkflowSwiftUI/Tests/StoreTests.swift create mode 100644 WorkflowSwiftUIMacros/Sources/Derived/Availability.swift create mode 100644 WorkflowSwiftUIMacros/Sources/Derived/Extensions.swift create mode 100644 WorkflowSwiftUIMacros/Sources/Derived/ObservableStateMacro.swift create mode 100644 WorkflowSwiftUIMacros/Sources/Plugins.swift create mode 100644 WorkflowSwiftUIMacros/Tests/Derived/ObservableStateMacroTests.swift create mode 100644 project.yml diff --git a/.github/workflows/swift.yaml b/.github/workflows/swift.yaml index 7963f6199..2f763ba3f 100644 --- a/.github/workflows/swift.yaml +++ b/.github/workflows/swift.yaml @@ -52,7 +52,7 @@ jobs: -destination "$IOS_DESTINATION" \ build test | bundle exec xcpretty - spm: + xcodegen-apps: runs-on: macos-latest steps: @@ -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: diff --git a/.gitignore b/.gitignore index 54fe2f140..c64a570a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +!Samples/AsyncWorker/AsyncWorker/Info.plist diff --git a/.swiftformat b/.swiftformat index f4cccbf8c..1000b907c 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,6 +1,6 @@ # file config ---swiftversion 5.7 +--swiftversion 5.9 --exclude Pods,Tooling,**Dummy.swift # format config @@ -24,6 +24,7 @@ --enable spaceInsideBraces --enable specifiers --enable trailingSpace # https://google.github.io/swift/#horizontal-whitespace +--enable wrapMultilineStatementBraces --allman false --binarygrouping none diff --git a/Development.podspec b/Development.podspec index 0958144aa..cb754da2b 100644 --- a/Development.podspec +++ b/Development.podspec @@ -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/**/*' diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 000000000..77b241f50 --- /dev/null +++ b/NOTICE.txt @@ -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. diff --git a/Package.swift b/Package.swift index 0f0167efc..3f8fc3b75 100644 --- a/Package.swift +++ b/Package.swift @@ -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( @@ -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 @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 0c30f99ed..16827fcb1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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. @@ -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 diff --git a/Samples/ObservableScreen/Sources/AppDelegate.swift b/Samples/ObservableScreen/Sources/AppDelegate.swift new file mode 100644 index 000000000..1b75f344c --- /dev/null +++ b/Samples/ObservableScreen/Sources/AppDelegate.swift @@ -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 + } +} diff --git a/Samples/ObservableScreen/Sources/CounterView.swift b/Samples/ObservableScreen/Sources/CounterView.swift new file mode 100644 index 000000000..285681605 --- /dev/null +++ b/Samples/ObservableScreen/Sources/CounterView.swift @@ -0,0 +1,61 @@ +import SwiftUI +import ViewEnvironment +import WorkflowSwiftUI + +struct CounterView: View { + typealias Model = CounterModel + + let store: Store + 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 diff --git a/Samples/ObservableScreen/Sources/CounterWorkflow.swift b/Samples/ObservableScreen/Sources/CounterWorkflow.swift new file mode 100644 index 000000000..759f901a9 --- /dev/null +++ b/Samples/ObservableScreen/Sources/CounterWorkflow.swift @@ -0,0 +1,119 @@ +import Foundation +import Workflow +import WorkflowSwiftUI +import WorkflowUI + +struct CounterWorkflow: Workflow { + // Dependencies from parent. + let info: CounterInfo + let resetToken: ResetToken + let initialValue: Int + let maxValue: Int? + + @ObservableState + struct State { + private var _count: Int + + var count: Int { + get { _count } + set { + if let maxValue, newValue > maxValue { + _count = maxValue + } else { + _count = newValue + } + } + } + + var maxValue: Int? { + didSet { + guard let maxValue else { return } + if count > maxValue { + count = maxValue + } + } + } + + var info: CounterInfo + + init(count: Int, maxValue: Int? = nil, info: CounterInfo) { + self._count = count + self.maxValue = maxValue + self.info = info + } + } + + enum Action: WorkflowAction { + typealias WorkflowType = CounterWorkflow + + case increment + case decrement + + func apply(toState state: inout CounterWorkflow.State) -> CounterWorkflow.Output? { + switch self { + case .increment: + state.count += state.info.stepSize + case .decrement: + state.count -= state.info.stepSize + } + return nil + } + } + + typealias Output = Never + + init(info: CounterInfo, resetToken: ResetToken, initialValue: Int = 0, maxValue: Int? = nil) { + self.info = info + self.resetToken = resetToken + self.initialValue = initialValue + self.maxValue = maxValue + } + + func makeInitialState() -> State { + State(count: initialValue, maxValue: maxValue, info: info) + } + + func workflowDidChange(from previousWorkflow: CounterWorkflow, state: inout State) { + guard resetToken == previousWorkflow.resetToken else { + // this state reset will totally invalidate the body even if `count` doesn't change + state = makeInitialState() + return + } + + // CounterInfo is an @ObservableState dependency. + // We can safely set it on every render and rely on Observation to handle change detection. + state.info = info + + // maxValue is not observable. We should conditionally update it only on change. + // Otherwise every set will trigger invalidation. + if maxValue != previousWorkflow.maxValue { + state.maxValue = maxValue + } + } + + typealias Rendering = ActionModel + typealias Model = ActionModel + + func render( + state: State, + context: RenderContext + ) -> ActionModel { + print("\(Self.self) rendered \(state.info.name) count: \(state.count)") + return context.makeActionModel(state: state) + } +} + +typealias CounterModel = CounterWorkflow.Model + +@ObservableState +struct CounterInfo { + let id = UUID() + var name: String + var stepSize = 1 +} + +extension CounterWorkflow { + struct ResetToken: Equatable { + let id = UUID() + } +} diff --git a/Samples/ObservableScreen/Sources/MultiCounterScreen.swift b/Samples/ObservableScreen/Sources/MultiCounterScreen.swift new file mode 100644 index 000000000..44a34af9d --- /dev/null +++ b/Samples/ObservableScreen/Sources/MultiCounterScreen.swift @@ -0,0 +1,14 @@ +import Foundation +import Perception +import SwiftUI +import ViewEnvironment +import Workflow +import WorkflowSwiftUI + +struct MultiCounterScreen: ObservableScreen { + let model: MultiCounterModel + + static func makeView(store: Store) -> some View { + MultiCounterView(store: store) + } +} diff --git a/Samples/ObservableScreen/Sources/MultiCounterView.swift b/Samples/ObservableScreen/Sources/MultiCounterView.swift new file mode 100644 index 000000000..a724108fe --- /dev/null +++ b/Samples/ObservableScreen/Sources/MultiCounterView.swift @@ -0,0 +1,108 @@ +import Foundation +import Perception +import SwiftUI +import ViewEnvironment +import Workflow +import WorkflowSwiftUI + +struct MultiCounterView: View { + @Perception.Bindable var store: Store + + var body: some View { + WithPerceptionTracking { + let _ = print("Evaluated MultiCounterView body") + VStack { + Text("Multi Counter Demo") + .font(.title) + + controls + + if let maxCounter = store.maxCounter { + CounterView(store: maxCounter, key: "max") + } + + ForEach( + Array(store.counters.enumerated()), + id: \.element.id + ) { index, counter in + HStack { + Button { + store.counterAction.send(.removeCounter(counter.info.id)) + } label: { + Image(systemName: "xmark.circle") + } + + CounterView(store: counter, key: "\(index)") + } + .padding(.vertical, 4) + } + + // When showSum is false, changes to counters do not invalidate this body + if store.showSum { + HStack { + Text("Sum") + Spacer() + Text("\(store.counters.map(\.count).reduce(0, +))") + } + } + + Spacer() + } + .padding() + } + } + + @ViewBuilder + var controls: some View { + // Binding directly to state + Toggle( + "Show Max", + isOn: $store.showMax + ) + // Binding with a custom setter action + ToggleWrapper( + "Show Sum", + isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum) + ) + + HStack { + Button("Add Counter") { + store.counterAction.send(.addCounter) + } + + Button("Reset Counters") { + // struct action + store.resetAction.send(.init()) + } + } + .buttonStyle(.bordered) + } +} + +struct ToggleWrapper: View { + var name: String + @Binding var isOn: Bool + + init(_ name: String, isOn: Binding) { + self.name = name + self._isOn = isOn + } + + var body: some View { + let _ = print("Evaluated ToggleWrapper body") + + Toggle("Show Sum", isOn: $isOn) + } +} + +#if DEBUG + +struct MultiCounterView_Previews: PreviewProvider { + static var previews: some View { + MultiCounterWorkflow() + .mapRendering(MultiCounterScreen.init) + .workflowPreview() + } +} + +#endif diff --git a/Samples/ObservableScreen/Sources/MultiCounterWorkflow.swift b/Samples/ObservableScreen/Sources/MultiCounterWorkflow.swift new file mode 100644 index 000000000..3b162f7aa --- /dev/null +++ b/Samples/ObservableScreen/Sources/MultiCounterWorkflow.swift @@ -0,0 +1,125 @@ +import CasePaths +import Foundation +import SwiftUI +import Workflow +import WorkflowSwiftUI + +struct MultiCounterWorkflow: Workflow { + @ObservableState + struct State { + var showSum = false + var showMax = false + var counters: [CounterInfo] = [] + var max: CounterInfo = .init(name: "Max") + var nextCounter = 1 + + var resetToken = CounterWorkflow.ResetToken() + + mutating func addCounter() { + counters += [.init(name: "Counter \(nextCounter)")] + nextCounter += 1 + } + } + + func makeInitialState() -> State { + var state = State() + state.addCounter() + state.addCounter() + return state + } + + struct ResetAction: WorkflowAction { + typealias WorkflowType = MultiCounterWorkflow + + func apply(toState state: inout MultiCounterWorkflow.State) -> Never? { + state.resetToken = .init() + return nil + } + } + + @CasePathable + enum SumAction: WorkflowAction { + typealias WorkflowType = MultiCounterWorkflow + + case showSum(Bool) + + func apply(toState state: inout MultiCounterWorkflow.State) -> Never? { + switch self { + case .showSum(let showSum): + state.showSum = showSum + return nil + } + } + } + + enum CounterAction: WorkflowAction { + typealias WorkflowType = MultiCounterWorkflow + + case addCounter + case removeCounter(UUID) + + func apply(toState state: inout MultiCounterWorkflow.State) -> Never? { + switch self { + case .addCounter: + state.addCounter() + return nil + case .removeCounter(let id): + state.counters.removeAll { $0.id == id } + return nil + } + } + } + + typealias Output = Never + typealias Rendering = MultiCounterModel + + func render(state: State, context: RenderContext) -> Rendering { + print("\(Self.self) rendered") + + let maxCounter: CounterWorkflow.Model? = if state.showMax { + CounterWorkflow( + info: state.max, + resetToken: state.resetToken, + initialValue: 5 + ) + .rendered(in: context, key: "max") + } else { + nil + } + + let counters: [CounterWorkflow.Model] = state.counters.map { counter in + CounterWorkflow( + info: counter, + resetToken: state.resetToken, + maxValue: maxCounter?.count + ) + .rendered(in: context, key: "\(counter.id)") + } + + let sumAction = context.makeSink(of: SumAction.self) + let counterAction = context.makeSink(of: CounterAction.self) + let resetAction = context.makeSink(of: ResetAction.self) + + return MultiCounterModel( + accessor: context.makeStateAccessor(state: state), + counters: counters, + maxCounter: maxCounter, + sumAction: sumAction, + counterAction: counterAction, + resetAction: resetAction + ) + } +} + +struct MultiCounterModel: ObservableModel { + typealias State = MultiCounterWorkflow.State + + let accessor: StateAccessor + + let counters: [CounterWorkflow.Model] + let maxCounter: CounterWorkflow.Model? + + let sumAction: Sink + let counterAction: Sink + let resetAction: Sink +} diff --git a/Samples/SampleSwiftUIApp/.gitignore b/Samples/SampleSwiftUIApp/.gitignore deleted file mode 100644 index 05ef11923..000000000 --- a/Samples/SampleSwiftUIApp/.gitignore +++ /dev/null @@ -1 +0,0 @@ -Podfile.lock diff --git a/Samples/SampleSwiftUIApp/Podfile b/Samples/SampleSwiftUIApp/Podfile deleted file mode 100644 index 0d2f415e2..000000000 --- a/Samples/SampleSwiftUIApp/Podfile +++ /dev/null @@ -1,7 +0,0 @@ -project 'SampleSwiftUIApp.xcodeproj' -platform :ios, '14.0' - -target 'SampleSwiftUIApp' do - pod 'Workflow', path: '../../Workflow.podspec', :testspecs => ['Tests'] - pod 'WorkflowSwiftUI', path: '../../WorkflowSwiftUI.podspec' -end diff --git a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/AppDelegate.swift b/Samples/SampleSwiftUIApp/SampleSwiftUIApp/AppDelegate.swift deleted file mode 100644 index e726b1aba..000000000 --- a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/AppDelegate.swift +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2020 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import UIKit - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } -} diff --git a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/CounterView.swift b/Samples/SampleSwiftUIApp/SampleSwiftUIApp/CounterView.swift deleted file mode 100644 index 559a5ab2e..000000000 --- a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/CounterView.swift +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2020 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import SwiftUI -import Workflow -import WorkflowSwiftUI - -struct CounterView: View { - var body: some View { - WorkflowView( - workflow: CounterWorkflow(), - onOutput: { _ in } - ) { rendering in - VStack { - Text("The value is \(rendering.value)") - Button(action: rendering.onIncrement) { - Text("+") - } - Button(action: rendering.onDecrement) { - Text("-") - } - } - } - } -} - -struct CounterScreen { - let value: Int - var onIncrement: () -> Void - var onDecrement: () -> Void -} - -struct CounterWorkflow: Workflow { - enum Action: WorkflowAction { - case increment - case decrement - - func apply(toState state: inout Int) -> Never? { - switch self { - case .increment: - state += 1 - case .decrement: - state -= 1 - } - return nil - } - - typealias WorkflowType = CounterWorkflow - } - - func makeInitialState() -> Int { - return 0 - } - - func workflowDidChange(from previousWorkflow: CounterWorkflow, state: inout Int) {} - - func render(state: Int, context: RenderContext) -> CounterScreen { - let sink = context.makeSink(of: Action.self) - return CounterScreen( - value: state, - onIncrement: { - sink.send(.increment) - }, - onDecrement: { - sink.send(.decrement) - } - ) - } - - typealias Output = Never -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - CounterView() - } -} diff --git a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/SceneDelegate.swift b/Samples/SampleSwiftUIApp/SampleSwiftUIApp/SceneDelegate.swift deleted file mode 100644 index 30162d3b0..000000000 --- a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/SceneDelegate.swift +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2020 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import SwiftUI -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - - // Create the SwiftUI view that provides the window contents. - let contentView = CounterView() - - // Use a UIHostingController as window root view controller. - if let windowScene = scene as? UIWindowScene { - let window = UIWindow(windowScene: windowScene) - window.rootViewController = UIHostingController(rootView: contentView) - self.window = window - window.makeKeyAndVisible() - } - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } -} diff --git a/TestingSupport/AppHost/Sources/AppDelegate.swift b/TestingSupport/AppHost/Sources/AppDelegate.swift new file mode 100644 index 000000000..140454c22 --- /dev/null +++ b/TestingSupport/AppHost/Sources/AppDelegate.swift @@ -0,0 +1,20 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = UIViewController() + + window?.makeKeyAndVisible() + + return true + } +} diff --git a/WorkflowSwiftUI.podspec b/WorkflowSwiftUI.podspec deleted file mode 100644 index 709a229cc..000000000 --- a/WorkflowSwiftUI.podspec +++ /dev/null @@ -1,25 +0,0 @@ -require_relative('version') - -Pod::Spec.new do |s| - s.name = 'WorkflowSwiftUI' - s.version = WORKFLOW_VERSION - s.summary = 'Infrastructure for Workflow-powered SwiftUI' - s.homepage = 'https://www.github.com/square/workflow-swift' - s.license = 'Apache License, Version 2.0' - s.author = 'Square' - s.source = { :git => 'https://github.com/square/workflow-swift.git', :tag => "v#{s.version}" } - - # 1.7 is needed for `swift_versions` support - s.cocoapods_version = '>= 1.7.0' - - s.swift_versions = [WORKFLOW_SWIFT_VERSION] - s.ios.deployment_target = WORKFLOW_IOS_DEPLOYMENT_TARGET - s.osx.deployment_target = WORKFLOW_MACOS_DEPLOYMENT_TARGET - - s.source_files = 'WorkflowSwiftUI/Sources/*.swift' - - s.dependency 'Workflow', "#{s.version}" - - s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' } - - end diff --git a/WorkflowSwiftUI/Sources/ActionModel.swift b/WorkflowSwiftUI/Sources/ActionModel.swift new file mode 100644 index 000000000..7c494bbc6 --- /dev/null +++ b/WorkflowSwiftUI/Sources/ActionModel.swift @@ -0,0 +1,24 @@ +/// An ``ObservableModel`` for workflows with a single action. +/// +/// Rather than creating this model directly, you should use the +/// ``Workflow/RenderContext/makeActionModel(state:)`` method to create an instance of this model. +public struct ActionModel: ObservableModel, SingleActionModel { + public let accessor: StateAccessor + public let sendAction: (Action) -> Void +} + +/// An observable model with a single action. +/// +/// Conforming to this type provides some convenience methods for sending actions to the model. You +/// can use ``ActionModel`` rather than conforming yourself. +public protocol SingleActionModel: ObservableModel { + associatedtype Action + + var sendAction: (Action) -> Void { get } +} + +extension ActionModel: Identifiable where State: Identifiable { + public var id: State.ID { + accessor.id + } +} diff --git a/WorkflowSwiftUI/Sources/Bindable+Store.swift b/WorkflowSwiftUI/Sources/Bindable+Store.swift new file mode 100644 index 000000000..c0709fbe6 --- /dev/null +++ b/WorkflowSwiftUI/Sources/Bindable+Store.swift @@ -0,0 +1,124 @@ +import CasePaths +import Perception +import SwiftUI +import Workflow + +public extension Perception.Bindable { + @_disfavoredOverload + subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable + where Value == Store + { + _StoreBindable(bindable: self, keyPath: keyPath) + } +} + +/// Provides custom action redirection on bindings chained from the root model. +/// +/// ## Example +/// +/// In this example, the `State` type has a `Bool` we can bind to, but we want to use the custom +/// `Action` type to update instead. +/// +/// We can achieve this by making the action `CasePathable` and then appending `sending(action:)` to +/// the binding. +/// +/// ```swift +/// @ObservableState +/// public struct State { +/// var isOn = false +/// } +/// +/// @CasePathable +/// public enum Action: WorkflowAction { +/// public typealias WorkflowType = MyWorkflow +/// +/// case toggle(Bool) +/// +/// public func apply(toState state: inout State) -> WorkflowType.Output? { +/// switch self { +/// case .toggle(let value): +/// state.isOn = value +/// return nil +/// } +/// } +/// } +/// +/// public typealias MyModel = ActionModel +/// +/// public struct MyWorkflow: Workflow { +/// public typealias Rendering = MyModel +/// public typealias Output = Never +/// +/// public func makeInitialState() -> State { +/// .init() +/// } +/// +/// public func render(state: State, context: RenderContext) -> Rendering { +/// return context.makeActionModel(state: state) +/// } +/// } +/// +/// public struct MyWorkflowView: View { +/// @Perception.Bindable var store: Store +/// +/// public var body: some View { +/// Toggle( +/// "Enabled", +/// isOn: $store.isOn.sending(action: \.toggle) +/// ) +/// } +/// } +/// ``` +/// +/// This type is used internally when `sending` is used on a chained binding; you do not need to use +/// it directly. +@dynamicMemberLookup +public struct _StoreBindable { + fileprivate let bindable: Perception.Bindable> + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable { + _StoreBindable( + bindable: bindable, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given sink. + /// + /// - Parameter sink: The sink to receive an action with values from the binding. + /// - Parameter action: An action to contain sent values. + /// - Returns: A binding. + public func sending( + sink: KeyPath>, + action: CaseKeyPath + ) -> Binding { + bindable[state: keyPath, sink: sink, action: action] + } + + /// Creates a binding to the value by sending new values through a closure. + /// + /// - Parameter closure: A keypath to a closure on the model. + /// - Returns: A binding. + public func sending( + closure: KeyPath Void> + ) -> Binding { + bindable[state: keyPath, send: closure] + } +} + +public extension _StoreBindable where Model: SingleActionModel { + /// Creates a binding to the value by sending new values through the model's action. + /// + /// - Parameter action: An action to contain sent values. + /// - Returns: A binding. + func sending( + action: CaseKeyPath + ) -> Binding { + bindable[state: keyPath, action: action] + } +} diff --git a/WorkflowSwiftUI/Sources/Derived/AreOrderedSetsDuplicates.swift b/WorkflowSwiftUI/Sources/Derived/AreOrderedSetsDuplicates.swift new file mode 100644 index 000000000..b63520483 --- /dev/null +++ b/WorkflowSwiftUI/Sources/Derived/AreOrderedSetsDuplicates.swift @@ -0,0 +1,17 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Sources/ComposableArchitecture/Internal/AreOrderedSetsDuplicates.swift + +import Foundation +import OrderedCollections + +@inlinable +func areOrderedSetsDuplicates(_ lhs: OrderedSet, _ rhs: OrderedSet) -> Bool { + guard lhs.count == rhs.count + else { return false } + + return withUnsafePointer(to: lhs) { lhsPointer in + withUnsafePointer(to: rhs) { rhsPointer in + memcmp(lhsPointer, rhsPointer, MemoryLayout>.size) == 0 || lhs == rhs + } + } +} diff --git a/WorkflowSwiftUI/Sources/Derived/ObservableState.swift b/WorkflowSwiftUI/Sources/Derived/ObservableState.swift new file mode 100644 index 000000000..5aa455ea4 --- /dev/null +++ b/WorkflowSwiftUI/Sources/Derived/ObservableState.swift @@ -0,0 +1,184 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Sources/ComposableArchitecture/Observation/ObservableState.swift + +import Foundation +import IdentifiedCollections + +/// A type that emits notifications to observers when underlying data changes. +/// +/// Conforming to this protocol signals to other APIs that the value type supports observation. +/// However, applying the ``ObservableState`` protocol by itself to a type doesn’t add observation +/// functionality to the type. Instead, always use the ``ObservableState()`` macro when adding +/// observation support to a type. +#if !os(visionOS) +public protocol ObservableState: Perceptible { + var _$id: ObservableStateID { get } + mutating func _$willModify() +} +#else +public protocol ObservableState: Observable { + var _$id: ObservableStateID { get } + mutating func _$willModify() +} +#endif + +/// A unique identifier for a observed value. +public struct ObservableStateID: Equatable, Hashable, Sendable { + @usableFromInline + var location: UUID { + get { storage.id.location } + set { + if !isKnownUniquelyReferenced(&storage) { + storage = Storage(id: storage.id) + } + storage.id.location = newValue + } + } + + private var storage: Storage + + private init(storage: Storage) { + self.storage = storage + } + + public init() { + self.init(storage: Storage(id: .location(UUID()))) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.storage === rhs.storage || lhs.storage.id == rhs.storage.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(storage.id) + } + + @inlinable + public static func _$id(for value: some Any) -> Self { + (value as? any ObservableState)?._$id ?? Self() + } + + @inlinable + public static func _$id(for value: some ObservableState) -> Self { + value._$id + } + + public func _$tag(_ tag: Int) -> Self { + Self(storage: Storage(id: .tag(tag, storage.id))) + } + + @inlinable + public mutating func _$willModify() { + location = UUID() + } + + private final class Storage: @unchecked Sendable { + fileprivate var id: ID + + init(id: ID = .location(UUID())) { + self.id = id + } + + enum ID: Equatable, Hashable, Sendable { + case location(UUID) + indirect case tag(Int, ID) + + var location: UUID { + get { + switch self { + case .location(let location): + location + case .tag(_, let id): + id.location + } + } + set { + switch self { + case .location: + self = .location(newValue) + case .tag(let tag, var id): + id.location = newValue + self = .tag(tag, id) + } + } + } + } + } +} + +@inlinable +public func _$isIdentityEqual( + _ lhs: T, _ rhs: T +) -> Bool { + lhs._$id == rhs._$id +} + +@inlinable +public func _$isIdentityEqual( + _ lhs: IdentifiedArray, + _ rhs: IdentifiedArray +) -> Bool { + areOrderedSetsDuplicates(lhs.ids, rhs.ids) +} + +@inlinable +public func _$isIdentityEqual( + _ lhs: C, + _ rhs: C +) -> Bool + where C.Element: ObservableState +{ + lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0._$id == $1._$id } +} + +// NB: This is a fast path so that String is not checked as a collection. +@inlinable +public func _$isIdentityEqual(_ lhs: String, _ rhs: String) -> Bool { + false +} + +@inlinable +public func _$isIdentityEqual(_ lhs: T, _ rhs: T) -> Bool { + guard !_isPOD(T.self) else { return false } + + func openCollection(_ lhs: C, _ rhs: Any) -> Bool { + guard C.Element.self is ObservableState.Type else { + return false + } + + func openIdentifiable(_: Element.Type) -> Bool? { + guard + let lhs = lhs as? IdentifiedArrayOf, + let rhs = rhs as? IdentifiedArrayOf + else { + return nil + } + return areOrderedSetsDuplicates(lhs.ids, rhs.ids) + } + + if let identifiable = C.Element.self as? any Identifiable.Type, + let result = openIdentifiable(identifiable) + { + return result + } else if let rhs = rhs as? C { + return lhs.count == rhs.count && zip(lhs, rhs).allSatisfy(_$isIdentityEqual) + } else { + return false + } + } + + if let lhs = lhs as? any ObservableState, let rhs = rhs as? any ObservableState { + return lhs._$id == rhs._$id + } else if let lhs = lhs as? any Collection { + return openCollection(lhs, rhs) + } else { + return false + } +} + +@inlinable +public func _$willModify(_: inout some Any) {} +@inlinable +public func _$willModify(_ value: inout some ObservableState) { + value._$willModify() +} diff --git a/WorkflowSwiftUI/Sources/Derived/ObservationStateRegistrar.swift b/WorkflowSwiftUI/Sources/Derived/ObservationStateRegistrar.swift new file mode 100644 index 000000000..591e4bcb7 --- /dev/null +++ b/WorkflowSwiftUI/Sources/Derived/ObservationStateRegistrar.swift @@ -0,0 +1,186 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift + +#if canImport(Perception) +/// Provides storage for tracking and access to data changes. +public struct ObservationStateRegistrar: Sendable { + public private(set) var id = ObservableStateID() + #if !os(visionOS) + @usableFromInline + let registrar = PerceptionRegistrar() + #else + @usableFromInline + let registrar = ObservationRegistrar() + #endif + public init() {} + public mutating func _$willModify() { id._$willModify() } +} + +extension ObservationStateRegistrar: Equatable, Hashable, Codable { + public static func == (_: Self, _: Self) -> Bool { true } + public func hash(into hasher: inout Hasher) {} + public init(from decoder: Decoder) throws { self.init() } + public func encode(to encoder: Encoder) throws {} +} + +#if canImport(Observation) +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public extension ObservationStateRegistrar { + /// Registers access to a specific property for observation. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + @inlinable + func access( + _ subject: Subject, + keyPath: KeyPath + ) { + registrar.access(subject, keyPath: keyPath) + } + + /// Mutates a value to a new value, and decided to notify observers based on the identity of the + /// value. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + /// - value: The value being mutated. + /// - newValue: The new value to mutate with. + /// - isIdentityEqual: A comparison function that determines whether two values have the same + /// identity or not. + @inlinable + func mutate( + _ subject: Subject, + keyPath: KeyPath, + _ value: inout Value, + _ newValue: Value, + _ isIdentityEqual: (Value, Value) -> Bool + ) { + if isIdentityEqual(value, newValue) { + value = newValue + } else { + registrar.withMutation(of: subject, keyPath: keyPath) { + value = newValue + } + } + } + + /// A no-op for non-observable values. + /// + /// See ``willModify(_:keyPath:_:)-29op6`` info on what this method does when used with + /// observable values. + @inlinable + func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member + } + + /// A property observation called before setting the value of the subject. + /// + /// - Parameters: + /// - subject: An instance of an observable type.` + /// - keyPath: The key path of an observed property. + /// - member: The value in the subject that will be set. + @inlinable + func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member._$willModify() + return member + } + + /// A property observation called after setting the value of the subject. + /// + /// If the identity of the value changed between ``willModify(_:keyPath:_:)-29op6`` and + /// ``didModify(_:keyPath:_:_:_:)-34nhq``, observers are notified. + @inlinable + func didModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member, + _ oldValue: Member, + _ isIdentityEqual: (Member, Member) -> Bool + ) { + if !isIdentityEqual(oldValue, member) { + let newValue = member + member = oldValue + mutate(subject, keyPath: keyPath, &member, newValue, isIdentityEqual) + } + } +} +#endif + +#if !os(visionOS) +public extension ObservationStateRegistrar { + @_disfavoredOverload + @inlinable + func access( + _ subject: Subject, + keyPath: KeyPath + ) { + registrar.access(subject, keyPath: keyPath) + } + + @_disfavoredOverload + @inlinable + func mutate( + _ subject: Subject, + keyPath: KeyPath, + _ value: inout Value, + _ newValue: Value, + _ isIdentityEqual: (Value, Value) -> Bool + ) { + if isIdentityEqual(value, newValue) { + value = newValue + } else { + registrar.withMutation(of: subject, keyPath: keyPath) { + value = newValue + } + } + } + + @_disfavoredOverload + @inlinable + func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member + } + + @_disfavoredOverload + @inlinable + func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member._$willModify() + return member + } + + @_disfavoredOverload + @inlinable + func didModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member, + _ oldValue: Member, + _ isIdentityEqual: (Member, Member) -> Bool + ) { + if !isIdentityEqual(oldValue, member) { + let newValue = member + member = oldValue + mutate(subject, keyPath: keyPath, &member, newValue, isIdentityEqual) + } + } +} +#endif +#endif diff --git a/WorkflowSwiftUI/Sources/Exports.swift b/WorkflowSwiftUI/Sources/Exports.swift new file mode 100644 index 000000000..0fff10905 --- /dev/null +++ b/WorkflowSwiftUI/Sources/Exports.swift @@ -0,0 +1,6 @@ +#if canImport(Observation) +@_exported import Observation +#endif +#if canImport(Perception) +@_exported import Perception +#endif diff --git a/WorkflowSwiftUI/Sources/Macros.swift b/WorkflowSwiftUI/Sources/Macros.swift new file mode 100644 index 000000000..a0515ad4d --- /dev/null +++ b/WorkflowSwiftUI/Sources/Macros.swift @@ -0,0 +1,20 @@ +#if swift(>=5.9) +import Observation + +/// Defines and implements conformance of the Observable protocol. +@attached(extension, conformances: Observable, ObservableState) +@attached(member, names: named(_$id), named(_$observationRegistrar), named(_$willModify)) +@attached(memberAttribute) +public macro ObservableState() = + #externalMacro(module: "WorkflowSwiftUIMacros", type: "ObservableStateMacro") + +@attached(accessor, names: named(init), named(get), named(set)) +@attached(peer, names: prefixed(_)) +public macro ObservationStateTracked() = + #externalMacro(module: "WorkflowSwiftUIMacros", type: "ObservationStateTrackedMacro") + +@attached(accessor, names: named(willSet)) +public macro ObservationStateIgnored() = + #externalMacro(module: "WorkflowSwiftUIMacros", type: "ObservationStateIgnoredMacro") + +#endif diff --git a/WorkflowSwiftUI/Sources/ObservableModel.swift b/WorkflowSwiftUI/Sources/ObservableModel.swift new file mode 100644 index 000000000..303e2a930 --- /dev/null +++ b/WorkflowSwiftUI/Sources/ObservableModel.swift @@ -0,0 +1,150 @@ +import Workflow + +/// A type that can be observed for fine-grained changes and accept updates. +/// +/// Workflows that render ``ObservableModel`` types can be used to power ``ObservableScreen`` +/// screens, for performant UI that only updates when necessary, while still adhering to a +/// unidirectional data flow. +/// +/// To render an ``ObservableModel``, your Workflow state must first conform to ``ObservableState``, +/// using the `@ObservableState` macro. +/// +/// # Examples +/// +/// For trivial workflows with no actions, you can generate a model directly from your state: +/// +/// ```swift +/// struct TrivialWorkflow: Workflow { +/// typealias Output = Never +/// +/// @ObservableState +/// struct State { +/// var counter = 0 +/// } +/// +/// func makeInitialState() -> State { +/// .init() +/// } +/// +/// func render( +/// state: State, +/// context: RenderContext +/// ) -> StateAccessor { +/// context.makeStateAccessor(state: state) +/// } +/// } +/// ``` +/// +/// For simple workflows with a single action, you can generate a model from your state and action: +/// +/// ```swift +/// struct SingleActionWorkflow: Workflow { +/// typealias Output = Never +/// +/// @ObservableState +/// struct State { +/// var counter = 0 +/// } +/// +/// enum Action: WorkflowAction { +/// typealias WorkflowType = SingleActionWorkflow +/// case increment +/// +/// func apply(toState state: inout State) -> Never? { +/// state.counter += 1 +/// return nil +/// } +/// } +/// +/// func makeInitialState() -> State { +/// .init() +/// } +/// +/// func render( +/// state: State, +/// context: RenderContext +/// ) -> ActionModel { +/// context.makeActionModel(state: state) +/// } +/// } +/// ``` +/// +/// For complex workflows that have multiple actions or compose observable models from child +/// workflows, you can create a custom model that conforms to ``ObservableModel``: +/// +/// ```swift +/// struct ComplexWorkflow: Workflow { +/// typealias Output = Never +/// +/// @ObservableState +/// struct State { +/// var counter = 0 +/// } +/// +/// enum UpAction: WorkflowAction { +/// typealias WorkflowType = ComplexWorkflow +/// case increment +/// +/// func apply(toState state: inout State) -> Never? { +/// state.counter += 1 +/// return nil +/// } +/// } +/// +/// enum DownAction: WorkflowAction { +/// typealias WorkflowType = ComplexWorkflow +/// case decrement +/// +/// func apply(toState state: inout State) -> Never? { +/// state.counter -= 1 +/// return nil +/// } +/// } +/// +/// func makeInitialState() -> State { +/// .init() +/// } +/// +/// func render( +/// state: State, +/// context: RenderContext +/// ) -> CustomModel { +/// CustomModel( +/// accessor: context.makeStateAccessor(state: state), +/// child: TrivialWorkflow().rendered(in: context), +/// up: context.makeSink(of: UpAction.self), +/// down: context.makeSink(of: DownAction.self) +/// ) +/// } +/// } +/// +/// struct CustomModel: ObservableModel { +/// var accessor: StateAccessor +/// +/// var child: TrivialWorkflow.Rendering +/// +/// var up: Sink +/// var down: Sink +/// } +/// ``` +/// +@dynamicMemberLookup +public protocol ObservableModel { + /// The associated state type that this model observes. + associatedtype State: ObservableState + + /// The accessor that can be used to read and write state. + var accessor: StateAccessor { get } +} + +public extension ObservableModel { + /// Allows dynamic member lookup to read and write state through the accessor. + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + accessor.state[keyPath: keyPath] + } + set { + accessor.sendValue { $0[keyPath: keyPath] = newValue } + } + } +} diff --git a/WorkflowSwiftUI/Sources/ObservableScreen+Preview.swift b/WorkflowSwiftUI/Sources/ObservableScreen+Preview.swift new file mode 100644 index 000000000..c84223d16 --- /dev/null +++ b/WorkflowSwiftUI/Sources/ObservableScreen+Preview.swift @@ -0,0 +1,173 @@ +#if canImport(UIKit) +#if DEBUG + +import Foundation +import SwiftUI +import Workflow +import WorkflowUI + +public extension ObservableScreen { + /// Generates a static preview of this screen type. + /// + /// Previews generated with this method are static and do not update state. To generate a + /// stateful preview, instantiate a workflow and use one of the + /// ``Workflow/Workflow/workflowPreview(customizeEnvironment:)`` methods. + /// + /// - Parameter makeModel: A closure to create the screen's model. The provided `context` param + /// is a convenience to generate dummy sinks and state accessors. + /// - Returns: A View for previews. + static func observableScreenPreview(makeModel: (StaticStorePreviewContext) -> Model) -> some View { + let store = Store.preview(makeModel: makeModel) + return Self.makeView(store: store) + } + + /// Generates a static preview of this screen type. + /// + /// Previews generated with this method are static and do not update state. To generate a + /// stateful preview, instantiate a workflow and use one of the + /// ``Workflow/Workflow/workflowPreview(customizeEnvironment:)`` methods. + /// + /// - Parameter state: The state of the screen. + /// - Returns: A View for previews. + static func observableScreenPreview(state: S) -> some View where Model == ActionModel { + observableScreenPreview { context in + context.makeActionModel(state: state) + } + } + + /// Generates a static preview of this screen type. + /// + /// Previews generated with this method are static and do not update state. To generate a + /// stateful preview, instantiate a workflow and use one of the + /// ``Workflow/Workflow/workflowPreview(customizeEnvironment:)`` methods. + /// + /// - Parameter state: The state of the screen. + /// - Returns: A View for previews. + static func observableScreenPreview(state: S) -> some View where Model == StateAccessor { + observableScreenPreview { context in + context.makeStateAccessor(state: state) + } + } +} + +// MARK: - Preview previews + +@ObservableState +private struct PreviewDemoState { + var name = "Test" + var count = 0 +} + +private struct PreviewDemoTrivialScreen: ObservableScreen { + typealias Model = StateAccessor + + var model: Model + + static func makeView(store: Store) -> some View { + PreviewDemoView(store: store) + } + + struct PreviewDemoView: View { + let store: Store + + var body: some View { + VStack { + Text("\(store.name)") + Button("Add", systemImage: "add") { + store.count += 1 + } + Button("Reset") { + store.count = 0 + } + } + } + } +} + +private enum PreviewDemoAction {} + +private struct PreviewDemoActionScreen: ObservableScreen { + typealias Model = ActionModel + + var model: Model + + static func makeView(store: Store) -> some View { + PreviewDemoView(store: store) + } + + struct PreviewDemoView: View { + let store: Store + + var body: some View { + VStack { + Text("\(store.name)") + Button("Add", systemImage: "add") { + store.count += 1 + } + Button("Reset") { + store.count = 0 + } + } + } + } +} + +private enum PreviewDemoAction2 {} + +private struct PreviewDemoComplexModel: ObservableModel { + var accessor: StateAccessor + + var sink: Sink + var sink2: Sink +} + +private struct PreviewDemoComplexScreen: ObservableScreen { + typealias Model = PreviewDemoComplexModel + + var model: Model + + static func makeView(store: Store) -> some View { + PreviewDemoView(store: store) + } + + struct PreviewDemoView: View { + let store: Store + + var body: some View { + VStack { + Text("\(store.name)") + Button("Add", systemImage: "add") { + store.count += 1 + } + Button("Reset") { + store.count = 0 + } + } + } + } +} + +struct PreviewDemoScreen_Preview: PreviewProvider { + static var previews: some View { + PreviewDemoTrivialScreen + .observableScreenPreview(state: .init()) + .previewDisplayName("Trivial Screen") + + PreviewDemoActionScreen + .observableScreenPreview(state: .init()) + .previewDisplayName("Single Action Screen") + + PreviewDemoComplexScreen + .observableScreenPreview { context in + PreviewDemoComplexModel( + accessor: context.makeStateAccessor(state: .init()), + sink: context.makeSink(of: PreviewDemoAction.self), + sink2: context.makeSink(of: PreviewDemoAction2.self) + ) + } + .previewDisplayName("Custom Model Screen") + } +} + +#endif +#endif diff --git a/WorkflowSwiftUI/Sources/ObservableScreen.swift b/WorkflowSwiftUI/Sources/ObservableScreen.swift new file mode 100644 index 000000000..27819ef34 --- /dev/null +++ b/WorkflowSwiftUI/Sources/ObservableScreen.swift @@ -0,0 +1,208 @@ +#if canImport(UIKit) + +import SwiftUI +import Workflow +import WorkflowUI + +/// A screen that renders SwiftUI views with an observable model for fine-grained invalidations. +/// +/// Screens conforming to this protocol will render SwiftUI views that observe fine-grained changes +/// to the underlying model, and selectively invalidate in response to changes to properties that +/// are accessed by the view. +/// +/// Invalidations happen when the observed state is mutated, during actions or the +/// `workflowDidChange` method. When this screen is rendered, a new model is injected into the +/// store. Any invalidated views will then be updated with the new model by SwiftUI during its own +/// rendering cycle. +/// +/// To use this protocol with a workflow, your workflow should render a type that conforms to +/// ``ObservableModel``, and then map to a screen implementation that uses that concrete model +/// type. See ``ObservableModel`` for options on how to render one easily. +public protocol ObservableScreen: Screen { + /// The type of the root view rendered by this screen. + associatedtype Content: View + /// The type of the model that this screen observes. + associatedtype Model: ObservableModel + + /// The sizing options for the screen. + var sizingOptions: SwiftUIScreenSizingOptions { get } + /// The model that this screen observes. + var model: Model { get } + + /// Constructs the root view for this screen. This is only called once to initialize the view. + /// After the initial construction, the view will be updated by injecting new values into the + /// store. + @ViewBuilder + static func makeView(store: Store) -> Content +} + +public extension ObservableScreen { + var sizingOptions: SwiftUIScreenSizingOptions { [] } +} + +public extension ObservableScreen { + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: ModeledHostingController.self, + environment: environment, + build: { + let (store, setModel) = Store.make(model: model) + return ModeledHostingController( + setModel: setModel, + viewEnvironment: environment, + rootView: Self.makeView(store: store), + sizingOptions: sizingOptions + ) + }, + update: { hostingController in + hostingController.setModel(model) + hostingController.setViewEnvironment(environment) + } + ) + } +} + +public struct SwiftUIScreenSizingOptions: OptionSet { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let preferredContentSize: SwiftUIScreenSizingOptions = .init(rawValue: 1 << 0) +} + +private struct ViewEnvironmentModifier: ViewModifier { + @ObservedObject var holder: ViewEnvironmentHolder + + func body(content: Content) -> some View { + content + .environment(\.viewEnvironment, holder.viewEnvironment) + } +} + +private final class ViewEnvironmentHolder: ObservableObject { + @Published var viewEnvironment: ViewEnvironment + + init(viewEnvironment: ViewEnvironment) { + self.viewEnvironment = viewEnvironment + } +} + +private final class ModeledHostingController: UIHostingController> { + let setModel: (Model) -> Void + let setViewEnvironment: (ViewEnvironment) -> Void + + var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { + didSet { + updateSizingOptionsIfNeeded() + + if !hasLaidOutOnce { + setNeedsLayoutBeforeFirstLayoutIfNeeded() + } + } + } + + private var hasLaidOutOnce = false + + init( + setModel: @escaping (Model) -> Void, + viewEnvironment: ViewEnvironment, + rootView: Content, + sizingOptions swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions + ) { + let viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment) + + self.setModel = setModel + self.setViewEnvironment = { viewEnvironmentHolder.viewEnvironment = $0 } + self.swiftUIScreenSizingOptions = swiftUIScreenSizingOptions + + super.init( + rootView: rootView + .modifier(ViewEnvironmentModifier(holder: viewEnvironmentHolder)) + ) + + updateSizingOptionsIfNeeded() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("not implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // `UIHostingController`'s provides a system background color by default. In order to + // support `ObervableModelScreen`s being composed in contexts where it is composed within another + // view controller where a transparent background is more desirable, we set the background + // to clear to allow this kind of flexibility. + view.backgroundColor = .clear + + setNeedsLayoutBeforeFirstLayoutIfNeeded() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + defer { hasLaidOutOnce = true } + + if #available(iOS 16.0, *) { + // Handled in initializer, but set it on first layout to resolve a bug where the PCS is + // not updated appropriately after the first layout. + // UI-5797 + if !hasLaidOutOnce, + swiftUIScreenSizingOptions.contains(.preferredContentSize) + { + let size = view.sizeThatFits(view.frame.size) + + if preferredContentSize != size { + preferredContentSize = size + } + } + } else if swiftUIScreenSizingOptions.contains(.preferredContentSize) { + let size = view.sizeThatFits(view.frame.size) + + if preferredContentSize != size { + preferredContentSize = size + } + } + } + + private func updateSizingOptionsIfNeeded() { + if #available(iOS 16.0, *) { + self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions + } + + if !swiftUIScreenSizingOptions.contains(.preferredContentSize), + preferredContentSize != .zero + { + preferredContentSize = .zero + } + } + + private func setNeedsLayoutBeforeFirstLayoutIfNeeded() { + if swiftUIScreenSizingOptions.contains(.preferredContentSize) { + // Without manually calling setNeedsLayout here it was observed that a call to + // layoutIfNeeded() immediately after loading the view would not perform a layout, and + // therefore would not update the preferredContentSize in viewDidLayoutSubviews(). + // UI-5797 + view.setNeedsLayout() + } + } +} + +fileprivate extension SwiftUIScreenSizingOptions { + @available(iOS 16.0, *) + var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions { + var options = UIHostingControllerSizingOptions() + + if contains(.preferredContentSize) { + options.insert(.preferredContentSize) + } + + return options + } +} + +#endif diff --git a/WorkflowSwiftUI/Sources/RenderContext+ObservableModel.swift b/WorkflowSwiftUI/Sources/RenderContext+ObservableModel.swift new file mode 100644 index 000000000..ae28af3c7 --- /dev/null +++ b/WorkflowSwiftUI/Sources/RenderContext+ObservableModel.swift @@ -0,0 +1,32 @@ +import Foundation +import Workflow + +public extension RenderContext where WorkflowType.State: ObservableState { + /// Creates a ``StateAccessor`` for this workflow's state. + /// + /// ``StateAccessor`` is used by ``ObservableModel`` to read and write observable state. A state + /// accessor can serve as the ``ObservableModel`` implementation for simple workflows with no + /// actions. State updates will be sent to the workflow's state mutation sink. + func makeStateAccessor( + state: WorkflowType.State + ) -> StateAccessor { + StateAccessor(state: state, sendValue: makeStateMutationSink().send) + } + + /// Creates an ``ActionModel`` for this workflow's state and action. + /// + /// ``ActionModel`` is a simple ``ObservableModel`` implementation for workflows with one action + /// type. For more complex workflows with multiple actions, you can create a custom model that + /// conforms to ``ObservableModel``. For less complex workflows, you can use + /// ``makeStateAccessor(state:)`` instead. See ``ObservableModel`` for more information. + func makeActionModel( + state: WorkflowType.State + ) -> ActionModel + where Action.WorkflowType == WorkflowType + { + ActionModel( + accessor: makeStateAccessor(state: state), + sendAction: makeSink(of: Action.self).send + ) + } +} diff --git a/WorkflowSwiftUI/Sources/StateAccessor.swift b/WorkflowSwiftUI/Sources/StateAccessor.swift new file mode 100644 index 000000000..69bdb0de0 --- /dev/null +++ b/WorkflowSwiftUI/Sources/StateAccessor.swift @@ -0,0 +1,25 @@ +/// A wrapper around observable state that provides read and write access through unidirectional +/// channels. +/// +/// This type serves as the primary channel of information in an ``ObservableModel``, by providing +/// read and write access to state through separate mechanisms. +/// +/// To create an accessor, use ``Workflow/RenderContext/makeStateAccessor(state:)``. State writes +/// will flow through a workflow's state mutation sink. +/// +/// This type can be embedded in an ``ObservableModel`` or used directly, for trivial workflows with +/// no custom actions. +public struct StateAccessor { + let state: State + let sendValue: (@escaping (inout State) -> Void) -> Void +} + +extension StateAccessor: ObservableModel { + public var accessor: StateAccessor { self } +} + +extension StateAccessor: Identifiable where State: Identifiable { + public var id: State.ID { + state.id + } +} diff --git a/WorkflowSwiftUI/Sources/Store+Preview.swift b/WorkflowSwiftUI/Sources/Store+Preview.swift new file mode 100644 index 000000000..f31a4b246 --- /dev/null +++ b/WorkflowSwiftUI/Sources/Store+Preview.swift @@ -0,0 +1,83 @@ +#if DEBUG + +import Foundation +import Workflow + +/// Dummy context for creating no-op sinks and models for static previews of observable screens. +public struct StaticStorePreviewContext { + fileprivate init() {} + + public func makeSink(of actionType: Action.Type) -> Sink { + Sink { _ in } + } + + public func makeStateAccessor(state: State) -> StateAccessor { + StateAccessor( + state: state, + sendValue: { _ in } + ) + } + + public func makeActionModel( + state: State + ) -> ActionModel { + ActionModel( + accessor: makeStateAccessor(state: state), + sendAction: makeSink(of: Action.self).send + ) + } +} + +extension Store { + /// Generates a static store for previews. + /// + /// Previews generated with this method are static and do not update state. To generate a + /// stateful preview, instantiate a workflow and use one of the + /// ``Workflow/Workflow/workflowPreview(customizeEnvironment:)`` methods. + /// + /// - Parameter makeModel: A closure to create the store's model. The provided `context` param + /// is a convenience to generate dummy sinks and state accessors. + /// - Returns: A store for previews. + public static func preview( + makeModel: (StaticStorePreviewContext) -> Model + ) -> Store { + let context = StaticStorePreviewContext() + let model = makeModel(context) + let (store, _) = make(model: model) + return store + } + + /// Generates a static store for previews. + /// + /// Previews generated with this method are static and do not update state. To generate a + /// stateful preview, instantiate a workflow and use one of the + /// ``Workflow/Workflow/workflowPreview(customizeEnvironment:)`` methods. + /// + /// - Parameter state: The state of the view. + /// - Returns: A store for previews. + public static func preview( + state: State + ) -> Store> where Model == ActionModel { + preview { context in + context.makeActionModel(state: state) + } + } + + /// Generates a static store for previews. + /// + /// Previews generated with this method are static and do not update state. To generate a + /// stateful preview, instantiate a workflow and use one of the + /// ``Workflow/Workflow/workflowPreview(customizeEnvironment:)`` methods. + /// + /// - Parameter state: The state of the view. + /// - Returns: A store for previews. + public static func preview( + state: State + ) -> Store> where Model == StateAccessor { + preview { context in + context.makeStateAccessor(state: state) + } + } +} + +#endif diff --git a/WorkflowSwiftUI/Sources/Store.swift b/WorkflowSwiftUI/Sources/Store.swift new file mode 100644 index 000000000..3258dc48a --- /dev/null +++ b/WorkflowSwiftUI/Sources/Store.swift @@ -0,0 +1,453 @@ +import CasePaths +import IdentifiedCollections +import Perception +import SwiftUI +import Workflow + +/// Provides access to a workflow's state and actions from within an ``ObservableScreen``. +/// +/// The store wraps an ``ObservableModel`` and provides controlled access to members through dynamic +/// member lookup: +/// - state properties +/// - action sinks +/// - child stores using nested `ObservableModel`s +/// +/// Because arbitrary properties on the model cannot be tracked for observation, any other types of +/// properties will not be accessible. +/// +/// For state properties that are writable, an automatic `Binding` can be derived by annotating the +/// store with `@Bindable`. These bindings will use the workflow's state mutation sink. +/// +/// All properties can be turned into bindings by appending `sending(store:action:)` or +/// `sending(closure:)` to specify the "write" action. For properties that are already writable, +/// this will refine the binding to send a custom action instead of the built-in state mutation +/// sink. +/// +@dynamicMemberLookup +public final class Store: Perceptible { + public typealias State = Model.State + + private var model: Model + private let _$observationRegistrar = PerceptionRegistrar() + + private var childStores: [AnyHashable: ChildStore] = [:] + private var childModelAccesses: [AnyHashable: ChildModelAccess] = [:] + private var invalidated = false + + static func make(model: Model) -> (Store, (Model) -> Void) { + let store = Store(model) + return (store, store.setModel) + } + + fileprivate init(_ model: Model) { + self.model = model + } + + var state: State { + _$observationRegistrar.access(self, keyPath: \.state) + return model.accessor.state + } + + private func send(keyPath: WritableKeyPath, value: Value) { + guard !invalidated else { + return + } + model.accessor.sendValue { state in + state[keyPath: keyPath] = value + } + } + + fileprivate func setModel(_ newModel: Model) { + // Make a list of any child store accesses that are mutated as a result of this set. We'll + // use this list to wrap the update with appropriate willSet/didSet calls. + let changedChildAccess = childModelAccesses.values.filter { $0.isChanged(model, newModel) } + + /// Update the model, wrapped in willSet and didSet observations for mutations to child + /// store wrappers. + func updateModel() { + for access in changedChildAccess { + access.willSet(self) + } + + model = newModel + + for access in changedChildAccess { + access.didSet(self) + } + } + + // Update the model, registering a mutation if the state has changed + + if !_$isIdentityEqual(model.accessor.state, newModel.accessor.state) { + _$observationRegistrar.withMutation(of: self, keyPath: \.state) { + updateModel() + } + } else { + updateModel() + } + + // Update and invalidate child stores + + for (keyPath, childStore) in childStores { + if childStore.isInvalid(newModel) { + childStore.invalidate() + childStores[keyPath] = nil + } else { + childStore.setModel(newModel) + } + } + + childModelAccesses = childModelAccesses.filter { _, access in + !access.isInvalid(newModel) + } + } + + func invalidate() { + invalidated = true + for childStore in childStores.values { + childStore.invalidate() + } + } +} + +// MARK: - Subscripting + +public extension Store { + subscript(dynamicMember keyPath: KeyPath) -> T { + state[keyPath: keyPath] + } + + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + state[keyPath: keyPath] + } + set { + send(keyPath: keyPath, value: newValue) + } + } + + subscript(dynamicMember keyPath: KeyPath>) -> Sink { + model[keyPath: keyPath] + } + + subscript( + state state: KeyPath, + send send: KeyPath Void> + ) -> Value { + get { + self.state[keyPath: state] + } + set { + model[keyPath: send](newValue) + } + } + + subscript( + state state: KeyPath, + sink sink: KeyPath>, + action action: CaseKeyPath + ) -> Value { + get { + self.state[keyPath: state] + } + set { + model[keyPath: sink].send(action(newValue)) + } + } +} + +// MARK: - Scoping + +public extension Store { + /// Holds a cached child store for a nested ObservableModel on this store's model. + internal struct ChildStore { + var store: Any + var setModel: (Model) -> Void + var isInvalid: (Model) -> Bool + + private var _invalidate: () -> Void + + init( + store: Store, + setModel: @escaping (Model) -> Void, + isInvalid: @escaping (Model) -> Bool + ) { + self.store = store + self.setModel = setModel + self.isInvalid = isInvalid + + self._invalidate = { + store.invalidate() + } + } + + func invalidate() { + _invalidate() + } + } + + /// Represents an access to the "wrapper" of a nested child store, such as an Optional or + /// collection type. + /// + /// Each nested model's scope will track its own mutations, but we use this to track mutations + /// to the wrapper itself, such as changes to a collection size. + internal struct ChildModelAccess { + var willSet: (Store) -> Void + var didSet: (Store) -> Void + var isChanged: (Model, Model) -> Bool + var isInvalid: (Model) -> Bool + + init( + keyPath: KeyPath, + isChanged: @escaping (Model, Model) -> Bool, + isInvalid: @escaping (Model) -> Bool + ) { + self.willSet = { store in + store._$observationRegistrar.willSet(store, keyPath: (\Store.model).appending(path: keyPath)) + } + self.didSet = { store in + store._$observationRegistrar.didSet(store, keyPath: (\Store.model).appending(path: keyPath)) + } + self.isChanged = isChanged + self.isInvalid = isInvalid + } + } + + /// Track access to a child store wrapper. + internal func access( + keyPath key: KeyPath, + isChanged: @escaping (Model, Model) -> Bool, + isInvalid: @escaping (Model) -> Bool = { _ in false } + ) { + _$observationRegistrar.access(self, keyPath: (\Store.model).appending(path: key)) + if childModelAccesses[key] == nil { + childModelAccesses[key] = ChildModelAccess( + keyPath: key, + isChanged: isChanged, + isInvalid: isInvalid + ) + } + } + + internal func scope( + key: AnyHashable, + getModel: @escaping (Model) -> ChildModel, + isInvalid: @escaping (Model) -> Bool + ) -> Store { + if let childStore = childStores[key]?.store as? Store { + return childStore + } + + let childModel = getModel(model) + let childStore = Store(childModel) + + childStores[key] = ChildStore( + store: childStore, + setModel: { model in + childStore.setModel(getModel(model)) + }, + isInvalid: isInvalid + ) + + return childStore + } + + // Normal props + + func scope(keyPath: KeyPath) -> Store { + scope( + key: keyPath, + getModel: { $0[keyPath: keyPath] }, + isInvalid: { _ in false } + ) + } + + // Optionals + + func scope( + keyPath: KeyPath + ) -> Store? { + access(keyPath: keyPath) { oldModel, newModel in + // invalidate if presence changes + (oldModel[keyPath: keyPath] == nil) != (newModel[keyPath: keyPath] == nil) + } + + guard let childModel = model[keyPath: keyPath] else { + return nil + } + + return scope( + key: keyPath, + getModel: { model in + model[keyPath: keyPath] ?? childModel + }, + isInvalid: { model in + model[keyPath: keyPath] == nil + } + ) + } + + // Collections + + func scope( + collection: KeyPath + ) -> _StoreCollection + where + ChildModel: ObservableModel, + ChildCollection: RandomAccessCollection, + ChildCollection.Element == ChildModel, + ChildCollection.Index == Int + { + access(keyPath: collection) { oldModel, newModel in + // invalidate if collection size changes + oldModel[keyPath: collection].count != newModel[keyPath: collection].count + } + + let models = model[keyPath: collection] + + return _StoreCollection( + startIndex: models.startIndex, + endIndex: models.endIndex + ) { index in + self.scope( + key: collection.appending(path: \.[_offset: index]), + getModel: { model in + model[keyPath: collection][index] + }, + isInvalid: { model in + !model[keyPath: collection].indices.contains(index) + } + ) + } + } + + func scope( + collection: KeyPath> + ) -> _StoreCollection where ChildModel: ObservableModel { + access(keyPath: collection) { oldModel, newModel in + // invalidate if collection size changes + oldModel[keyPath: collection].count != newModel[keyPath: collection].count + } + + let models = model[keyPath: collection] + + return _StoreCollection( + startIndex: models.startIndex, + endIndex: models.endIndex + ) { index in + let id = models.ids[index] + + // These scopes are keyed by ID and will not be invalidated by reordering. Register a + // mutation to this index if its identity changes + self.access(keyPath: collection.appending(path: \.[index])) { _, newModel in + let newCollection = newModel[keyPath: collection] + return !newCollection.indices.contains(index) || newCollection.ids[index] != id + } isInvalid: { model in + !model[keyPath: collection].ids.contains(id) + } + + return self.scope( + key: collection.appending(path: \.[id: id]), + getModel: { model in + let models = model[keyPath: collection] + return models[id: id] ?? models[index] + }, + isInvalid: { model in + !model[keyPath: collection].ids.contains(id) + } + ) + } + } + + subscript(dynamicMember keyPath: KeyPath) -> Store { + scope(keyPath: keyPath) + } + + subscript( + dynamicMember keyPath: KeyPath + ) -> Store? { + scope(keyPath: keyPath) + } + + subscript( + dynamicMember collection: KeyPath + ) -> _StoreCollection where + ChildModel: ObservableModel, + ChildCollection: RandomAccessCollection, + ChildCollection.Element == ChildModel, + ChildCollection.Index == Int + { + scope(collection: collection) + } + + subscript( + dynamicMember collection: KeyPath> + ) -> _StoreCollection where ChildModel: ObservableModel { + scope(collection: collection) + } +} + +// NB: Would prefer to return `some RandomAccessCollection` and make this internal, but in Xcode +// 15.1 it breaks subscript access: "Missing argument label '_offset:' in subscript". Revisit later. + +public struct _StoreCollection: RandomAccessCollection { + init(startIndex: Int, endIndex: Int, storeAtIndex: @escaping (Int) -> Store) { + self.startIndex = startIndex + self.endIndex = endIndex + self.storeAtIndex = storeAtIndex + } + + public let startIndex: Int + public let endIndex: Int + private let storeAtIndex: (Int) -> Store + + public subscript(position: Int) -> Store { + storeAtIndex(position) + } +} + +// MARK: - Single action conveniences + +public extension Store where Model: SingleActionModel { + func action(_ action: Model.Action) -> () -> Void { + { self.send(action) } + } + + func send(_ action: Model.Action) { + guard !invalidated else { + return + } + model.sendAction(action) + } + + subscript( + state keyPath: KeyPath, + action action: CaseKeyPath + ) -> Value { + get { state[keyPath: keyPath] } + set { send(action(newValue)) } + } +} + +// MARK: - Conformances + +extension Store: Equatable { + public static func == (lhs: Store, rhs: Store) -> Bool { + lhs === rhs + } +} + +extension Store: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +extension Store: Identifiable {} + +#if canImport(Observation) +import Observation + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Store: Observable {} +#endif diff --git a/WorkflowSwiftUI/Sources/Workflow+Preview.swift b/WorkflowSwiftUI/Sources/Workflow+Preview.swift new file mode 100644 index 000000000..f26d7a05a --- /dev/null +++ b/WorkflowSwiftUI/Sources/Workflow+Preview.swift @@ -0,0 +1,151 @@ +#if canImport(UIKit) +#if DEBUG + +import Foundation +import ReactiveSwift +import SwiftUI +import Workflow +import WorkflowUI + +public extension Workflow where Rendering: Screen { + func workflowPreview( + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in }, + onOutput: @escaping (Output) -> Void + ) -> some View { + PreviewView( + workflow: self, + customizeEnvironment: customizeEnvironment, + onOutput: onOutput + ) + .ignoresSafeArea() + } +} + +public extension Workflow where Rendering: Screen, Output == Never { + func workflowPreview( + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in } + ) -> some View { + PreviewView( + workflow: self, + customizeEnvironment: customizeEnvironment, + onOutput: { _ in } + ) + .ignoresSafeArea() + } +} + +private struct PreviewView: UIViewControllerRepresentable where WorkflowType.Rendering: Screen { + typealias ScreenType = WorkflowType.Rendering + typealias UIViewControllerType = WorkflowHostingController + + let workflow: WorkflowType + let customizeEnvironment: (inout ViewEnvironment) -> Void + let onOutput: (WorkflowType.Output) -> Void + + func makeUIViewController(context: Context) -> UIViewControllerType { + let controller = WorkflowHostingController( + workflow: workflow, + customizeEnvironment: customizeEnvironment + ) + let coordinator = context.coordinator + + coordinator.outputDisposable?.dispose() + coordinator.outputDisposable = controller.output.observeValues(onOutput) + + return controller + } + + func updateUIViewController( + _ controller: UIViewControllerType, + context: Context + ) { + let coordinator = context.coordinator + + coordinator.outputDisposable?.dispose() + coordinator.outputDisposable = controller.output.observeValues(onOutput) + + controller.customizeEnvironment = customizeEnvironment + controller.update(workflow: workflow) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + // This coordinator allows us to manage the lifetime of the WorkflowHostingController's `output` + // signal observation that's used to provide an `onOutput` callback to consumers. + final class Coordinator { + var outputDisposable: Disposable? + } +} + +private struct PreviewDemoWorkflow: Workflow { + typealias Output = Never + typealias Rendering = StateAccessor + + @ObservableState + struct State { + var value: Int + } + + func makeInitialState() -> State { .init(value: 0) } + + func render(state: State, context: RenderContext) -> Rendering { + context.makeStateAccessor(state: state) + } +} + +private struct PreviewDemoOutputtingWorkflow: Workflow { + typealias Output = Int + typealias Rendering = StateAccessor + typealias State = PreviewDemoWorkflow.State + + func makeInitialState() -> State { .init(value: 0) } + + func render(state: State, context: RenderContext) -> Rendering { + context.makeStateAccessor(state: state) + } +} + +private struct PreviewDemoScreen: ObservableScreen { + typealias Model = StateAccessor + + var model: Model + + static func makeView(store: Store) -> some View { + PreviewDemoView(store: store) + } + + struct PreviewDemoView: View { + let store: Store + + var body: some View { + VStack { + Text("\(store.value)") + Button("Add", systemImage: "add") { + store.value += 1 + } + Button("Reset") { + store.value = 0 + } + } + } + } +} + +struct PreviewDemoWorkflow_Preview: PreviewProvider { + static var previews: some View { + PreviewDemoOutputtingWorkflow() + .mapRendering(PreviewDemoScreen.init) + .workflowPreview( + onOutput: { print($0) } + ) + + PreviewDemoWorkflow() + .mapRendering(PreviewDemoScreen.init) + .workflowPreview() + } +} + +#endif +#endif diff --git a/WorkflowSwiftUI/Sources/WorkflowView.swift b/WorkflowSwiftUI/Sources/WorkflowView.swift index 1798a3c86..d88e9995f 100644 --- a/WorkflowSwiftUI/Sources/WorkflowView.swift +++ b/WorkflowSwiftUI/Sources/WorkflowView.swift @@ -44,6 +44,7 @@ import Workflow /// } /// } /// ``` +@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") public struct WorkflowView: View { /// The workflow implementation to use public var workflow: T @@ -69,23 +70,26 @@ public struct WorkflowView: View { } } -extension WorkflowView where T.Output == Never { +@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") +public extension WorkflowView where T.Output == Never { /// Convenience initializer for workflows with no output. - public init(workflow: T, content: @escaping (T.Rendering) -> Content) { + init(workflow: T, content: @escaping (T.Rendering) -> Content) { self.init(workflow: workflow, onOutput: { _ in }, content: content) } } -extension WorkflowView where T.Rendering == Content { +@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") +public extension WorkflowView where T.Rendering == Content { /// Convenience initializer for workflows whose rendering type conforms to `View`. - public init(workflow: T, onOutput: @escaping (T.Output) -> Void) { + init(workflow: T, onOutput: @escaping (T.Output) -> Void) { self.init(workflow: workflow, onOutput: onOutput, content: { $0 }) } } -extension WorkflowView where T.Output == Never, T.Rendering == Content { +@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") +public extension WorkflowView where T.Output == Never, T.Rendering == Content { /// Convenience initializer for workflows with no output whose rendering type conforms to `View`. - public init(workflow: T) { + init(workflow: T) { self.init(workflow: workflow, onOutput: { _ in }, content: { $0 }) } } @@ -156,6 +160,7 @@ fileprivate final class WorkflowHostingViewController value + do { + var state = ParentState(optional: nil) + let optionalDidChange = expectation(description: "optional.didChange") + + withPerceptionTracking { + _ = state.optional + } onChange: { + optionalDidChange.fulfill() + } + + state.optional = ChildState(count: 42) + await fulfillment(of: [optionalDidChange], timeout: 0) + XCTAssertEqual(state.optional?.count, 42) + } + + // nil -> nil + do { + var state = ParentState(optional: nil) + let optionalDidChange = expectation(description: "optional.didChange") + + withPerceptionTracking { + _ = state.optional + } onChange: { + optionalDidChange.fulfill() + } + + state.optional = nil + await fulfillment(of: [optionalDidChange], timeout: 0) + XCTAssertNil(state.optional) + } + + // value -> nil + do { + var state = ParentState(optional: ChildState()) + let optionalDidChange = expectation(description: "optional.didChange") + + withPerceptionTracking { + _ = state.optional + } onChange: { + optionalDidChange.fulfill() + } + + state.optional = nil + await fulfillment(of: [optionalDidChange], timeout: 0) + XCTAssertNil(state.optional) + } + } + + func testMutateOptional() async { + var state = ParentState(optional: ChildState()) + let optionalCountDidChange = expectation(description: "optional.count.didChange") + + withPerceptionTracking { + _ = state.optional + } onChange: { + XCTFail("Optional should not change") + } + let optional = state.optional + withPerceptionTracking { + _ = optional?.count + } onChange: { + optionalCountDidChange.fulfill() + } + + state.optional?.count += 1 + await fulfillment(of: [optionalCountDidChange], timeout: 0) + XCTAssertEqual(state.optional?.count, 1) + } + + func testReplaceWithCopy() async { + let childState = ChildState(count: 1) + var childStateCopy = childState + childStateCopy.count = 2 + var state = ParentState(child: childState, sibling: childStateCopy) + let childCountDidChange = expectation(description: "child.count.didChange") + + withPerceptionTracking { + _ = state.child.count + } onChange: { + childCountDidChange.fulfill() + } + + state.child.replace(with: state.sibling) + + await fulfillment(of: [childCountDidChange], timeout: 0) + XCTAssertEqual(state.child.count, 2) + XCTAssertEqual(state.sibling.count, 2) + } + + func testIdentifiedArray_AddElement() { + var state = ParentState() + let rowsDidChange = expectation(description: "rowsDidChange") + + withPerceptionTracking { + _ = state.rows + } onChange: { + rowsDidChange.fulfill() + } + + state.rows.append(ChildState()) + XCTAssertEqual(state.rows.count, 1) + wait(for: [rowsDidChange], timeout: 0) + } + + func testIdentifiedArray_MutateElement() { + var state = ParentState(rows: [ + ChildState(), + ChildState(), + ]) + let firstRowCountDidChange = expectation(description: "firstRowCountDidChange") + + withPerceptionTracking { + _ = state.rows + } onChange: { + XCTFail("rows should not change") + } + withPerceptionTracking { + _ = state.rows[0] + } onChange: { + XCTFail("rows[0] should not change") + } + withPerceptionTracking { + _ = state.rows[0].count + } onChange: { + firstRowCountDidChange.fulfill() + } + withPerceptionTracking { + _ = state.rows[1].count + } onChange: { + XCTFail("rows[1].count should not change") + } + + state.rows[0].count += 1 + XCTAssertEqual(state.rows[0].count, 1) + wait(for: [firstRowCountDidChange], timeout: 0) + } + + func testCopy() { + var state = ParentState() + var childCopy = state.child.copy() + childCopy.count = 42 + let childCountDidChange = expectation(description: "childCountDidChange") + + withPerceptionTracking { + _ = state.child.count + } onChange: { + childCountDidChange.fulfill() + } + + state.child.replace(with: childCopy) + XCTAssertEqual(state.child.count, 42) + wait(for: [childCountDidChange], timeout: 0) + } + + func testArrayAppend() { + var state = ParentState() + let childrenDidChange = expectation(description: "childrenDidChange") + + withPerceptionTracking { + _ = state.children + } onChange: { + childrenDidChange.fulfill() + } + + state.children.append(ChildState()) + wait(for: [childrenDidChange]) + } + + func testArrayMutate() { + var state = ParentState(children: [ChildState()]) + + withPerceptionTracking { + _ = state.children + } onChange: { + XCTFail("children should not change") + } + + state.children[0].count += 1 + } +} + +@ObservableState +private struct ChildState: Equatable, Identifiable { + let id = UUID() + var count = 0 + mutating func replace(with other: Self) { + self = other + } + + mutating func reset() { + self = Self() + } + + mutating func copy() -> Self { + self + } +} + +@ObservableState +private struct ParentState: Equatable { + var child = ChildState() + var children: [ChildState] = [] + var optional: ChildState? + var rows: IdentifiedArrayOf = [] + var sibling = ChildState() + mutating func swap() { + let childCopy = child + child = sibling + sibling = childCopy + } +} diff --git a/WorkflowSwiftUI/Tests/StoreTests.swift b/WorkflowSwiftUI/Tests/StoreTests.swift new file mode 100644 index 000000000..781c68bd9 --- /dev/null +++ b/WorkflowSwiftUI/Tests/StoreTests.swift @@ -0,0 +1,821 @@ +import CasePaths +import IdentifiedCollections +import Perception +import SwiftUI +import Workflow +import XCTest +@testable import WorkflowSwiftUI + +final class StoreTests: XCTestCase { + func test_stateRead() { + var state = State() + let model = StateAccessor(state: state) { update in + update(&state) + } + let (store, _) = Store.make(model: model) + + withPerceptionTracking { + XCTAssertEqual(store.count, 0) + } onChange: { + XCTFail("State should not have been mutated") + } + } + + func test_stateMutation() async { + var state = State() + let model = StateAccessor(state: state) { update in + update(&state) + } + let (store, _) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + withPerceptionTracking { + _ = store.child.name + } onChange: { + XCTFail("child.name should not change") + } + + store.count = 1 + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + func test_childStateMutation() async { + var state = State() + let model = StateAccessor(state: state) { update in + update(&state) + } + let (store, _) = Store.make(model: model) + + let childNameDidChange = expectation(description: "child.name.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + XCTFail("count should not change") + } + + withPerceptionTracking { + _ = store.child.name + } onChange: { + childNameDidChange.fulfill() + } + + store.child.name = "foo" + await fulfillment(of: [childNameDidChange], timeout: 0) + XCTAssertEqual(state.count, 0) + XCTAssertEqual(state.child.name, "foo") + } + + func test_stateReplacement() async { + var state = State() + let model = StateAccessor(state: state) { update in + update(&state) + } + let (store, setModel) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + var newState = State(count: 1) + let newModel = StateAccessor(state: newState) { update in + update(&newState) + } + + setModel(newModel) + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 0) + XCTAssertEqual(newState.count, 1) + + store.count = 2 + + XCTAssertEqual(state.count, 0) + XCTAssertEqual(newState.count, 2) + } + + func test_sinkAccess() async { + var state = State() + let actionCalled = expectation(description: "action.called") + let model = CustomActionModel( + accessor: StateAccessor(state: state) { update in + update(&state) + }, + sink: Sink { _ in + actionCalled.fulfill() + } + ) + let (store, _) = Store.make(model: model) + + store.sink.send(.foo) + await fulfillment(of: [actionCalled], timeout: 0) + } + + func test_stateWithSetterClosure() async { + var state = State() + let model = ClosureModel( + accessor: StateAccessor(state: state) { _ in + XCTFail("state should not be mutated through accessor") + }, + onCountChanged: { count in + state.count = count + } + ) + let (store, _) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + XCTAssertEqual(store[state: \.count, send: \.onCountChanged], 0) + store[state: \.count, send: \.onCountChanged] = 1 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + func test_stateWithSetterAction() async { + var state = State() + let model = CustomActionModel( + accessor: StateAccessor(state: state) { _ in + XCTFail("state should not be mutated through accessor") + }, + sink: Sink { action in + switch action { + case .onCountChanged(let count): + state.count = count + case .foo: + XCTFail("unexpected action: \(action)") + } + } + ) + let (store, _) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + XCTAssertEqual(store[state: \.count, sink: \.sink, action: \.onCountChanged], 0) + store[state: \.count, sink: \.sink, action: \.onCountChanged] = 1 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + func test_singleActionModel() async { + func makeModel(state: State, sink: Sink) -> ActionModel { + ActionModel( + accessor: StateAccessor(state: state) { _ in + XCTFail("state should not be mutated through accessor") + }, + sendAction: sink.send + ) + } + + // store.send + do { + var state = State() + let sink = Sink { action in + switch action { + case .onCountChanged(let count): + state.count = count + case .foo: + XCTFail("unexpected action: \(action)") + } + } + let model = makeModel(state: state, sink: sink) + let (store, _) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + store.send(.onCountChanged(1)) + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + // store.action + do { + var state = State() + let sink = Sink { action in + switch action { + case .onCountChanged(let count): + state.count = count + case .foo: + XCTFail("unexpected action: \(action)") + } + } + let model = makeModel(state: state, sink: sink) + let (store, _) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + let action = store.action(.onCountChanged(2)) + XCTAssertEqual(state.count, 0) + + action() + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 2) + } + + // store[state:action:] + do { + var state = State() + let sink = Sink { action in + switch action { + case .onCountChanged(let count): + state.count = count + case .foo: + XCTFail("unexpected action: \(action)") + } + } + let model = makeModel(state: state, sink: sink) + let (store, _) = Store.make(model: model) + + let countDidChange = expectation(description: "count.didChange") + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + store[state: \State.count, action: \.onCountChanged] = 3 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 3) + } + } + + // MARK: - Child stores + + func test_childStore() async { + var childState = ParentModel.ChildState(age: 0) + + let model = ParentModel( + accessor: StateAccessor(state: State()) { _ in + XCTFail("parent state should not be mutated") + }, + child: StateAccessor(state: childState) { update in + update(&childState) + }, + array: [], + identified: [] + ) + let (store, _) = Store.make(model: model) + + let childAgeDidChange = expectation(description: "child.age.didChange") + withPerceptionTracking { + _ = store.child.age + } onChange: { + childAgeDidChange.fulfill() + } + + store.child.age = 1 + + await fulfillment(of: [childAgeDidChange], timeout: 0) + XCTAssertEqual(childState.age, 1) + } + + func test_childStore_optional() async { + func makeModel() -> ParentModel { + ParentModel( + accessor: StateAccessor(state: State()) { _ in + XCTFail("parent state should not be mutated") + }, + child: StateAccessor(state: .init()) { _ in + XCTFail("child state should not be mutated") + }, + array: [], + identified: [] + ) + } + + // some to nil + do { + var childState = ParentModel.ChildState(age: 0) + let childModel = StateAccessor(state: childState) { update in + update(&childState) + } + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.optional = childModel + setModel(model) + + let optionalDidChange = expectation(description: "optional.didChange") + withPerceptionTracking { + _ = store.optional + } onChange: { + optionalDidChange.fulfill() + } + + model.optional = nil + setModel(model) + + await fulfillment(of: [optionalDidChange], timeout: 0) + } + + // nil to some + do { + var childState = ParentModel.ChildState(age: 0) + let childModel = StateAccessor(state: childState) { update in + update(&childState) + } + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.optional = nil + setModel(model) + + let optionalDidChange = expectation(description: "optional.didChange") + withPerceptionTracking { + _ = store.optional + } onChange: { + optionalDidChange.fulfill() + } + + model.optional = childModel + setModel(model) + + await fulfillment(of: [optionalDidChange], timeout: 0) + } + + // some to some + do { + var childState = ParentModel.ChildState(age: 0) + let childModel = StateAccessor(state: childState) { update in + update(&childState) + } + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.optional = childModel + setModel(model) + + withPerceptionTracking { + _ = store.optional + } onChange: { + XCTFail("optional should not change") + } + + let optionalAgeDidChange = expectation(description: "optional.age.didChange") + withPerceptionTracking { + _ = store.optional?.age + } onChange: { + optionalAgeDidChange.fulfill() + } + + // the new instance will trigger a change in store.optional.age even though the value + // does not change + var newChildState = ParentModel.ChildState(age: 0) + let newChildModel = StateAccessor(state: newChildState) { update in + update(&newChildState) + } + + model.optional = newChildModel + setModel(model) + + await fulfillment(of: [optionalAgeDidChange], timeout: 0) + } + + // nil to nil + do { + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.optional = nil + setModel(model) + + withPerceptionTracking { + _ = store.optional + } onChange: { + XCTFail("optional should not change") + } + + model.optional = nil + setModel(model) + } + } + + func test_childStore_collection() async { + func makeChildStates() -> [ParentModel.ChildState] { + [ + .init(age: 0), + .init(age: 1), + .init(age: 2), + ] + } + + func makeChildModels(childStates: [ParentModel.ChildState]) -> [StateAccessor] { + childStates.map { state in + StateAccessor(state: state) { _ in + XCTFail("child state should not be mutated") + } + } + } + + func makeModel() -> ParentModel { + ParentModel( + accessor: StateAccessor(state: State()) { _ in + XCTFail("parent state should not be mutated") + }, + child: StateAccessor(state: .init()) { _ in + XCTFail("child state should not be mutated") + }, + array: [], + identified: [] + ) + } + + // add + do { + let childStates = makeChildStates() + let childModels = makeChildModels(childStates: childStates) + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.array = [] + setModel(model) + + let arrayDidChange = expectation(description: "array.didChange") + withPerceptionTracking { + _ = store.array + } onChange: { + arrayDidChange.fulfill() + } + + model.array = childModels + setModel(model) + + await fulfillment(of: [arrayDidChange], timeout: 0) + } + + // remove + do { + let childStates = makeChildStates() + let childModels = makeChildModels(childStates: childStates) + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.array = childModels + setModel(model) + + let arrayDidChange = expectation(description: "array.didChange") + withPerceptionTracking { + _ = store.array + } onChange: { + arrayDidChange.fulfill() + } + + model.array = [] + setModel(model) + + await fulfillment(of: [arrayDidChange], timeout: 0) + } + + // reorder + do { + let childStates = makeChildStates() + let childModels = makeChildModels(childStates: childStates) + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.array = childModels + setModel(model) + + withPerceptionTracking { + _ = store.array + } onChange: { + XCTFail("array should not change") + } + + let array0AgeDidChange = expectation(description: "array[0].age.didChange") + withPerceptionTracking { + _ = store.array[0].age + } onChange: { + array0AgeDidChange.fulfill() + } + + model.array = [childModels[1], childModels[2], childModels[0]] + setModel(model) + + await fulfillment(of: [array0AgeDidChange], timeout: 0) + } + } + + func test_childStore_identifiedCollection() async { + func makeChildStates() -> [ParentModel.ChildState] { + [ + .init(age: 0), + .init(age: 1), + .init(age: 2), + ] + } + + func makeModel() -> ParentModel { + ParentModel( + accessor: StateAccessor(state: State()) { _ in + XCTFail("parent state should not be mutated") + }, + child: StateAccessor(state: .init()) { _ in + XCTFail("child state should not be mutated") + }, + array: [], + identified: [] + ) + } + + // add + do { + var childStates = makeChildStates() + let childModels = IdentifiedArray( + uniqueElements: zip(childStates.indices, childStates).map { index, state in + StateAccessor(state: state) { update in + update(&childStates[index]) + } + } + ) + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.identified = [] + setModel(model) + + let identifiedDidChange = expectation(description: "identified.didChange") + withPerceptionTracking { + _ = store.identified + } onChange: { + identifiedDidChange.fulfill() + } + + model.identified = childModels + setModel(model) + + await fulfillment(of: [identifiedDidChange], timeout: 0) + } + + // remove + do { + var childStates = makeChildStates() + let childModels = IdentifiedArray( + uniqueElements: zip(childStates.indices, childStates).map { index, state in + StateAccessor(state: state) { update in + update(&childStates[index]) + } + } + ) + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.identified = childModels + setModel(model) + + let arrayDidChange = expectation(description: "identified.didChange") + withPerceptionTracking { + _ = store.identified + } onChange: { + arrayDidChange.fulfill() + } + + model.identified = [] + setModel(model) + + await fulfillment(of: [arrayDidChange], timeout: 0) + } + + // reorder + do { + var childStates = makeChildStates() + let childModels = IdentifiedArray( + uniqueElements: zip(childStates.indices, childStates).map { index, state in + StateAccessor(state: state) { update in + update(&childStates[index]) + } + } + ) + var model = makeModel() + let (store, setModel) = Store.make(model: model) + + model.identified = childModels + setModel(model) + + withPerceptionTracking { + _ = store.identified + } onChange: { + XCTFail("identified should not change") + } + + let identified0AgeDidChange = expectation(description: "identified[0].age.didChange") + withPerceptionTracking { + _ = store.identified[0].age + } onChange: { + identified0AgeDidChange.fulfill() + } + + model.identified = [childModels[1], childModels[2], childModels[0]] + setModel(model) + + await fulfillment(of: [identified0AgeDidChange], timeout: 0) + } + } + + func test_invalidation() { + // TODO: + } + + // MARK: - Bindings + + @MainActor + func test_bindings() async { + var state = State() + let model = StateAccessor(state: state) { update in + update(&state) + } + let (_store, _) = Store.make(model: model) + @Perception.Bindable var store = _store + + let countDidChange = expectation(description: "count.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + let binding = $store.count + binding.wrappedValue = 1 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + @MainActor + func test_bindingSendingCustomAction() async { + var state = State() + let model = CustomActionModel( + accessor: StateAccessor(state: state) { _ in + XCTFail("state should not be mutated through accessor") + }, + sink: Sink { action in + switch action { + case .onCountChanged(let count): + state.count = count + case .foo: + XCTFail("unexpected action: \(action)") + } + } + ) + let (_store, _) = Store.make(model: model) + @Perception.Bindable var store = _store + + let countDidChange = expectation(description: "count.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + let binding = $store.count.sending(sink: \.sink, action: \.onCountChanged) + binding.wrappedValue = 1 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + @MainActor + func test_bindingSendingClosure() async { + var state = State() + let model = ClosureModel( + accessor: StateAccessor(state: state) { _ in + XCTFail("state should not be mutated through accessor") + }, + onCountChanged: { count in + state.count = count + } + ) + let (_store, _) = Store.make(model: model) + @Perception.Bindable var store = _store + + let countDidChange = expectation(description: "count.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + let binding = $store.count.sending(closure: \.onCountChanged) + binding.wrappedValue = 1 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + @MainActor + func test_bindingSendingSingleAction() async { + var state = State() + let model = ActionModel( + accessor: StateAccessor(state: state) { _ in + XCTFail("state should not be mutated through accessor") + }, + sendAction: Sink { action in + switch action { + case .onCountChanged(let count): + state.count = count + case .foo: + XCTFail("unexpected action: \(action)") + } + }.send + ) + let (_store, _) = Store.make(model: model) + @Perception.Bindable var store = _store + + let countDidChange = expectation(description: "count.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + let binding = $store.count.sending(action: \.onCountChanged) + binding.wrappedValue = 1 + + await fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } +} + +@ObservableState +private struct State { + var count = 0 + var child = Child() + + @ObservableState + struct Child { + var name = "" + } +} + +@CasePathable +private enum Action { + case foo + case onCountChanged(Int) +} + +private struct CustomActionModel: ObservableModel { + var accessor: StateAccessor + + var sink: Sink +} + +private struct ClosureModel: ObservableModel { + var accessor: StateAccessor + + var onCountChanged: (Int) -> Void +} + +private struct ParentModel: ObservableModel { + @ObservableState + struct ChildState: Identifiable { + let id = UUID() + var age = 0 + } + + typealias ChildModel = StateAccessor + + var accessor: StateAccessor + + var child: ChildModel + var optional: ChildModel? + var array: [ChildModel] = [] + var identified: IdentifiedArrayOf = [] +} diff --git a/WorkflowSwiftUIMacros/Sources/Derived/Availability.swift b/WorkflowSwiftUIMacros/Sources/Derived/Availability.swift new file mode 100644 index 000000000..fa005b4f3 --- /dev/null +++ b/WorkflowSwiftUIMacros/Sources/Derived/Availability.swift @@ -0,0 +1,109 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Sources/ComposableArchitectureMacros/Availability.swift + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension AttributeSyntax { + var availability: AttributeSyntax? { + if attributeName.identifier == "available" { + return self + } else { + return nil + } + } +} + +extension IfConfigClauseSyntax.Elements { + var availability: IfConfigClauseSyntax.Elements? { + switch self { + case .attributes(let attributes): + if let availability = attributes.availability { + return .attributes(availability) + } else { + return nil + } + default: + return nil + } + } +} + +extension IfConfigClauseSyntax { + var availability: IfConfigClauseSyntax? { + if let availability = elements?.availability { + return with(\.elements, availability) + } else { + return nil + } + } + + var clonedAsIf: IfConfigClauseSyntax { + detached.with(\.poundKeyword, .poundIfToken()) + } +} + +extension IfConfigDeclSyntax { + var availability: IfConfigDeclSyntax? { + var elements = [IfConfigClauseListSyntax.Element]() + for clause in clauses { + if let availability = clause.availability { + if elements.isEmpty { + elements.append(availability.clonedAsIf) + } else { + elements.append(availability) + } + } + } + if elements.isEmpty { + return nil + } else { + return with(\.clauses, IfConfigClauseListSyntax(elements)) + } + } +} + +extension AttributeListSyntax.Element { + var availability: AttributeListSyntax.Element? { + switch self { + case .attribute(let attribute): + if let availability = attribute.availability { + return .attribute(availability) + } + case .ifConfigDecl(let ifConfig): + if let availability = ifConfig.availability { + return .ifConfigDecl(availability) + } + } + return nil + } +} + +extension AttributeListSyntax { + var availability: AttributeListSyntax? { + var elements = [AttributeListSyntax.Element]() + for element in self { + if let availability = element.availability { + elements.append(availability) + } + } + if elements.isEmpty { + return nil + } + return AttributeListSyntax(elements) + } +} diff --git a/WorkflowSwiftUIMacros/Sources/Derived/Extensions.swift b/WorkflowSwiftUIMacros/Sources/Derived/Extensions.swift new file mode 100644 index 000000000..655afc454 --- /dev/null +++ b/WorkflowSwiftUIMacros/Sources/Derived/Extensions.swift @@ -0,0 +1,302 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Sources/ComposableArchitectureMacros/Extensions.swift + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension VariableDeclSyntax { + var identifierPattern: IdentifierPatternSyntax? { + bindings.first?.pattern.as(IdentifierPatternSyntax.self) + } + + var isInstance: Bool { + for modifier in modifiers { + for token in modifier.tokens(viewMode: .all) { + if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { + return false + } + } + } + return true + } + + var identifier: TokenSyntax? { + identifierPattern?.identifier + } + + var type: TypeSyntax? { + bindings.first?.typeAnnotation?.type + } + + func accessorsMatching(_ predicate: (TokenKind) -> Bool) -> [AccessorDeclSyntax] { + let patternBindings = bindings.compactMap { binding in + binding.as(PatternBindingSyntax.self) + } + let accessors: [AccessorDeclListSyntax.Element] = patternBindings.compactMap { patternBinding in + switch patternBinding.accessorBlock?.accessors { + case .accessors(let accessors): + return accessors + default: + return nil + } + }.flatMap { $0 } + return accessors.compactMap { accessor in + guard let decl = accessor.as(AccessorDeclSyntax.self) else { + return nil + } + if predicate(decl.accessorSpecifier.tokenKind) { + return decl + } else { + return nil + } + } + } + + var willSetAccessors: [AccessorDeclSyntax] { + accessorsMatching { $0 == .keyword(.willSet) } + } + + var didSetAccessors: [AccessorDeclSyntax] { + accessorsMatching { $0 == .keyword(.didSet) } + } + + var isComputed: Bool { + if accessorsMatching({ $0 == .keyword(.get) }).count > 0 { + return true + } else { + return bindings.contains { binding in + if case .getter = binding.accessorBlock?.accessors { + return true + } else { + return false + } + } + } + } + + var isImmutable: Bool { + bindingSpecifier.tokenKind == .keyword(.let) + } + + func isEquivalent(to other: VariableDeclSyntax) -> Bool { + if isInstance != other.isInstance { + return false + } + return identifier?.text == other.identifier?.text + } + + var initializer: InitializerClauseSyntax? { + bindings.first?.initializer + } + + func hasMacroApplication(_ name: String) -> Bool { + for attribute in attributes { + switch attribute { + case .attribute(let attr): + if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { + return true + } + default: + break + } + } + return false + } + + func firstAttribute(for name: String) -> AttributeSyntax? { + for attribute in attributes { + switch attribute { + case .attribute(let attr): + if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { + return attr + } + default: + break + } + } + return nil + } +} + +extension TypeSyntax { + var identifier: String? { + for token in tokens(viewMode: .all) { + switch token.tokenKind { + case .identifier(let identifier): + return identifier + default: + break + } + } + return nil + } + + func genericSubstitution(_ parameters: GenericParameterListSyntax?) -> String? { + var genericParameters = [String: TypeSyntax?]() + if let parameters { + for parameter in parameters { + genericParameters[parameter.name.text] = parameter.inheritedType + } + } + var iterator = asProtocol(TypeSyntaxProtocol.self).tokens(viewMode: .sourceAccurate) + .makeIterator() + guard let base = iterator.next() else { + return nil + } + + if let genericBase = genericParameters[base.text] { + if let text = genericBase?.identifier { + return "some " + text + } else { + return nil + } + } + var substituted = base.text + + while let token = iterator.next() { + switch token.tokenKind { + case .leftAngle: + substituted += "<" + case .rightAngle: + substituted += ">" + case .comma: + substituted += "," + case .identifier(let identifier): + let type: TypeSyntax = "\(raw: identifier)" + guard let substituedType = type.genericSubstitution(parameters) else { + return nil + } + substituted += substituedType + default: + // ignore? + break + } + } + + return substituted + } +} + +extension FunctionDeclSyntax { + var isInstance: Bool { + for modifier in modifiers { + for token in modifier.tokens(viewMode: .all) { + if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { + return false + } + } + } + return true + } + + struct SignatureStandin: Equatable { + var isInstance: Bool + var identifier: String + var parameters: [String] + var returnType: String + } + + var signatureStandin: SignatureStandin { + var parameters = [String]() + for parameter in signature.parameterClause.parameters { + parameters.append( + parameter.firstName.text + ":" + + (parameter.type.genericSubstitution(genericParameterClause?.parameters) ?? "")) + } + let returnType = + signature.returnClause?.type.genericSubstitution(genericParameterClause?.parameters) ?? "Void" + return SignatureStandin( + isInstance: isInstance, identifier: name.text, parameters: parameters, returnType: returnType + ) + } + + func isEquivalent(to other: FunctionDeclSyntax) -> Bool { + signatureStandin == other.signatureStandin + } +} + +extension DeclGroupSyntax { + var memberFunctionStandins: [FunctionDeclSyntax.SignatureStandin] { + var standins = [FunctionDeclSyntax.SignatureStandin]() + for member in memberBlock.members { + if let function = member.as(MemberBlockItemSyntax.self)?.decl.as(FunctionDeclSyntax.self) { + standins.append(function.signatureStandin) + } + } + return standins + } + + func hasMemberFunction(equvalentTo other: FunctionDeclSyntax) -> Bool { + for member in memberBlock.members { + if let function = member.as(MemberBlockItemSyntax.self)?.decl.as(FunctionDeclSyntax.self) { + if function.isEquivalent(to: other) { + return true + } + } + } + return false + } + + func hasMemberProperty(equivalentTo other: VariableDeclSyntax) -> Bool { + for member in memberBlock.members { + if let variable = member.as(MemberBlockItemSyntax.self)?.decl.as(VariableDeclSyntax.self) { + if variable.isEquivalent(to: other) { + return true + } + } + } + return false + } + + var definedVariables: [VariableDeclSyntax] { + memberBlock.members.compactMap { member in + if let variableDecl = member.as(MemberBlockItemSyntax.self)?.decl.as(VariableDeclSyntax.self) { + return variableDecl + } + return nil + } + } + + func addIfNeeded(_ decl: DeclSyntax?, to declarations: inout [DeclSyntax]) { + guard let decl else { return } + if let fn = decl.as(FunctionDeclSyntax.self) { + if !hasMemberFunction(equvalentTo: fn) { + declarations.append(decl) + } + } else if let property = decl.as(VariableDeclSyntax.self) { + if !hasMemberProperty(equivalentTo: property) { + declarations.append(decl) + } + } + } + + var isClass: Bool { + self.is(ClassDeclSyntax.self) + } + + var isActor: Bool { + self.is(ActorDeclSyntax.self) + } + + var isEnum: Bool { + self.is(EnumDeclSyntax.self) + } + + var isStruct: Bool { + self.is(StructDeclSyntax.self) + } +} diff --git a/WorkflowSwiftUIMacros/Sources/Derived/ObservableStateMacro.swift b/WorkflowSwiftUIMacros/Sources/Derived/ObservableStateMacro.swift new file mode 100644 index 000000000..52895711f --- /dev/null +++ b/WorkflowSwiftUIMacros/Sources/Derived/ObservableStateMacro.swift @@ -0,0 +1,599 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public struct ObservableStateMacro { + static let moduleName = "WorkflowSwiftUI" + + static let conformanceName = "ObservableState" + static var qualifiedConformanceName: String { + "\(moduleName).\(conformanceName)" + } + + static let originalConformanceName = "Observable" + static var qualifiedOriginalConformanceName: String { + "Observation.\(originalConformanceName)" + } + + static var observableConformanceType: TypeSyntax { + "\(raw: qualifiedConformanceName)" + } + + static let registrarTypeName = "ObservationStateRegistrar" + static var qualifiedRegistrarTypeName: String { + "\(moduleName).\(registrarTypeName)" + } + + static let idName = "ObservableStateID" + static var qualifiedIDName: String { + "\(moduleName).\(idName)" + } + + static let trackedMacroName = "ObservationStateTracked" + static let ignoredMacroName = "ObservationStateIgnored" + + static let registrarVariableName = "_$observationRegistrar" + + static func registrarVariable(_ observableType: TokenSyntax) -> DeclSyntax { + """ + @\(raw: ignoredMacroName) var \(raw: registrarVariableName) = \(raw: qualifiedRegistrarTypeName)() + """ + } + + static func idVariable() -> DeclSyntax { + """ + public var _$id: \(raw: qualifiedIDName) { + \(raw: registrarVariableName).id + } + """ + } + + static func willModifyFunction() -> DeclSyntax { + """ + public mutating func _$willModify() { + \(raw: registrarVariableName)._$willModify() + } + """ + } + + static var ignoredAttribute: AttributeSyntax { + AttributeSyntax( + leadingTrivia: .space, + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax(name: .identifier(ignoredMacroName)), + trailingTrivia: .space + ) + } +} + +struct ObservationDiagnostic: DiagnosticMessage { + enum ID: String { + case invalidApplication = "invalid type" + case missingInitializer = "missing initializer" + } + + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + + init( + message: String, diagnosticID: SwiftDiagnostics.MessageID, + severity: SwiftDiagnostics.DiagnosticSeverity = .error + ) { + self.message = message + self.diagnosticID = diagnosticID + self.severity = severity + } + + init( + message: String, domain: String, id: ID, severity: SwiftDiagnostics.DiagnosticSeverity = .error + ) { + self.message = message + self.diagnosticID = MessageID(domain: domain, id: id.rawValue) + self.severity = severity + } +} + +extension DiagnosticsError { + init( + syntax: S, message: String, domain: String = "Observation", id: ObservationDiagnostic.ID, + severity: SwiftDiagnostics.DiagnosticSeverity = .error + ) { + self.init(diagnostics: [ + Diagnostic( + node: Syntax(syntax), + message: ObservationDiagnostic(message: message, domain: domain, id: id, severity: severity) + ), + ]) + } +} + +extension DeclModifierListSyntax { + func privatePrefixed(_ prefix: String) -> DeclModifierListSyntax { + let modifier: DeclModifierSyntax = DeclModifierSyntax(name: "private", trailingTrivia: .space) + return [modifier] + + filter { + switch $0.name.tokenKind { + case .keyword(let keyword): + switch keyword { + case .fileprivate, .private, .internal, .public, .package: + return false + default: + return true + } + default: + return true + } + } + } + + init(keyword: Keyword) { + self.init([DeclModifierSyntax(name: .keyword(keyword))]) + } +} + +extension TokenSyntax { + func privatePrefixed(_ prefix: String) -> TokenSyntax { + switch tokenKind { + case .identifier(let identifier): + return TokenSyntax( + .identifier(prefix + identifier), leadingTrivia: leadingTrivia, + trailingTrivia: trailingTrivia, presence: presence + ) + default: + return self + } + } +} + +extension PatternBindingListSyntax { + func privatePrefixed(_ prefix: String) -> PatternBindingListSyntax { + var bindings = map { $0 } + for index in 0 ..< bindings.count { + let binding = bindings[index] + if let identifier = binding.pattern.as(IdentifierPatternSyntax.self) { + bindings[index] = PatternBindingSyntax( + leadingTrivia: binding.leadingTrivia, + pattern: IdentifierPatternSyntax( + leadingTrivia: identifier.leadingTrivia, + identifier: identifier.identifier.privatePrefixed(prefix), + trailingTrivia: identifier.trailingTrivia + ), + typeAnnotation: binding.typeAnnotation, + initializer: binding.initializer, + accessorBlock: binding.accessorBlock, + trailingComma: binding.trailingComma, + trailingTrivia: binding.trailingTrivia + ) + } + } + + return PatternBindingListSyntax(bindings) + } +} + +extension VariableDeclSyntax { + func privatePrefixed(_ prefix: String, addingAttribute attribute: AttributeSyntax) + -> VariableDeclSyntax { + let newAttributes = attributes + [.attribute(attribute)] + return VariableDeclSyntax( + leadingTrivia: leadingTrivia, + attributes: newAttributes, + modifiers: modifiers.privatePrefixed(prefix), + bindingSpecifier: TokenSyntax( + bindingSpecifier.tokenKind, leadingTrivia: .space, trailingTrivia: .space, + presence: .present + ), + bindings: bindings.privatePrefixed(prefix), + trailingTrivia: trailingTrivia + ) + } + + var isValidForObservation: Bool { + !isComputed && isInstance && !isImmutable && identifier != nil + } +} + +extension ObservableStateMacro: MemberMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + providingMembersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + guard !declaration.isEnum + else { + return try enumExpansion(of: node, providingMembersOf: declaration, in: context) + } + + guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { + return [] + } + + let observableType = identified.name.trimmed + + if declaration.isClass { + // classes are not supported + throw DiagnosticsError( + syntax: node, + message: "'@ObservableState' cannot be applied to class type '\(observableType.text)'", + id: .invalidApplication + ) + } + if declaration.isActor { + // actors cannot yet be supported for their isolation + throw DiagnosticsError( + syntax: node, + message: "'@ObservableState' cannot be applied to actor type '\(observableType.text)'", + id: .invalidApplication + ) + } + + var declarations = [DeclSyntax]() + + declaration.addIfNeeded( + ObservableStateMacro.registrarVariable(observableType), to: &declarations + ) + declaration.addIfNeeded(ObservableStateMacro.idVariable(), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.willModifyFunction(), to: &declarations) + + return declarations + } +} + +extension Array where Element == ObservableStateCase { + init(members: MemberBlockItemListSyntax) { + var tag = 0 + self.init(members: members, tag: &tag) + } + + init(members: MemberBlockItemListSyntax, tag: inout Int) { + self = members.flatMap { member -> [ObservableStateCase] in + if let enumCaseDecl = member.decl.as(EnumCaseDeclSyntax.self) { + return enumCaseDecl.elements.map { + defer { tag += 1 } + return ObservableStateCase.element($0, tag: tag) + } + } + if let ifConfigDecl = member.decl.as(IfConfigDeclSyntax.self) { + let configs = ifConfigDecl.clauses.flatMap { decl -> [ObservableStateCase.IfConfig] in + guard let elements = decl.elements?.as(MemberBlockItemListSyntax.self) + else { return [] } + return [ + ObservableStateCase.IfConfig( + poundKeyword: decl.poundKeyword, + condition: decl.condition, + cases: Array(members: elements, tag: &tag) + ), + ] + } + return [.ifConfig(configs)] + } + return [] + } + } +} + +enum ObservableStateCase { + case element(EnumCaseElementSyntax, tag: Int) + indirect case ifConfig([IfConfig]) + + struct IfConfig { + let poundKeyword: TokenSyntax + let condition: ExprSyntax? + let cases: [ObservableStateCase] + } + + var getCase: String { + switch self { + case .element(let element, let tag): + if let parameters = element.parameterClause?.parameters, parameters.count == 1 { + return """ + case let .\(element.name.text)(state): + return ._$id(for: state)._$tag(\(tag)) + """ + } else { + return """ + case .\(element.name.text): + return ObservableStateID()._$tag(\(tag)) + """ + } + case .ifConfig(let configs): + return + configs + .map { + """ + \($0.poundKeyword.text) \($0.condition?.trimmedDescription ?? "") + \($0.cases.map(\.getCase).joined(separator: "\n")) + """ + } + .joined(separator: "\n") + "#endif\n" + } + } + + var willModifyCase: String { + switch self { + case .element(let element, _): + if let parameters = element.parameterClause?.parameters, + parameters.count == 1, + let parameter = parameters.first { + return """ + case var .\(element.name.text)(state): + \(ObservableStateMacro.moduleName)._$willModify(&state) + self = .\(element.name.text)(\(parameter.firstName.map { "\($0): " } ?? "")state) + """ + } else { + return """ + case .\(element.name.text): + break + """ + } + case .ifConfig(let configs): + return + configs + .map { + """ + \($0.poundKeyword.text) \($0.condition?.trimmedDescription ?? "") + \($0.cases.map(\.willModifyCase).joined(separator: "\n")) + """ + } + .joined(separator: "\n") + "#endif\n" + } + } +} + +extension ObservableStateMacro { + public static func enumExpansion< + Declaration: DeclGroupSyntax, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + providingMembersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + let cases = [ObservableStateCase](members: declaration.memberBlock.members) + var getCases: [String] = [] + var willModifyCases: [String] = [] + for enumCase in cases { + getCases.append(enumCase.getCase) + willModifyCases.append(enumCase.willModifyCase) + } + + return [ + """ + public var _$id: \(raw: qualifiedIDName) { + switch self { + \(raw: getCases.joined(separator: "\n")) + } + } + """, + """ + public mutating func _$willModify() { + switch self { + \(raw: willModifyCases.joined(separator: "\n")) + } + } + """, + ] + } +} + +extension SyntaxStringInterpolation { + // It would be nice for SwiftSyntaxBuilder to provide this out-of-the-box. + mutating func appendInterpolation(_ node: Node?) { + if let node { + appendInterpolation(node) + } + } +} + +extension ObservableStateMacro: MemberAttributeMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + MemberDeclaration: DeclSyntaxProtocol, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + attachedTo declaration: Declaration, + providingAttributesFor member: MemberDeclaration, + in context: Context + ) throws -> [AttributeSyntax] { + guard let property = member.as(VariableDeclSyntax.self), property.isValidForObservation, + property.identifier != nil + else { + return [] + } + + // dont apply to ignored properties or properties that are already flagged as tracked + if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) + || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) { + return [] + } + + property.diagnose( + attribute: "ObservationIgnored", + renamed: ObservableStateMacro.ignoredMacroName, + context: context + ) + property.diagnose( + attribute: "ObservationTracked", + renamed: ObservableStateMacro.trackedMacroName, + context: context + ) + + return [ + AttributeSyntax( + attributeName: IdentifierTypeSyntax( + name: .identifier(ObservableStateMacro.trackedMacroName))), + ] + } +} + +extension VariableDeclSyntax { + func diagnose( + attribute name: String, + renamed rename: String, + context: C + ) { + if let attribute = firstAttribute(for: name), + let type = attribute.attributeName.as(IdentifierTypeSyntax.self) { + context.diagnose( + Diagnostic( + node: attribute, + message: MacroExpansionErrorMessage("'@\(name)' cannot be used in '@ObservableState'"), + fixIt: .replace( + message: MacroExpansionFixItMessage("Use '@\(rename)' instead"), + oldNode: attribute, + newNode: attribute.with( + \.attributeName, + TypeSyntax( + type.with( + \.name, + .identifier(rename, trailingTrivia: type.name.trailingTrivia) + ) + ) + ) + ) + ) + ) + } + } +} + +extension ObservableStateMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + // This method can be called twice - first with an empty `protocols` when + // no conformance is needed, and second with a `MissingTypeSyntax` instance. + if protocols.isEmpty { + return [] + } + + return [ + (""" + \(declaration.attributes.availability)extension \(raw: type.trimmedDescription): \ + \(raw: qualifiedConformanceName), Observation.Observable {} + """ as DeclSyntax) + .cast(ExtensionDeclSyntax.self), + ] + } +} + +public struct ObservationStateTrackedMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + guard let property = declaration.as(VariableDeclSyntax.self), + property.isValidForObservation, + let identifier = property.identifier?.trimmed + else { + return [] + } + + if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) { + return [] + } + + let initAccessor: AccessorDeclSyntax = + """ + @storageRestrictions(initializes: _\(identifier)) + init(initialValue) { + _\(identifier) = initialValue + } + """ + + let getAccessor: AccessorDeclSyntax = + """ + get { + \(raw: ObservableStateMacro.registrarVariableName).access(self, keyPath: \\.\(identifier)) + return _\(identifier) + } + """ + + let setAccessor: AccessorDeclSyntax = + """ + set { + \(raw: ObservableStateMacro.registrarVariableName).mutate(self, keyPath: \\.\(identifier), &_\(identifier), newValue, _$isIdentityEqual) + } + """ + let modifyAccessor: AccessorDeclSyntax = """ + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \\.\(identifier), &_\(identifier)) + defer { + _$observationRegistrar.didModify(self, keyPath: \\.\(identifier), &_\(identifier), oldValue, _$isIdentityEqual) + } + yield &_\(identifier) + } + """ + + return [initAccessor, getAccessor, setAccessor, modifyAccessor] + } +} + +extension ObservationStateTrackedMacro: PeerMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + guard let property = declaration.as(VariableDeclSyntax.self), + property.isValidForObservation + else { + return [] + } + + if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) + || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) { + return [] + } + + let storage = DeclSyntax( + property.privatePrefixed("_", addingAttribute: ObservableStateMacro.ignoredAttribute)) + return [storage] + } +} + +public struct ObservationStateIgnoredMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + [] + } +} diff --git a/WorkflowSwiftUIMacros/Sources/Plugins.swift b/WorkflowSwiftUIMacros/Sources/Plugins.swift new file mode 100644 index 000000000..d0ef680cb --- /dev/null +++ b/WorkflowSwiftUIMacros/Sources/Plugins.swift @@ -0,0 +1,11 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct MacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + ObservableStateMacro.self, + ObservationStateTrackedMacro.self, + ObservationStateIgnoredMacro.self, + ] +} diff --git a/WorkflowSwiftUIMacros/Tests/Derived/ObservableStateMacroTests.swift b/WorkflowSwiftUIMacros/Tests/Derived/ObservableStateMacroTests.swift new file mode 100644 index 000000000..979d7afa7 --- /dev/null +++ b/WorkflowSwiftUIMacros/Tests/Derived/ObservableStateMacroTests.swift @@ -0,0 +1,651 @@ +// Derived from +// https://github.com/pointfreeco/swift-composable-architecture/blob/1.12.1/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift + +// If running this test in Xcode, set the run destination to "My Mac", or you'll get: +// > No such module 'WorkflowSwiftUIMacros' +// See https://forums.swift.org/t/xcode-15-beta-no-such-module-error-with-swiftpm-and-macro/65486 + +import MacroTesting +import WorkflowSwiftUIMacros +import XCTest + +final class ObservableStateMacroTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + // isRecording: true, + macros: [ + ObservableStateMacro.self, + ObservationStateIgnoredMacro.self, + ObservationStateTrackedMacro.self, + ] + ) { + super.invokeTest() + } + } + + func testAvailability() { + assertMacro { + """ + @ObservableState + @available(iOS 18, *) + struct State { + var count = 0 + } + """ + } expansion: { + #""" + @available(iOS 18, *) + struct State { + var count = 0 { + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + get { + _$observationRegistrar.access(self, keyPath: \.count) + return _count + } + set { + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + } + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) + defer { + _$observationRegistrar.didModify(self, keyPath: \.count, &_count, oldValue, _$isIdentityEqual) + } + yield &_count + } + } + + var _$observationRegistrar = WorkflowSwiftUI.ObservationStateRegistrar() + + public var _$id: WorkflowSwiftUI.ObservableStateID { + _$observationRegistrar.id + } + + public mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """# + } + } + + func testObservableState() throws { + assertMacro { + #""" + @ObservableState + struct State { + var count = 0 + } + """# + } expansion: { + #""" + struct State { + var count = 0 { + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + get { + _$observationRegistrar.access(self, keyPath: \.count) + return _count + } + set { + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + } + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) + defer { + _$observationRegistrar.didModify(self, keyPath: \.count, &_count, oldValue, _$isIdentityEqual) + } + yield &_count + } + } + + var _$observationRegistrar = WorkflowSwiftUI.ObservationStateRegistrar() + + public var _$id: WorkflowSwiftUI.ObservableStateID { + _$observationRegistrar.id + } + + public mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """# + } + } + + func testObservableState_AccessControl() throws { + assertMacro { + #""" + @ObservableState + public struct State { + var count = 0 + } + """# + } expansion: { + #""" + public struct State { + var count = 0 { + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + get { + _$observationRegistrar.access(self, keyPath: \.count) + return _count + } + set { + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + } + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) + defer { + _$observationRegistrar.didModify(self, keyPath: \.count, &_count, oldValue, _$isIdentityEqual) + } + yield &_count + } + } + + var _$observationRegistrar = WorkflowSwiftUI.ObservationStateRegistrar() + + public var _$id: WorkflowSwiftUI.ObservableStateID { + _$observationRegistrar.id + } + + public mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """# + } + assertMacro { + #""" + @ObservableState + package struct State { + var count = 0 + } + """# + } expansion: { + #""" + package struct State { + var count = 0 { + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + get { + _$observationRegistrar.access(self, keyPath: \.count) + return _count + } + set { + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + } + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) + defer { + _$observationRegistrar.didModify(self, keyPath: \.count, &_count, oldValue, _$isIdentityEqual) + } + yield &_count + } + } + + var _$observationRegistrar = WorkflowSwiftUI.ObservationStateRegistrar() + + public var _$id: WorkflowSwiftUI.ObservableStateID { + _$observationRegistrar.id + } + + public mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """# + } + } + + func testObservableStateIgnored() throws { + assertMacro { + #""" + @ObservableState + struct State { + @ObservationStateIgnored + var count = 0 + } + """# + } expansion: { + """ + struct State { + var count = 0 + + var _$observationRegistrar = WorkflowSwiftUI.ObservationStateRegistrar() + + public var _$id: WorkflowSwiftUI.ObservableStateID { + _$observationRegistrar.id + } + + public mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """ + } + } + + func testObservableState_Enum() { + assertMacro { + """ + @ObservableState + enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + """ + } expansion: { + """ + enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature2(state) + } + } + } + """ + } + } + + func testObservableState_Enum_Label() { + assertMacro { + """ + @ObservableState + enum Path { + case feature1(state: String) + } + """ + } expansion: { + """ + enum Path { + case feature1(state: String) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature1(state: state) + } + } + } + """ + } + } + + func testObservableState_Enum_AccessControl() { + assertMacro { + """ + @ObservableState + public enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + """ + } expansion: { + """ + public enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature2(state) + } + } + } + """ + } + assertMacro { + """ + @ObservableState + package enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + """ + } expansion: { + """ + package enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature2(state) + } + } + } + """ + } + } + + func testObservableState_Enum_AccessControl_WrappedByExtension() { + assertMacro { + """ + public extension Feature { + @ObservableState + enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + } + """ + } expansion: { + """ + public extension Feature { + enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature2(state) + } + } + } + } + """ + } + assertMacro { + """ + public extension Feature { + @ObservableState + package enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + } + """ + } expansion: { + """ + public extension Feature { + package enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + WorkflowSwiftUI._$willModify(&state) + self = .feature2(state) + } + } + } + } + """ + } + } + + func testObservableState_Enum_NonObservableCase() { + assertMacro { + """ + @ObservableState + public enum Path { + case foo(Int) + } + """ + } expansion: { + """ + public enum Path { + case foo(Int) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .foo(state): + return ._$id(for: state)._$tag(0) + } + } + + public mutating func _$willModify() { + switch self { + case var .foo(state): + WorkflowSwiftUI._$willModify(&state) + self = .foo(state) + } + } + } + """ + } + } + + func testObservableState_Enum_MultipleAssociatedValues() { + assertMacro { + """ + @ObservableState + public enum Path { + case foo(Int, String) + } + """ + } expansion: { + """ + public enum Path { + case foo(Int, String) + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case .foo: + return ObservableStateID()._$tag(0) + } + } + + public mutating func _$willModify() { + switch self { + case .foo: + break + } + } + } + """ + } + } + + func testObservableState_Class() { + assertMacro { + """ + @ObservableState + public class Model { + } + """ + } diagnostics: { + """ + @ObservableState + ┬─────────────── + ╰─ 🛑 '@ObservableState' cannot be applied to class type 'Model' + public class Model { + } + """ + } + } + + func testObservableState_Actor() { + assertMacro { + """ + @ObservableState + public actor Model { + } + """ + } diagnostics: { + """ + @ObservableState + ┬─────────────── + ╰─ 🛑 '@ObservableState' cannot be applied to actor type 'Model' + public actor Model { + } + """ + } + } + + func testObservableState_Enum_IfConfig() { + assertMacro { + """ + @ObservableState + public enum State { + case child(ChildFeature.State) + #if os(macOS) + case mac(MacFeature.State) + #elseif os(tvOS) + case tv(TVFeature.State) + #endif + + #if DEBUG + #if INNER + case inner(InnerFeature.State) + #endif + #endif + } + """ + } expansion: { + """ + public enum State { + case child(ChildFeature.State) + #if os(macOS) + case mac(MacFeature.State) + #elseif os(tvOS) + case tv(TVFeature.State) + #endif + + #if DEBUG + #if INNER + case inner(InnerFeature.State) + #endif + #endif + + public var _$id: WorkflowSwiftUI.ObservableStateID { + switch self { + case let .child(state): + return ._$id(for: state)._$tag(0) + #if os(macOS) + case let .mac(state): + return ._$id(for: state)._$tag(1) + #elseif os(tvOS) + case let .tv(state): + return ._$id(for: state)._$tag(2) + #endif + + #if DEBUG + #if INNER + case let .inner(state): + return ._$id(for: state)._$tag(3) + #endif + #endif + + } + } + + public mutating func _$willModify() { + switch self { + case var .child(state): + WorkflowSwiftUI._$willModify(&state) + self = .child(state) + #if os(macOS) + case var .mac(state): + WorkflowSwiftUI._$willModify(&state) + self = .mac(state) + #elseif os(tvOS) + case var .tv(state): + WorkflowSwiftUI._$willModify(&state) + self = .tv(state) + #endif + + #if DEBUG + #if INNER + case var .inner(state): + WorkflowSwiftUI._$willModify(&state) + self = .inner(state) + #endif + #endif + + } + } + } + """ + } + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 000000000..fb496e4ad --- /dev/null +++ b/project.yml @@ -0,0 +1,102 @@ +name: Workflow +options: + bundleIdPrefix: com.squareup.workflow + createIntermediateGroups: true + deploymentTarget: + iOS: 15.0 +packages: + Workflow: + path: "." +targets: + ObservableScreen: + platform: iOS + type: application + sources: + - Samples/ObservableScreen/Sources + dependencies: + - package: Workflow + products: [WorkflowSwiftUI] + info: + path: Samples/ObservableScreen/App/Info.plist + properties: + UILaunchScreen: + UIColorName: "" + + # This is a scheme for all tests except for macros. + Tests-iOS: + platform: iOS + type: bundle.unit-test + info: + path: TestingSupport/AppHost/App/Info.plist + settings: + CODE_SIGN_IDENTITY: "" + scheme: + testTargets: + - package: Workflow/WorkflowCombineTestingTests + - package: Workflow/WorkflowCombineTests + - package: Workflow/WorkflowConcurrencyTestingTests + - package: Workflow/WorkflowConcurrencyTests + - package: Workflow/WorkflowReactiveSwiftTestingTests + - package: Workflow/WorkflowReactiveSwiftTests + - package: Workflow/WorkflowRxSwiftTestingTests + - package: Workflow/WorkflowRxSwiftTests + - package: Workflow/WorkflowSwiftUIExperimentalTests + - package: Workflow/WorkflowSwiftUITests + - package: Workflow/WorkflowTestingTests + - package: Workflow/WorkflowTests + - package: Workflow/WorkflowUITests + + Tests-All: + platform: iOS + type: bundle.unit-test + info: + path: TestingSupport/AppHost/App/Info.plist + settings: + CODE_SIGN_IDENTITY: "" + scheme: + testTargets: + - package: Workflow/WorkflowCombineTestingTests + - package: Workflow/WorkflowCombineTests + - package: Workflow/WorkflowConcurrencyTestingTests + - package: Workflow/WorkflowConcurrencyTests + - package: Workflow/WorkflowReactiveSwiftTestingTests + - package: Workflow/WorkflowReactiveSwiftTests + - package: Workflow/WorkflowRxSwiftTestingTests + - package: Workflow/WorkflowRxSwiftTests + - package: Workflow/WorkflowSwiftUIExperimentalTests + - package: Workflow/WorkflowSwiftUIMacrosTests + - package: Workflow/WorkflowSwiftUITests + - package: Workflow/WorkflowTestingTests + - package: Workflow/WorkflowTests + - package: Workflow/WorkflowUITests + + # to add app-hosted test targets: + + # ViewEnvironmentUI-Tests: + # type: bundle.unit-test + # platform: iOS + # sources: ViewEnvironmentUI/Tests + # settings: + # GENERATE_INFOPLIST_FILE: true + # TEST_HOST: $(BUILT_PRODUCTS_DIR)/ViewEnvironmentUI-TestAppHost.app/ViewEnvironmentUI-TestAppHost + # BUNDLE_LOADER: $(BUILT_PRODUCTS_DIR)/ViewEnvironmentUI-TestAppHost.app/ViewEnvironmentUI-TestAppHost + # dependencies: + # - package: Workflow + # products: [ViewEnvironmentUI] + # - target: ViewEnvironmentUI-TestAppHost + # scheme: + # testTargets: + # - ViewEnvironmentUI-Tests + + # ViewEnvironmentUI-TestAppHost: + # platform: iOS + # type: application + # sources: TestingSupport/AppHost/Sources + # dependencies: + # - package: Workflow + # products: [ViewEnvironmentUI] + # info: + # path: TestingSupport/AppHost/App/Info.plist + # properties: + # UILaunchScreen: + # UIColorName: "" \ No newline at end of file