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 8b1a425d6..0ae993532 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ -// swift-tools-version:5.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