diff --git a/Package.swift b/Package.swift index fcbc840..bab7a19 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/johnpatrickmorgan/FlowStacks", from: "0.3.6"), - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.5.0"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.8.0"), ], targets: [ .target( diff --git a/README.md b/README.md index d1ac0e2..3fd1b6f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _The coordinator pattern in the Composable Architecture_ -`TCACoordinators` brings a flexible approach to navigation in SwiftUI using the [Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture). It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA such as `.forEach`, `ifCaseLet` and `SwitchStore` with [a novel approach to handling navigation in SwiftUI](https://github.com/johnpatrickmorgan/FlowStacks). +`TCACoordinators` brings a flexible approach to navigation in SwiftUI using the [Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture). It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA with [a novel approach to handling navigation in SwiftUI](https://github.com/johnpatrickmorgan/FlowStacks). You might like this library if you want to: @@ -25,33 +25,14 @@ The library works by translating the array of screens into a hierarchy of nested ### Step 1 - Create a screen reducer -First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Both the state and action types will be the sum of the individual screens' state and action types, and the reducer will combine each individual screens' reducers into one: +First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Thanks to the `@Reducer macro`, this can be easily achieved with an enum reducer, e.g. the following (where `Home`, `NumbersList` and `NumberDetail` are the individual screen reducers): ```swift -@Reducer -struct Screen { - enum State: Equatable { - case home(Home.State) - case numbersList(NumbersList.State) - case numberDetail(NumberDetail.State) - } - enum Action { - case home(Home.Action) - case numbersList(NumbersList.Action) - case numberDetail(NumberDetail.Action) - } - - var body: some ReducerOf { - Scope(state: /State.home, action: /Action.home) { - Home() - } - Scope(state: /State.numbersList, action: /Action.numbersList) { - NumbersList() - } - Scope(state: /State.numberDetail, action: /Action.numberDetail) { - NumberDetail() - } - } +@Reducer(state: .equatable) +enum Screen { + case home(Home) + case numbersList(NumbersList) + case numberDetail(NumberDetail) } ``` @@ -62,6 +43,7 @@ The coordinator will manage multiple screens in a navigation flow. Its state sho ```swift @Reducer struct Coordinator { + @ObservableState struct State: Equatable { var routes: [Route] } @@ -108,16 +90,15 @@ struct Coordinator { break } return .none - }.forEachRoute(\.routes, action: \.router) { - Screen() } + .forEachRoute(\.routes, action: \.router) } } ``` ### Step 3 - Create a coordinator view -With that in place, a `CoordinatorView` can be created. It will use a `TCARouter`, which translates the array of routes into a nested list of screen views with invisible `NavigationLinks` and presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, the `TCARouter` takes a closure that can create the view for any screen in the navigation flow. A `SwitchStore` is the natural way to achieve that, with a `CaseLet` for each of the possible screens: +With that in place, a `CoordinatorView` can be created. It will use a `TCARouter`, which translates the array of routes into a nested list of screen views with invisible `NavigationLinks` and presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, the `TCARouter` takes a closure that can create the view for any screen in the navigation flow. A switch statement is the natural way to achieve that, with a case for each of the possible screens: ```swift struct CoordinatorView: View { @@ -125,29 +106,15 @@ struct CoordinatorView: View { var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screen in - SwitchStore(screen) { screen in - switch screen { - case .home: - CaseLet( - /Screen.State.home, - action: Screen.Action.home, - then: HomeView.init - ) - - case .numbersList: - CaseLet( - /Screen.State.numbersList, - action: Screen.Action.numbersList, - then: NumbersListView.init - ) - - case .numberDetail: - CaseLet( - /Screen.State.numberDetail, - action: Screen.Action.numberDetail, - then: NumberDetailView.init - ) - } + switch screen.case { + case let .home(store): + HomeView(store: store) + + case let .numbersList(store): + NumbersListView(store: store) + + case let .numberDetail(store): + NumberDetailView(store: store) } } } @@ -180,7 +147,7 @@ If the user taps the back button, the routes array will be automatically updated ## Cancellation of in-flight effects on dismiss -By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. This would normally require a lot of boilerplate, but can be entirely handled by this library without additional work. To opt out of automatic cancellation, pass `cancellationId: nil` to `forEachRoute`. +By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. To opt out of automatic cancellation, pass `cancellationId: nil` to `forEachRoute`. ## Making complex navigation updates @@ -219,7 +186,8 @@ If the flow of screens needs to change, the change can be made easily in one pla ## How does it work? -This library uses [FlowStacks](https://github.com/johnpatrickmorgan/FlowStacks) for hoisting navigation state out of individual screens. This [blog post](https://johnpatrickmorgan.github.io/2021/07/03/NStack/) explains how that is achieved. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture. +This library uses [FlowStacks](https://github.com/johnpatrickmorgan/FlowStacks) for hoisting navigation state out of individual screens. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture. + ## Migrating from v0.8 and lower diff --git a/Sources/TCACoordinators/Collection+safeSubscript.swift b/Sources/TCACoordinators/Collection+safeSubscript.swift new file mode 100644 index 0000000..5d23536 --- /dev/null +++ b/Sources/TCACoordinators/Collection+safeSubscript.swift @@ -0,0 +1,6 @@ +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift b/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift index 7c1270e..859e2b7 100644 --- a/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift +++ b/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift @@ -68,7 +68,36 @@ public extension Reducer { ) } - /// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in + /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in + /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are + /// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which they + /// originated is dismissed. + /// - Parameters: + /// - routes: A writable keypath for the routes `IdentifiedArray`. + /// - action: A casepath for the router action from this reducer's Action type. + /// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It + /// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + _ routes: WritableKeyPath>>, + action: CaseKeyPath>, + cancellationId: (some Hashable)? + ) -> some ReducerOf + where Action: CasePathable, + ScreenState: CaseReducerState, + ScreenState.StateReducer.Action == ScreenAction, + ScreenAction: CasePathable + { + self.forEachRoute( + routes, + action: action, + cancellationId: cancellationId + ) { + ScreenState.StateReducer.body + } + } + + /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are /// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects /// will be cancelled when the screen from which they originated is dismissed. @@ -99,6 +128,32 @@ public extension Reducer { toLocalAction: action ) } + + /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in + /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are + /// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which they + /// originated is dismissed. + /// - Parameters: + /// - routes: A writable keypath for the routes `IdentifiedArray`. + /// - action: A casepath for the router action from this reducer's Action type. + /// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It + /// will be combined with the screen's identifier. Defaults to the type of the parent reducer. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + _ routes: WritableKeyPath>>, + action: CaseKeyPath>, + cancellationIdType: Any.Type = Self.self + ) -> some ReducerOf + where Action: CasePathable, + ScreenState: CaseReducerState, + ScreenState: Identifiable, + ScreenState.StateReducer.Action == ScreenAction, + ScreenAction: CasePathable + { + self.forEachRoute(routes, action: action, cancellationIdType: cancellationIdType) { + ScreenState.StateReducer.body + } + } } extension Case { diff --git a/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift b/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift index d7ce79e..9c921e7 100644 --- a/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift +++ b/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift @@ -66,6 +66,35 @@ public extension Reducer { ) } + /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in + /// the coordinator's routes array will have its actions and state propagated. When screens are + /// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects + /// will be cancelled when the screen from which they originated is dismissed. + /// - Parameters: + /// - routes: A writable keypath for the routes array. + /// - action: A casepath for the router action from this reducer's Action type. + /// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It + /// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + _ routes: WritableKeyPath]>, + action: CaseKeyPath>, + cancellationId: (some Hashable)? + ) -> some ReducerOf + where Action: CasePathable, + ScreenState: CaseReducerState, + ScreenState.StateReducer.Action == ScreenAction, + ScreenAction: CasePathable + { + self.forEachRoute( + routes, + action: action, + cancellationId: cancellationId + ) { + ScreenState.StateReducer.body + } + } + /// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in /// the coordinator's routes Array will have its actions and state propagated. When screens are /// dismissed, the routes will be updated. In-flight effects @@ -96,4 +125,29 @@ public extension Reducer { toLocalAction: action ) } + + /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in + /// the coordinator's routes Array will have its actions and state propagated. When screens are + /// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which + /// they originated is dismissed. + /// - Parameters: + /// - routes: A writable keypath for the routes array. + /// - action: A casepath for the router action from this reducer's Action type. + /// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It + /// will be combined with the screen's identifier. Defaults to the type of the parent reducer. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + _ routes: WritableKeyPath]>, + action: CaseKeyPath>, + cancellationIdType: Any.Type = Self.self + ) -> some ReducerOf + where Action: CasePathable, + ScreenState: CaseReducerState, + ScreenState.StateReducer.Action == ScreenAction, + ScreenAction: CasePathable + { + self.forEachRoute(routes, action: action, cancellationIdType: cancellationIdType) { + ScreenState.StateReducer.body + } + } } diff --git a/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift b/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift index bc094ce..4304757 100644 --- a/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift +++ b/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift @@ -7,7 +7,7 @@ public extension TCARouter where Screen: Identifiable { /// Convenience initializer for managing screens in an `IdentifiedArray`. init( _ store: Store>, IdentifiedRouterAction>, - screenContent: @escaping (Store) -> ScreenContent + @ViewBuilder screenContent: @escaping (Store) -> ScreenContent ) where Screen.ID == ID { self.init( store: store.scope(state: \.elements, action: \.self), diff --git a/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift b/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift index 36154f7..9656e7e 100644 --- a/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift +++ b/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift @@ -7,7 +7,7 @@ public extension TCARouter where ID == Int { /// Convenience initializer for managing screens in an `Array`, identified by index. init( _ store: Store<[Route], IndexedRouterAction>, - screenContent: @escaping (Store) -> ScreenContent + @ViewBuilder screenContent: @escaping (Store) -> ScreenContent ) { self.init( store: store, diff --git a/Sources/TCACoordinators/TCARouter/TCARouter.swift b/Sources/TCACoordinators/TCARouter/TCARouter.swift index 47ecfec..ea665bc 100644 --- a/Sources/TCACoordinators/TCARouter/TCARouter.swift +++ b/Sources/TCACoordinators/TCARouter/TCARouter.swift @@ -1,16 +1,16 @@ @_spi(Internals) import ComposableArchitecture import FlowStacks -import Foundation import SwiftUI -/// TCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented. The TCARouter translates that collection into a hierarchy of SwiftUI views, and ensures that `updateScreens`. +/// TCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented. +/// The TCARouter translates that collection into a hierarchy of SwiftUI views, and updates it when the user navigates back. public struct TCARouter< Screen: Equatable, ScreenAction, ID: Hashable, ScreenContent: View >: View { - let store: Store<[Route], RouterAction> + @Perception.Bindable private var store: Store<[Route], RouterAction> let identifier: (Screen, Int) -> ID let screenContent: (Store) -> ScreenContent @@ -41,24 +41,30 @@ public struct TCARouter< } public var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - Router( - viewStore - .binding( - get: { $0 }, - send: RouterAction.updateRoutes - ), - buildView: { screen, index in - screenContent(scopedStore(index: index, screen: screen)) - } - ) + if Screen.self is ObservableState.Type { + WithPerceptionTracking { + Router( + $store[], + buildView: { screen, index in + WithPerceptionTracking { + screenContent(scopedStore(index: index, screen: screen)) + } + } + ) + } + } else { + UnobservedTCARouter(store: store, identifier: identifier, screenContent: screenContent) } } } -extension Collection { - /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript(safe index: Index) -> Element? { - indices.contains(index) ? self[index] : nil +private extension Store { + subscript() -> [Route] + where State == [Route], Action == RouterAction + { + get { currentState } + set { + send(.updateRoutes(newValue)) + } } } diff --git a/Sources/TCACoordinators/TCARouter/UnobservedTCARouter.swift b/Sources/TCACoordinators/TCARouter/UnobservedTCARouter.swift new file mode 100644 index 0000000..4a5e100 --- /dev/null +++ b/Sources/TCACoordinators/TCARouter/UnobservedTCARouter.swift @@ -0,0 +1,59 @@ +@_spi(Internals) import ComposableArchitecture +import FlowStacks +import Foundation +import SwiftUI + +/// UnobservedTCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented. +/// The TCARouter translates that collection into a hierarchy of SwiftUI views, and updates it when the user navigates back. +/// The unobserved router is used when the Screen does not conform to ObservableState. +struct UnobservedTCARouter< + Screen: Equatable, + ScreenAction, + ID: Hashable, + ScreenContent: View +>: View { + let store: Store<[Route], RouterAction> + let identifier: (Screen, Int) -> ID + let screenContent: (Store) -> ScreenContent + + init( + store: Store<[Route], RouterAction>, + identifier: @escaping (Screen, Int) -> ID, + @ViewBuilder screenContent: @escaping (Store) -> ScreenContent + ) { + self.store = store + self.identifier = identifier + self.screenContent = screenContent + } + + func scopedStore(index: Int, screen: Screen) -> Store { + var screen = screen + let id = identifier(screen, index) + return store.scope( + id: store.id(state: \.[index], action: \.[id: id]), + state: ToState { + screen = $0[safe: index]?.screen ?? screen + return screen + }, + action: { + .routeAction(id: id, action: $0) + }, + isInvalid: { !$0.indices.contains(index) } + ) + } + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + Router( + viewStore + .binding( + get: { $0 }, + send: RouterAction.updateRoutes + ), + buildView: { screen, index in + screenContent(scopedStore(index: index, screen: screen)) + } + ) + } + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj index 1989695..1c20c71 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj +++ b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 5248864B26F2A26500970899 /* TCACoordinatorsExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248864A26F2A26500970899 /* TCACoordinatorsExampleUITests.swift */; }; 5248864D26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248864C26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift */; }; 5248866026F2A53B00970899 /* TCACoordinators in Frameworks */ = {isa = PBXBuildFile; productRef = 5248865F26F2A53B00970899 /* TCACoordinators */; }; + 528AE0E92BECE20F00E143C5 /* FormScreen+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */; }; + 528AE0EB2BED197000E143C5 /* OutcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528AE0EA2BED197000E143C5 /* OutcomeView.swift */; }; 528FEDEB2880BD94007765AD /* Step3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDEA2880BC61007765AD /* Step3.swift */; }; 528FEDEC2880BD94007765AD /* Step1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE82880BC61007765AD /* Step1.swift */; }; 528FEDED2880BD94007765AD /* FinalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE72880BC60007765AD /* FinalScreen.swift */; }; @@ -38,6 +40,9 @@ 529822D5283D76AD0011112B /* GameCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D1283D76AD0011112B /* GameCoordinator.swift */; }; 529822D7283D76F60011112B /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D6283D76F60011112B /* WelcomeView.swift */; }; 529822D9283D77060011112B /* LogInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D8283D77060011112B /* LogInView.swift */; }; + 912FC7232BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7222BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift */; }; + 912FC7252BEBAFAA0036B444 /* FormScreen+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */; }; + 912FC7272BEBB9080036B444 /* GameViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7262BEBB9080036B444 /* GameViewState.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -72,6 +77,7 @@ 5248864A26F2A26500970899 /* TCACoordinatorsExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCACoordinatorsExampleUITests.swift; sourceTree = ""; }; 5248864C26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCACoordinatorsExampleUITestsLaunchTests.swift; sourceTree = ""; }; 5248865A26F2A2C200970899 /* TCACoordinators */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TCACoordinators; path = ..; sourceTree = ""; }; + 528AE0EA2BED197000E143C5 /* OutcomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutcomeView.swift; sourceTree = ""; }; 528FEDE42880BC60007765AD /* FormScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormScreen.swift; sourceTree = ""; }; 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormAppCoordinator.swift; sourceTree = ""; }; 528FEDE72880BC60007765AD /* FinalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalScreen.swift; sourceTree = ""; }; @@ -84,6 +90,9 @@ 529822D1283D76AD0011112B /* GameCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameCoordinator.swift; sourceTree = ""; }; 529822D6283D76F60011112B /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 529822D8283D77060011112B /* LogInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogInView.swift; sourceTree = ""; }; + 912FC7222BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LogInScreen+StateIdentifiable.swift"; sourceTree = ""; }; + 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormScreen+Identifiable.swift"; sourceTree = ""; }; + 912FC7262BEBB9080036B444 /* GameViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewState.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -195,6 +204,7 @@ children = ( 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */, 528FEDE42880BC60007765AD /* FormScreen.swift */, + 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */, 528FEDE72880BC60007765AD /* FinalScreen.swift */, 528FEDE82880BC61007765AD /* Step1.swift */, 528FEDE92880BC61007765AD /* Step2.swift */, @@ -207,10 +217,13 @@ isa = PBXGroup; children = ( 529822CE283D76AD0011112B /* GameView.swift */, + 912FC7262BEBB9080036B444 /* GameViewState.swift */, 529822CF283D76AD0011112B /* AppCoordinator.swift */, 529822D0283D76AD0011112B /* LogInCoordinator.swift */, + 912FC7222BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift */, 529822D1283D76AD0011112B /* GameCoordinator.swift */, 529822D6283D76F60011112B /* WelcomeView.swift */, + 528AE0EA2BED197000E143C5 /* OutcomeView.swift */, 529822D8283D77060011112B /* LogInView.swift */, ); path = Game; @@ -358,13 +371,17 @@ 528FEDF42880BD95007765AD /* FinalScreen.swift in Sources */, 529822D7283D76F60011112B /* WelcomeView.swift in Sources */, 528FEDF52880BD95007765AD /* FormScreen.swift in Sources */, + 912FC7252BEBAFAA0036B444 /* FormScreen+Identifiable.swift in Sources */, + 912FC7232BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift in Sources */, 524087E5278E3D950048C6EE /* Screen.swift in Sources */, 528FEDF22880BD95007765AD /* Step3.swift in Sources */, 529822D3283D76AD0011112B /* AppCoordinator.swift in Sources */, 524087E6278E3D950048C6EE /* IdentifiedCoordinator.swift in Sources */, + 528AE0EB2BED197000E143C5 /* OutcomeView.swift in Sources */, 528FEDF62880BD95007765AD /* FormAppCoordinator.swift in Sources */, 529822D2283D76AD0011112B /* GameView.swift in Sources */, 528FEDF32880BD95007765AD /* Step1.swift in Sources */, + 912FC7272BEBB9080036B444 /* GameViewState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -377,6 +394,7 @@ 5248864126F2A26500970899 /* TCACoordinatorsExampleTests.swift in Sources */, 528FEDED2880BD94007765AD /* FinalScreen.swift in Sources */, 524087E7278E3D9F0048C6EE /* IdentifiedCoordinator.swift in Sources */, + 528AE0E92BECE20F00E143C5 /* FormScreen+Identifiable.swift in Sources */, 528FEDF12880BD94007765AD /* Step2.swift in Sources */, 524087E9278E3D9F0048C6EE /* Screen.swift in Sources */, 528FEDEF2880BD94007765AD /* FormAppCoordinator.swift in Sources */, diff --git a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a48c270..79833af 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -78,8 +78,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-dependencies", "state": { "branch": null, - "revision": "63301f4a181ed9aefb46dccef2dfb66466798341", - "version": "1.1.1" + "revision": "d3a5af3038a09add4d7682f66555d6212058a3c0", + "version": "1.2.2" } }, { diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift index 54c76fa..adb66fd 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift @@ -5,40 +5,39 @@ struct FinalScreenView: View { let store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { Form { Section { Button { - viewStore.send(.returnToName) + store.send(.returnToName) } label: { LabelledRow("First name") { - Text(viewStore.firstName) - }.foregroundColor(viewStore.firstName.isEmpty ? .red : .black) + Text(store.firstName) + }.foregroundColor(store.firstName.isEmpty ? .red : .black) } Button { - viewStore.send(.returnToName) + store.send(.returnToName) } label: { LabelledRow("Last Name") { - Text(viewStore.lastName) - }.foregroundColor(viewStore.lastName.isEmpty ? .red : .black) + Text(store.lastName) + }.foregroundColor(store.lastName.isEmpty ? .red : .black) } Button { - viewStore.send(.returnToDateOfBirth) + store.send(.returnToDateOfBirth) } label: { LabelledRow("Date of Birth") { - Text(viewStore.dateOfBirth, format: .dateTime.day().month().year()) + Text(store.dateOfBirth, format: .dateTime.day().month().year()) } } Button { - viewStore.send(.returnToJob) + store.send(.returnToJob) } label: { LabelledRow("Job") { - Text(viewStore.job ?? "-") - - }.foregroundColor((viewStore.job?.isEmpty ?? true) ? .red : .black) + Text(store.job ?? "-") + }.foregroundColor((store.job?.isEmpty ?? true) ? .red : .black) } } header: { Text("Confirm Your Info") @@ -46,43 +45,43 @@ struct FinalScreenView: View { .buttonStyle(.plain) Button("Submit") { - viewStore.send(.submit) - }.disabled(viewStore.isIncomplete) + store.send(.submit) + }.disabled(store.isIncomplete) } .navigationTitle("Submit") - .disabled(viewStore.submissionInFlight) + .disabled(store.submissionInFlight) .overlay { - if viewStore.submissionInFlight { + if store.submissionInFlight { Text("Submitting") .padding() .background(.thinMaterial) .cornerRadius(8) } } - .animation(.spring(), value: viewStore.submissionInFlight) + .animation(.spring(), value: store.submissionInFlight) } } } struct LabelledRow: View { let label: String - @ViewBuilder var content: () -> Content + let content: Content init( _ label: String, - @ViewBuilder content: @escaping () -> Content + @ViewBuilder content: () -> Content ) { self.label = label - self.content = content + self.content = content() } var body: some View { HStack { Text(label) Spacer() - content() + content } - .contentShape(Rectangle()) + .contentShape(.rect) } } @@ -93,7 +92,9 @@ struct APIModel: Codable, Equatable { let job: String } -struct FinalScreen: Reducer { +@Reducer +struct FinalScreen { + @ObservableState struct State: Equatable { let firstName: String let lastName: String @@ -116,7 +117,7 @@ struct FinalScreen: Reducer { } @Dependency(\.mainQueue) var mainQueue - let submit: (APIModel) async -> Bool + @Dependency(FormScreenEnvironment.self) var environment var body: some ReducerOf { Reduce { state, action in @@ -134,7 +135,7 @@ struct FinalScreen: Reducer { return .run { send in try await mainQueue.sleep(for: .seconds(0.8)) - await send(.receiveAPIResponse(submit(apiModel))) + await send(.receiveAPIResponse(environment.submit(apiModel))) } case .receiveAPIResponse: diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift index 54060dc..63d1ac6 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift @@ -4,6 +4,7 @@ import TCACoordinators @Reducer struct FormAppCoordinator { + @ObservableState struct State: Equatable { static let initialState = Self(routeIDs: [.root(.step1, embedInNavigationView: true)]) @@ -104,9 +105,7 @@ struct FormAppCoordinator { return .none } } - .forEachRoute(\.routes, action: \.router) { - FormScreen(environment: .test) - } + .forEachRoute(\.routes, action: \.router) } } @@ -115,20 +114,18 @@ struct FormAppCoordinatorView: View { var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screen in - SwitchStore(screen) { screen in - switch screen { - case .step1: - CaseLet(\FormScreen.State.step1, action: FormScreen.Action.step1, then: Step1View.init(store:)) + switch screen.case { + case let .step1(store): + Step1View(store: store) - case .step2: - CaseLet(\FormScreen.State.step2, action: FormScreen.Action.step2, then: Step2View.init(store:)) + case let .step2(store): + Step2View(store: store) - case .step3: - CaseLet(\FormScreen.State.step3, action: FormScreen.Action.step3, then: Step3View.init(store:)) + case let .step3(store): + Step3View(store: store) - case .finalScreen: - CaseLet(\FormScreen.State.finalScreen, action: FormScreen.Action.finalScreen, then: FinalScreenView.init(store:)) - } + case let .finalScreen(store): + FinalScreenView(store: store) } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen+Identifiable.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen+Identifiable.swift new file mode 100644 index 0000000..4790d9a --- /dev/null +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen+Identifiable.swift @@ -0,0 +1,23 @@ +extension FormScreen.State: Identifiable { + var id: ID { + switch self { + case .step1: + return .step1 + case .step2: + return .step2 + case .step3: + return .step3 + case .finalScreen: + return .finalScreen + } + } + + enum ID: Identifiable { + case step1 + case step2 + case step3 + case finalScreen + + var id: ID { self } + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift index e0c03d7..e5a4eab 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift @@ -1,11 +1,12 @@ import ComposableArchitecture import Foundation -struct FormScreenEnvironment { - let getOccupations: () async -> [String] - let submit: (APIModel) async -> Bool +@DependencyClient +struct FormScreenEnvironment: DependencyKey { + var getOccupations: () async -> [String] = { [] } + var submit: (APIModel) async -> Bool = { _ in false } - static let test = FormScreenEnvironment( + static let liveValue = FormScreenEnvironment( getOccupations: { [ "iOS Developer", @@ -20,60 +21,10 @@ struct FormScreenEnvironment { ) } -@Reducer -struct FormScreen: Reducer { - let environment: FormScreenEnvironment - - enum State: Equatable, Identifiable { - case step1(Step1.State) - case step2(Step2.State) - case step3(Step3.State) - case finalScreen(FinalScreen.State) - - var id: ID { - switch self { - case .step1: - return .step1 - case .step2: - return .step2 - case .step3: - return .step3 - case .finalScreen: - return .finalScreen - } - } - - enum ID: Identifiable { - case step1 - case step2 - case step3 - case finalScreen - - var id: ID { - self - } - } - } - - enum Action: Equatable { - case step1(Step1.Action) - case step2(Step2.Action) - case step3(Step3.Action) - case finalScreen(FinalScreen.Action) - } - - var body: some ReducerOf { - Scope(state: /State.step1, action: /Action.step1) { - Step1() - } - Scope(state: /State.step2, action: /Action.step2) { - Step2() - } - Scope(state: /State.step3, action: /Action.step3) { - Step3(getOccupations: environment.getOccupations) - } - Scope(state: /State.finalScreen, action: /Action.finalScreen) { - FinalScreen(submit: environment.submit) - } - } +@Reducer(state: .equatable) +enum FormScreen { + case step1(Step1) + case step2(Step2) + case step3(Step3) + case finalScreen(FinalScreen) } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift index 7bf9bf8..83f28c4 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import SwiftUI -struct Step1: Reducer { +@Reducer +struct Step1 { + @ObservableState public struct State: Equatable { - @BindingState var firstName: String = "" - @BindingState var lastName: String = "" + var firstName: String = "" + var lastName: String = "" } public enum Action: Equatable, BindableAction { @@ -18,17 +20,17 @@ struct Step1: Reducer { } struct Step1View: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { Form { - TextField("First Name", text: viewStore.$firstName) - TextField("Last Name", text: viewStore.$lastName) + TextField("First Name", text: $store.firstName) + TextField("Last Name", text: $store.lastName) Section { Button("Next") { - viewStore.send(.nextButtonTapped) + store.send(.nextButtonTapped) } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift index 5456a4f..394a384 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift @@ -2,15 +2,15 @@ import ComposableArchitecture import SwiftUI struct Step2View: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { Form { Section { DatePicker( "Date of Birth", - selection: viewStore.$dateOfBirth, + selection: $store.dateOfBirth, in: ...Date.now, displayedComponents: .date ) @@ -20,7 +20,7 @@ struct Step2View: View { } Button("Next") { - viewStore.send(.nextButtonTapped) + store.send(.nextButtonTapped) } } .navigationTitle("Step 2") @@ -28,9 +28,11 @@ struct Step2View: View { } } -struct Step2: Reducer { +@Reducer +struct Step2 { + @ObservableState public struct State: Equatable { - @BindingState var dateOfBirth: Date = .now + var dateOfBirth: Date = .now } public enum Action: Equatable, BindableAction { diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift index 5bc4630..6cac081 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift @@ -5,21 +5,23 @@ struct Step3View: View { let store: StoreOf var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithPerceptionTracking { Form { Section { - if !viewStore.occupations.isEmpty { - List(viewStore.occupations, id: \.self) { occupation in + if !store.occupations.isEmpty { + List(store.occupations, id: \.self) { occupation in Button { - viewStore.send(.selectOccupation(occupation)) + store.send(.selectOccupation(occupation)) } label: { HStack { - Text(occupation) - - Spacer() - - if let selected = viewStore.selectedOccupation, selected == occupation { - Image(systemName: "checkmark") + WithPerceptionTracking { + Text(occupation) + + Spacer() + + if let selected = store.selectedOccupation, selected == occupation { + Image(systemName: "checkmark") + } } } } @@ -34,18 +36,20 @@ struct Step3View: View { } Button("Next") { - viewStore.send(.nextButtonTapped) + store.send(.nextButtonTapped) } } .onAppear { - viewStore.send(.getOccupations) + store.send(.getOccupations) } .navigationTitle("Step 3") } } } -struct Step3: Reducer { +@Reducer +struct Step3 { + @ObservableState struct State: Equatable { var selectedOccupation: String? var occupations: [String] = [] @@ -58,14 +62,14 @@ struct Step3: Reducer { case nextButtonTapped } - let getOccupations: () async -> [String] + @Dependency(FormScreenEnvironment.self) var environment var body: some ReducerOf { Reduce { state, action in switch action { case .getOccupations: return .run { send in - await send(.receiveOccupations(getOccupations())) + await send(.receiveOccupations(environment.getOccupations())) } case let .receiveOccupations(occupations): diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift index 2580441..fba15ac 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift @@ -8,9 +8,9 @@ struct AppCoordinatorView: View { let store: StoreOf var body: some View { - WithViewStore(store, observe: \.isLoggedIn) { viewStore in + WithPerceptionTracking { VStack { - if viewStore.state { + if store.isLoggedIn { GameCoordinatorView(store: store.scope(state: \.game, action: \.game)) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } else { @@ -18,13 +18,14 @@ struct AppCoordinatorView: View { .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } } - .animation(.default, value: viewStore.state) + .animation(.default, value: store.isLoggedIn) } } } @Reducer -struct GameApp: Reducer { +struct GameApp { + @ObservableState struct State: Equatable { static let initialState = State(logIn: .initialState, game: .initialState(), isLoggedIn: false) diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift index 7e2f3c0..6c58100 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift @@ -7,42 +7,20 @@ struct GameCoordinatorView: View { var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screen in - SwitchStore(screen) { screen in - switch screen { - case .game: - CaseLet( - \GameScreen.State.game, - action: GameScreen.Action.game, - then: GameView.init - ) - } + switch screen.case { + case let .game(store): + GameView(store: store) + case let .outcome(store): + OutcomeView(store: store) } } } } -@Reducer -struct GameScreen { - enum State: Equatable, Identifiable { - case game(Game.State) - - var id: UUID { - switch self { - case let .game(state): - return state.id - } - } - } - - enum Action { - case game(Game.Action) - } - - var body: some ReducerOf { - Scope(state: \.game, action: \.game) { - Game() - } - } +@Reducer(state: .equatable) +enum GameScreen { + case game(Game) + case outcome(Outcome) } @Reducer @@ -62,9 +40,18 @@ struct GameCoordinator { } var body: some ReducerOf { - EmptyReducer() - .forEachRoute(\.routes, action: \.router) { - GameScreen() + Reduce { state, action in + guard case let .game(game) = state.routes.first?.screen else { return .none } + switch action { + case .router(.routeAction(id: _, action: .outcome(.newGameTapped))): + state.routes = [.root(.game(.init(oPlayerName: game.xPlayerName, xPlayerName: game.oPlayerName)), embedInNavigationView: true)] + case .router(.routeAction(id: _, action: .game(.gameCompleted(let winner)))): + state.routes.push(.outcome(.init(winner: winner, oPlayerName: game.oPlayerName, xPlayerName: game.xPlayerName))) + default: + break } + return .none + } + .forEachRoute(\.routes, action: \.router) } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift index bda4850..6719d52 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift @@ -10,94 +10,69 @@ struct GameView: UIViewControllerRepresentable { typealias UIViewControllerType = GameViewController - func makeUIViewController(context: Context) -> GameViewController { - GameViewController(store: self.store) + func makeUIViewController(context _: Context) -> GameViewController { + GameViewController(store: store) } - func updateUIViewController(_ uiViewController: GameViewController, context: Context) {} + func updateUIViewController(_: GameViewController, context _: Context) {} } final class GameViewController: UIViewController { let store: StoreOf - let viewStore: ViewStore - private var cancellables: Set = [] - - struct ViewState: Equatable { - let board: Three> - let isGameEnabled: Bool - let isPlayAgainButtonHidden: Bool - let title: String? - - init(state: Game.State) { - self.board = state.board.map { $0.map { $0?.label ?? "" } } - self.isGameEnabled = !state.board.hasWinner && !state.board.isFilled - self.isPlayAgainButtonHidden = !state.board.hasWinner && !state.board.isFilled - self.title = - state.board.hasWinner - ? "Winner! Congrats \(state.currentPlayerName)!" - : state.board.isFilled - ? "Tied game!" - : "\(state.currentPlayerName), place your \(state.currentPlayer.label)" - } - } + private var observationToken: ObservationToken? init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() - self.navigationItem.title = "Tic-Tac-Toe" - self.view.backgroundColor = .systemBackground + navigationItem.title = "Tic-Tac-Toe" + view.backgroundColor = .systemBackground - self.navigationItem.leftBarButtonItem = UIBarButtonItem( + navigationItem.leftBarButtonItem = UIBarButtonItem( title: "Quit", style: .done, target: self, - action: #selector(self.quitButtonTapped) + action: #selector(quitButtonTapped) ) let titleLabel = UILabel() titleLabel.textAlignment = .center - let playAgainButton = UIButton(type: .system) - playAgainButton.setTitle("Play again?", for: .normal) - playAgainButton.addTarget(self, action: #selector(self.playAgainButtonTapped), for: .touchUpInside) - let logOutButton = UIButton(type: .system) logOutButton.setTitle("Log out", for: .normal) - logOutButton.addTarget(self, action: #selector(self.logOutButtonTapped), for: .touchUpInside) + logOutButton.addTarget(self, action: #selector(logOutButtonTapped), for: .touchUpInside) - let titleStackView = UIStackView(arrangedSubviews: [titleLabel, playAgainButton, logOutButton]) + let titleStackView = UIStackView(arrangedSubviews: [titleLabel, logOutButton]) titleStackView.axis = .vertical titleStackView.spacing = 2 let gridCell11 = UIButton() - gridCell11.addTarget(self, action: #selector(self.gridCell11Tapped), for: .touchUpInside) + gridCell11.addTarget(self, action: #selector(gridCell11Tapped), for: .touchUpInside) let gridCell21 = UIButton() - gridCell21.addTarget(self, action: #selector(self.gridCell21Tapped), for: .touchUpInside) + gridCell21.addTarget(self, action: #selector(gridCell21Tapped), for: .touchUpInside) let gridCell31 = UIButton() - gridCell31.addTarget(self, action: #selector(self.gridCell31Tapped), for: .touchUpInside) + gridCell31.addTarget(self, action: #selector(gridCell31Tapped), for: .touchUpInside) let gridCell12 = UIButton() - gridCell12.addTarget(self, action: #selector(self.gridCell12Tapped), for: .touchUpInside) + gridCell12.addTarget(self, action: #selector(gridCell12Tapped), for: .touchUpInside) let gridCell22 = UIButton() - gridCell22.addTarget(self, action: #selector(self.gridCell22Tapped), for: .touchUpInside) + gridCell22.addTarget(self, action: #selector(gridCell22Tapped), for: .touchUpInside) let gridCell32 = UIButton() - gridCell32.addTarget(self, action: #selector(self.gridCell32Tapped), for: .touchUpInside) + gridCell32.addTarget(self, action: #selector(gridCell32Tapped), for: .touchUpInside) let gridCell13 = UIButton() - gridCell13.addTarget(self, action: #selector(self.gridCell13Tapped), for: .touchUpInside) + gridCell13.addTarget(self, action: #selector(gridCell13Tapped), for: .touchUpInside) let gridCell23 = UIButton() - gridCell23.addTarget(self, action: #selector(self.gridCell23Tapped), for: .touchUpInside) + gridCell23.addTarget(self, action: #selector(gridCell23Tapped), for: .touchUpInside) let gridCell33 = UIButton() - gridCell33.addTarget(self, action: #selector(self.gridCell33Tapped), for: .touchUpInside) + gridCell33.addTarget(self, action: #selector(gridCell33Tapped), for: .touchUpInside) let cells = [ [gridCell11, gridCell12, gridCell13], @@ -130,12 +105,12 @@ final class GameViewController: UIViewController { rootStackView.axis = .vertical rootStackView.spacing = 100 - self.view.addSubview(rootStackView) + view.addSubview(rootStackView) NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) gameStackView.arrangedSubviews @@ -148,57 +123,47 @@ final class GameViewController: UIViewController { ]) } - self.viewStore.publisher.title - .assign(to: \.text, on: titleLabel) - .store(in: &self.cancellables) - - self.viewStore.publisher.isPlayAgainButtonHidden - .assign(to: \.isHidden, on: playAgainButton) - .store(in: &self.cancellables) - - self.viewStore.publisher.isPlayAgainButtonHidden - .assign(to: \.isHidden, on: logOutButton) - .store(in: &self.cancellables) - - self.viewStore.publisher - .map(\.board, \.isGameEnabled) - .removeDuplicates(by: ==) - .sink { board, isGameEnabled in - for (rowIdx, row) in board.enumerated() { - for (colIdx, label) in row.enumerated() { - let button = cells[rowIdx][colIdx] - button.setTitle(label, for: .normal) - button.isEnabled = isGameEnabled - } + observationToken = observe { [weak self] in + guard let self else { return } + + titleLabel.text = store.title + + for (rowIdx, row) in store.gameBoard.enumerated() { + for (colIdx, label) in row.enumerated() { + let button = cells[rowIdx][colIdx] + button.setTitle(label, for: .normal) + button.isEnabled = store.isGameEnabled } } - .store(in: &self.cancellables) + } } - @objc private func gridCell11Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 0)) } - @objc private func gridCell12Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 1)) } - @objc private func gridCell13Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 2)) } - @objc private func gridCell21Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 0)) } - @objc private func gridCell22Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 1)) } - @objc private func gridCell23Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 2)) } - @objc private func gridCell31Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 0)) } - @objc private func gridCell32Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 1)) } - @objc private func gridCell33Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 2)) } + @objc private func gridCell11Tapped() { store.send(.cellTapped(row: 0, column: 0)) } + @objc private func gridCell12Tapped() { store.send(.cellTapped(row: 0, column: 1)) } + @objc private func gridCell13Tapped() { store.send(.cellTapped(row: 0, column: 2)) } + @objc private func gridCell21Tapped() { store.send(.cellTapped(row: 1, column: 0)) } + @objc private func gridCell22Tapped() { store.send(.cellTapped(row: 1, column: 1)) } + @objc private func gridCell23Tapped() { store.send(.cellTapped(row: 1, column: 2)) } + @objc private func gridCell31Tapped() { store.send(.cellTapped(row: 2, column: 0)) } + @objc private func gridCell32Tapped() { store.send(.cellTapped(row: 2, column: 1)) } + @objc private func gridCell33Tapped() { store.send(.cellTapped(row: 2, column: 2)) } @objc private func quitButtonTapped() { - self.viewStore.send(.quitButtonTapped) + store.send(.quitButtonTapped) } @objc private func playAgainButtonTapped() { - self.viewStore.send(.playAgainButtonTapped) + store.send(.playAgainButtonTapped) } @objc private func logOutButtonTapped() { - self.viewStore.send(.logOutButtonTapped) + store.send(.logOutButtonTapped) } } -struct Game: Reducer { +@Reducer +struct Game { + @ObservableState struct State: Equatable { let id = UUID() var board: Three> = .empty @@ -212,9 +177,9 @@ struct Game: Reducer { } var currentPlayerName: String { - switch self.currentPlayer { - case .o: return self.oPlayerName - case .x: return self.xPlayerName + switch currentPlayer { + case .o: return oPlayerName + case .x: return xPlayerName } } } @@ -224,6 +189,7 @@ struct Game: Reducer { case playAgainButtonTapped case logOutButtonTapped case quitButtonTapped + case gameCompleted(winner: Player?) } var body: some ReducerOf { @@ -241,13 +207,19 @@ struct Game: Reducer { state.currentPlayer.toggle() } + if state.board.hasWinner || state.board.isFilled { + return .run { [winner = state.board.winner] send in + await send(.gameCompleted(winner: winner)) + } + } + return .none case .playAgainButtonTapped: state = Game.State(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName) return .none - case .quitButtonTapped, .logOutButtonTapped: + case .quitButtonTapped, .logOutButtonTapped, .gameCompleted: return .none } } @@ -267,11 +239,11 @@ struct Three: CustomStringConvertible { } func map(_ transform: (Element) -> T) -> Three { - .init(transform(self.first), transform(self.second), transform(self.third)) + .init(transform(first), transform(second), transform(third)) } var description: String { - "[\(self.first),\(self.second),\(self.third)]" + "[\(first),\(second),\(third)]" } } @@ -332,11 +304,15 @@ extension Three where Element == Three { ) var isFilled: Bool { - self.allSatisfy { $0.allSatisfy { $0 != nil } } + allSatisfy { $0.allSatisfy { $0 != nil } } + } + + var winner: Player? { + if hasWin(.o) { .o } else if hasWin(.x) { .x } else { nil } } var hasWinner: Bool { - self.hasWin(.o) || self.hasWin(.x) + hasWin(.o) || hasWin(.x) } func hasWin(_ player: Player) -> Bool { diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameViewState.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameViewState.swift new file mode 100644 index 0000000..65a5ab6 --- /dev/null +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameViewState.swift @@ -0,0 +1,13 @@ +extension Game.State { + var gameBoard: Three> { + board.map { $0.map { $0?.label ?? "" } } + } + + var isGameEnabled: Bool { + !board.hasWinner && !board.isFilled + } + + var title: String { + "\(currentPlayerName), place your \(currentPlayer.label)" + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift index a13daf3..dc8596c 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift @@ -2,35 +2,10 @@ import ComposableArchitecture import SwiftUI import TCACoordinators -@Reducer -struct LogInScreen { - enum Action { - case welcome(Welcome.Action) - case logIn(LogIn.Action) - } - - enum State: Equatable, Identifiable { - case welcome(Welcome.State) - case logIn(LogIn.State) - - var id: UUID { - switch self { - case let .welcome(state): - state.id - case let .logIn(state): - state.id - } - } - } - - var body: some ReducerOf { - Scope(state: \.welcome, action: \.welcome) { - Welcome() - } - Scope(state: \.logIn, action: \.logIn) { - LogIn() - } - } +@Reducer(state: .equatable) +enum LogInScreen { + case welcome(Welcome) + case logIn(LogIn) } struct LogInCoordinatorView: View { @@ -38,29 +13,20 @@ struct LogInCoordinatorView: View { var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screen in - SwitchStore(screen) { screen in - switch screen { - case .welcome: - CaseLet( - \LogInScreen.State.welcome, - action: LogInScreen.Action.welcome, - then: WelcomeView.init - ) + switch screen.case { + case let .welcome(store): + WelcomeView(store: store) - case .logIn: - CaseLet( - \LogInScreen.State.logIn, - action: LogInScreen.Action.logIn, - then: LogInView.init - ) - } + case let .logIn(store): + LogInView(store: store) } } } } @Reducer -struct LogInCoordinator: Reducer { +struct LogInCoordinator { + @ObservableState struct State: Equatable { static let initialState = LogInCoordinator.State( routes: [.root(.welcome(.init()), embedInNavigationView: true)] @@ -83,8 +49,6 @@ struct LogInCoordinator: Reducer { } return .none } - .forEachRoute(\.routes, action: \.router) { - LogInScreen() - } + .forEachRoute(\.routes, action: \.router) } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInScreen+StateIdentifiable.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInScreen+StateIdentifiable.swift new file mode 100644 index 0000000..143e4a6 --- /dev/null +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInScreen+StateIdentifiable.swift @@ -0,0 +1,12 @@ +import Foundation + +extension LogInScreen.State: Identifiable { + var id: UUID { + switch self { + case let .welcome(state): + state.id + case let .logIn(state): + state.id + } + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift index bb6fd6c..3d002b9 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift @@ -20,7 +20,8 @@ struct LogInView: View { } } -struct LogIn: Reducer { +@Reducer +struct LogIn { struct State: Equatable { let id = UUID() } @@ -28,8 +29,4 @@ struct LogIn: Reducer { enum Action { case logInTapped(name: String) } - - var body: some ReducerOf { - EmptyReducer() - } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/OutcomeView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/OutcomeView.swift new file mode 100644 index 0000000..b92de10 --- /dev/null +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/OutcomeView.swift @@ -0,0 +1,45 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import UIKit + +struct OutcomeView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack { + if let winner = store.winnerName { + Text("Congratulations \(winner)!") + } else { + Text("The game ended in a draw") + } + Button("New game") { + store.send(.newGameTapped) + } + } + .navigationTitle("Game over") + .navigationBarBackButtonHidden() + } + } +} + +@Reducer +struct Outcome { + @ObservableState + struct State: Equatable { + let id = UUID() + var winner: Player? + var oPlayerName: String + var xPlayerName: String + + var winnerName: String? { + guard let winner = winner else { return nil } + return winner == .x ? xPlayerName : oPlayerName + } + } + + enum Action: Equatable { + case newGameTapped + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift index 36e5a09..e319a82 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift @@ -16,7 +16,8 @@ struct WelcomeView: View { } } -struct Welcome: Reducer { +@Reducer +struct Welcome { struct State: Equatable { let id = UUID() } @@ -24,8 +25,4 @@ struct Welcome: Reducer { enum Action { case logInTapped } - - var body: some ReducerOf { - EmptyReducer() - } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift index d74d370..ee82971 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift @@ -3,44 +3,44 @@ import SwiftUI import TCACoordinators struct IdentifiedCoordinatorView: View { - let store: StoreOf + @State var store: StoreOf var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screen in - SwitchStore(screen) { screen in - switch screen { - case .home: - CaseLet( - \Screen.State.home, - action: Screen.Action.home, - then: HomeView.init - ) - - case .numbersList: - CaseLet( - \Screen.State.numbersList, - action: Screen.Action.numbersList, - then: NumbersListView.init - ) - - case .numberDetail: - CaseLet( - \Screen.State.numberDetail, - action: Screen.Action.numberDetail, - then: NumberDetailView.init - ) - } + switch screen.case { + case let .home(store): + HomeView(store: store) + + case let .numbersList(store): + NumbersListView(store: store) + + case let .numberDetail(store): + NumberDetailView(store: store) } } } } +extension Screen.State: Identifiable { + var id: UUID { + switch self { + case let .home(state): + return state.id + case let .numbersList(state): + return state.id + case let .numberDetail(state): + return state.id + } + } +} + @Reducer -struct IdentifiedCoordinator: Reducer { +struct IdentifiedCoordinator { enum Deeplink { case showNumber(Int) } + @ObservableState struct State: Equatable { static let initialState = State( routes: [.root(.home(.init()), embedInNavigationView: true)] @@ -83,8 +83,6 @@ struct IdentifiedCoordinator: Reducer { } return .none } - .forEachRoute(\.routes, action: \.router) { - Screen() - } + .forEachRoute(\.routes, action: \.router) } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift index a4a6c8d..c553961 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift @@ -3,33 +3,19 @@ import SwiftUI import TCACoordinators struct IndexedCoordinatorView: View { - let store: StoreOf + @State var store: StoreOf var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screen in - SwitchStore(screen) { screen in - switch screen { - case .home: - CaseLet( - \Screen.State.home, - action: Screen.Action.home, - then: HomeView.init - ) + switch screen.case { + case let .home(store): + HomeView(store: store) - case .numbersList: - CaseLet( - \Screen.State.numbersList, - action: Screen.Action.numbersList, - then: NumbersListView.init - ) + case let .numbersList(store): + NumbersListView(store: store) - case .numberDetail: - CaseLet( - \Screen.State.numberDetail, - action: Screen.Action.numberDetail, - then: NumberDetailView.init - ) - } + case let .numberDetail(store): + NumberDetailView(store: store) } } } @@ -37,6 +23,7 @@ struct IndexedCoordinatorView: View { @Reducer struct IndexedCoordinator { + @ObservableState struct State: Equatable { static let initialState = State( routes: [.root(.home(.init()), embedInNavigationView: true)] @@ -79,8 +66,6 @@ struct IndexedCoordinator { } return .none } - .forEachRoute(\.routes, action: \.router) { - Screen() - } + .forEachRoute(\.routes, action: \.router) } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift index f4bde58..963a280 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift @@ -2,42 +2,11 @@ import ComposableArchitecture import Foundation import SwiftUI -@Reducer -struct Screen: Reducer { - enum Action { - case home(Home.Action) - case numbersList(NumbersList.Action) - case numberDetail(NumberDetail.Action) - } - - enum State: Equatable, Identifiable { - case home(Home.State) - case numbersList(NumbersList.State) - case numberDetail(NumberDetail.State) - - var id: UUID { - switch self { - case let .home(state): - return state.id - case let .numbersList(state): - return state.id - case let .numberDetail(state): - return state.id - } - } - } - - var body: some ReducerOf { - Scope(state: \.home, action: \.home) { - Home() - } - Scope(state: \.numbersList, action: \.numbersList) { - NumbersList() - } - Scope(state: \.numberDetail, action: \.numberDetail) { - NumberDetail() - } - } +@Reducer(state: .equatable) +enum Screen { + case home(Home) + case numbersList(NumbersList) + case numberDetail(NumberDetail) } // Home @@ -55,7 +24,8 @@ struct HomeView: View { } } -struct Home: Reducer { +@Reducer +struct Home { struct State: Equatable { let id = UUID() } @@ -63,10 +33,6 @@ struct Home: Reducer { enum Action { case startTapped } - - var body: some ReducerOf { - EmptyReducer() - } } // NumbersList @@ -75,12 +41,12 @@ struct NumbersListView: View { let store: StoreOf var body: some View { - WithViewStore(store, observe: \.numbers) { viewStore in - List(viewStore.state, id: \.self) { number in + WithPerceptionTracking { + List(store.numbers, id: \.self) { number in Button( "\(number)", action: { - viewStore.send(.numberSelected(number)) + store.send(.numberSelected(number)) } ) } @@ -89,7 +55,9 @@ struct NumbersListView: View { } } -struct NumbersList: Reducer { +@Reducer +struct NumbersList { + @ObservableState struct State: Equatable { let id = UUID() let numbers: [Int] @@ -98,10 +66,6 @@ struct NumbersList: Reducer { enum Action { case numberSelected(Int) } - - var body: some ReducerOf { - EmptyReducer() - } } // NumberDetail @@ -110,35 +74,36 @@ struct NumberDetailView: View { let store: StoreOf var body: some View { - WithViewStore(store, observe: \.number) { viewStore in + WithPerceptionTracking { VStack(spacing: 8.0) { - Text("Number \(viewStore.state)") + Text("Number \(store.number)") Button("Increment") { - viewStore.send(.incrementTapped) + store.send(.incrementTapped) } Button("Increment after delay") { - viewStore.send(.incrementAfterDelayTapped) + store.send(.incrementAfterDelayTapped) } - Button("Show double") { - viewStore.send(.showDouble(viewStore.state)) + Button("Show double (\(store.number * 2))") { + store.send(.showDouble(store.number)) } Button("Go back") { - viewStore.send(.goBackTapped) + store.send(.goBackTapped) } - Button("Go back to root") { - viewStore.send(.goBackToRootTapped) + Button("Go back to root from \(store.number)") { + store.send(.goBackToRootTapped) } Button("Go back to numbers list") { - viewStore.send(.goBackToNumbersList) + store.send(.goBackToNumbersList) } } - .navigationTitle("Number \(viewStore.state)") + .navigationTitle("Number \(store.number)") } } } @Reducer struct NumberDetail { + @ObservableState struct State: Equatable { let id = UUID() var number: Int diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift b/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift index 303580a..3bb3256 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift @@ -18,11 +18,11 @@ struct TCACoordinatorsExampleApp: App { // MainTabCoordinator struct MainTabCoordinatorView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - WithViewStore(store, observe: \.selectedTab) { viewStore in - TabView(selection: viewStore.binding(get: { $0 }, send: MainTabCoordinator.Action.tabSelected)) { + WithPerceptionTracking { + TabView(selection: $store.selectedTab.sending(\.tabSelected)) { IndexedCoordinatorView( store: store.scope( state: \.indexed, @@ -62,14 +62,14 @@ struct MainTabCoordinatorView: View { }.onOpenURL { _ in // In reality, the URL would be parsed into a Deeplink. let deeplink = MainTabCoordinator.Deeplink.identified(.showNumber(42)) - viewStore.send(.deeplinkOpened(deeplink)) + store.send(.deeplinkOpened(deeplink)) } } } } @Reducer -struct MainTabCoordinator: Reducer { +struct MainTabCoordinator { enum Tab: Hashable { case identified, indexed, app, form, deeplinkOpened } @@ -87,6 +87,7 @@ struct MainTabCoordinator: Reducer { case tabSelected(Tab) } + @ObservableState struct State: Equatable { static let initialState = State( identified: .initialState, diff --git a/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift b/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift index 178852c..cf33ec6 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift @@ -29,13 +29,35 @@ class TCACoordinatorsExampleUITests: XCTestCase { XCTAssertTrue(app.navigationBars["Number 2"].waitForExistence(timeout: navigationTimeout)) app.buttons["Increment after delay"].tap() - app.buttons["Show double"].tap() + app.buttons["Show double (4)"].tap() XCTAssertTrue(app.navigationBars["Number 4"].waitForExistence(timeout: navigationTimeout)) // Ensures increment will have happened off-screen. Thread.sleep(forTimeInterval: 3) - app.navigationBars["Number 4"].swipeDown(velocity: .fast) + app.navigationBars["Number 4"].swipeSheetDown() XCTAssertTrue(app.navigationBars["Number 3"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Show double (6)"].tap() + XCTAssertTrue(app.navigationBars["Number 6"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Show double (12)"].tap() + XCTAssertTrue(app.navigationBars["Number 12"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Go back to root from 12"].tap() + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout * 3)) + } +} + +extension XCUIElement { + func swipeSheetDown() { + if #available(iOS 17.0, *) { + // This doesn't work in iOS 16 + self.swipeDown(velocity: .fast) + } else { + let start = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + let end = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 5)) + start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0.0) + } } } diff --git a/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift b/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift index 555550f..6319067 100644 --- a/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift +++ b/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift @@ -2,8 +2,8 @@ import ComposableArchitecture @testable import TCACoordinators import XCTest -@MainActor final class IdentifiedRouterTests: XCTestCase { + @MainActor func testActionPropagation() async { let scheduler = DispatchQueue.test let store = TestStore( @@ -24,6 +24,7 @@ final class IdentifiedRouterTests: XCTestCase { } } + @MainActor func testActionCancellation() async { let scheduler = DispatchQueue.test let store = TestStore( @@ -51,6 +52,7 @@ final class IdentifiedRouterTests: XCTestCase { } @available(iOS 16.0, *) + @MainActor func testWithDelaysIfUnsupported() async throws { let initialRoutes: IdentifiedArrayOf> = [ .root(.init(id: "first", count: 1)), @@ -77,7 +79,7 @@ final class IdentifiedRouterTests: XCTestCase { } @Reducer -private struct Child: Reducer { +private struct Child { let scheduler: TestSchedulerOf struct State: Equatable, Identifiable { var id: String @@ -106,7 +108,7 @@ private struct Child: Reducer { } @Reducer -private struct Parent: Reducer { +private struct Parent { let scheduler: TestSchedulerOf struct State: Equatable { var routes: IdentifiedArrayOf> diff --git a/Tests/TCACoordinatorsTests/IndexedRouterTests.swift b/Tests/TCACoordinatorsTests/IndexedRouterTests.swift index 035f79a..f6e9824 100644 --- a/Tests/TCACoordinatorsTests/IndexedRouterTests.swift +++ b/Tests/TCACoordinatorsTests/IndexedRouterTests.swift @@ -2,8 +2,8 @@ import ComposableArchitecture @testable import TCACoordinators import XCTest -@MainActor final class IndexedRouterTests: XCTestCase { + @MainActor func testActionPropagation() async { let scheduler = DispatchQueue.test let store = TestStore( @@ -25,6 +25,7 @@ final class IndexedRouterTests: XCTestCase { } } + @MainActor func testActionCancellation() async { let scheduler = DispatchQueue.test let store = TestStore( @@ -52,6 +53,7 @@ final class IndexedRouterTests: XCTestCase { } @available(iOS 16.0, *) + @MainActor func testWithDelaysIfUnsupported() async throws { let initialRoutes: [Route] = [ .root(.init(count: 1)), @@ -78,7 +80,7 @@ final class IndexedRouterTests: XCTestCase { } @Reducer -private struct Child: Reducer { +private struct Child { let scheduler: TestSchedulerOf struct State: Equatable { var count = 0 @@ -106,7 +108,7 @@ private struct Child: Reducer { } @Reducer -private struct Parent: Reducer { +private struct Parent { struct State: Equatable { var routes: [Route] }