diff --git a/Package.swift b/Package.swift index 4a7a9da..4b5b437 100644 --- a/Package.swift +++ b/Package.swift @@ -13,8 +13,8 @@ let package = Package( targets: ["TCACoordinators"]), ], dependencies: [ - .package(url: "https://github.com/johnpatrickmorgan/FlowStacks", from: "0.1.1"), - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.27.1"), + .package(url: "https://github.com/johnpatrickmorgan/FlowStacks", from: "0.3.0"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.45.0"), ], targets: [ .target( diff --git a/README.md b/README.md index 0a44124..745f066 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 `Reducer.forEach`, `Reducer.pullback` 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 such as `.forEach`, `ifCaseLet` and `SwitchStore` with [a novel approach to handling navigation in SwiftUI](https://github.com/johnpatrickmorgan/FlowStacks). You might like this library if you want to: @@ -11,108 +11,115 @@ You might like this library if you want to: ✅ Easily go back to the root screen or a specific screen in the navigation stack.
✅ Keep all navigation logic in a single place.
✅ Break an app's navigation into multiple reusable coordinators and compose them together.
+✅ Use a single system to unify push navigation and modal presentation.
The library works by translating the array of screens into a hierarchy of nested `NavigationLink`s and presentation calls, so: 🚫 It does not rely on UIKit at all.
🚫 It does not use `AnyView` to type-erase screens.
-🚫 It does not try to recreate NavigationView from scratch.
+🚫 It does not try to recreate `NavigationView` from scratch.
## Usage example ### 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: +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: ```swift -enum ScreenState: Equatable { - case home(HomeState) - case numbersList(NumbersListState) - case numberDetail(NumberDetailState) -} - -enum ScreenAction { - case home(HomeAction) - case numbersList(NumbersListAction) - case numberDetail(NumberDetailAction) +struct Screen: ReducerProtocol { + 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 ReducerProtocol { + EmptyReducer() + .ifCaseLet(/State.home, action: /Action.home) { + Home() + } + .ifCaseLet(/State.numbersList, action: /Action.numbersList) { + NumbersList() + } + .ifCaseLet(/State.numberDetail, action: /Action.numberDetail) { + NumberDetail() + } + } } ``` -And the screen reducer will combine each individual screens' reducers into one: - -```swift -let screenReducer = Reducer.combine( - homeReducer - .pullback( - state: /ScreenState.home, - action: /ScreenAction.home, - environment: { _ in } - ), - numbersListReducer - .pullback( - state: /ScreenState.numbersList, - action: /ScreenAction.numbersList, - environment: { _ in } - ), - numberDetailReducer - .pullback( - state: /ScreenState.numberDetail, - action: /ScreenAction.numberDetail, - environment: { _ in } - ) -) -``` - ### Step 2 - Create a coordinator reducer -The coordinator will manage multiple screens in a navigation flow. Its state should include an array of `Route`s, representing the navigation stack: i.e. appending a new screen state to this array will trigger the corresponding screen to be pushed or presented. `Route` is an enum whose cases capture the screen state and how it should be shown, e.g. `case push(ScreenState)`. +The coordinator will manage multiple screens in a navigation flow. Its state should include an array of `Route`s, representing the navigation stack: i.e. appending a new screen state to this array will trigger the corresponding screen to be pushed or presented. `Route` is an enum whose cases capture the screen state and how it should be shown, e.g. `case push(ScreenState)`. ```swift -struct CoordinatorState: Equatable, IndexedRouterState { - var routes: [Route] +struct Coordinator: ReducerProtocol { + struct State: Equatable, IndexedRouterState { + var routes: [Route] + } + ... } ``` The coordinator's action should include two special cases. The first includes an index to allow screen actions to be dispatched to the correct screen in the routes array. The second allows the routes array to be updated automatically, e.g. when a user taps 'Back': ```swift -enum CoordinatorAction: IndexedRouterAction { - case routeAction(Int, action: ScreenAction) - case updateRoutes([Route]) +struct Coordinator: ReducerProtocol { + ... + enum Action: IndexedRouterAction { + case routeAction(Int, action: ScreenAction) + case updateRoutes([Route]) + } + ... } ``` -The coordinator's reducer uses `forEachIndexedRoute` to apply the `screenReducer` to each screen in the `routes` array, and combines that with a second reducer that defines when new screens should be pushed, presented or popped: +The coordinator reducer defines any logic for presenting and dismissing screens, and uses `forEachRoute` to further apply the `Screen` reducer to each screen in the `routes` array: ```swift -let coordinatorReducer: Reducer = screenReducer - .forEachIndexedRoute(environment: { _ in }) - .withRouteReducer( - Reducer { state, action, environment in +struct Coordinator: ReducerProtocol { + ... + var body: some ReducerProtocol { + return Reduce { state, action in switch action { case .routeAction(_, .home(.startTapped)): - state.routes.presentSheet(.numbersList(.init()), embedInNavigationView: true) + state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true) case .routeAction(_, .numbersList(.numberSelected(let number))): state.routes.push(.numberDetail(.init(number: number))) - + + case .routeAction(_, .numberDetail(.showDouble(let number))): + state.routes.presentSheet(.numberDetail(.init(number: number * 2))) + case .routeAction(_, .numberDetail(.goBackTapped)): state.routes.goBack() case .routeAction(_, .numberDetail(.goBackToNumbersList)): - state.routes.goBackTo(/.numbersList) + return .routeWithDelaysIfUnsupported(state.routes) { + $0.goBackTo(/Screen.State.numbersList) + } case .routeAction(_, .numberDetail(.goBackToRootTapped)): - state.routes.goBackToRoot() - + return .routeWithDelaysIfUnsupported(state.routes) { + $0.goBackToRoot() + } + default: break } return .none + }.forEachRoute { + Screen() } - ) + } +} ``` ### Step 3 - Create a coordinator view @@ -121,24 +128,24 @@ With that in place, a `CoordinatorView` can be created. It will use a `TCARouter ```swift struct CoordinatorView: View { - let store: Store + let store: Store var body: some View { TCARouter(store) { screen in SwitchStore(screen) { CaseLet( - state: /ScreenState.home, - action: ScreenAction.home, + state: /Screen.State.home, + action: Screen.Action.home, then: HomeView.init ) CaseLet( - state: /ScreenState.numbersList, - action: ScreenAction.numbersList, + state: /Screen.State.numbersList, + action: Screen.Action.numbersList, then: NumbersListView.init ) CaseLet( - state: /ScreenState.numberDetail, - action: ScreenAction.numberDetail, + state: /Screen.State.numberDetail, + action: Screen.Action.numberDetail, then: NumberDetailView.init ) } @@ -173,7 +180,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 override this behaviour, pass `cancelEffectsOnDismiss: false` to `withRouteReducer`. +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`. ## Making complex navigation updates @@ -213,8 +220,3 @@ 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. - - -## Limitations - -Currently only the `.stack` navigation view style is supported. There are some unexpected behaviours with the `.column` navigation view style that make it problematic for the approach used in this library. diff --git a/Sources/TCACoordinators/ReducerExtensions/Reducer+forEachIdentifiedRoute.swift b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+forEachIdentifiedRoute.swift similarity index 85% rename from Sources/TCACoordinators/ReducerExtensions/Reducer+forEachIdentifiedRoute.swift rename to Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+forEachIdentifiedRoute.swift index d46a20e..7dd07d7 100644 --- a/Sources/TCACoordinators/ReducerExtensions/Reducer+forEachIdentifiedRoute.swift +++ b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+forEachIdentifiedRoute.swift @@ -3,8 +3,16 @@ import FlowStacks import Foundation import SwiftUI +@available( + *, + deprecated, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + See equivalent extensions on ReducerProtocol. + """ +) extension Reducer where State: Identifiable { - /// Lifts a screen reducer to one that operates on an `IdentifiedArray` of `Route`s. The resulting reducer will /// update the routes whenever the user navigates back, e.g. by swiping. /// @@ -20,10 +28,10 @@ extension Reducer where State: Identifiable { file: StaticString = #fileID, line: UInt = #line ) -> Reducer - where - CoordinatorAction.ScreenAction == Action, - CoordinatorAction.Screen == CoordinatorState.Screen, - State == CoordinatorState.Screen + where + CoordinatorAction.ScreenAction == Action, + CoordinatorAction.Screen == CoordinatorState.Screen, + State == CoordinatorState.Screen { self .forEachIdentifiedRoute( @@ -52,8 +60,7 @@ extension Reducer where State: Identifiable { environment toLocalEnvironment: @escaping (CoordinatorEnvironment) -> Environment, file: StaticString = #fileID, line: UInt = #line - ) -> Reducer - { + ) -> Reducer { self .onRoutes() .forEach( @@ -70,12 +77,11 @@ extension Reducer where State: Identifiable { } } -extension Reducer { - +extension AnyReducer { /// Lifts a Screen reducer to one that acts on Route. /// - Returns: The new reducer. - func onRoutes() -> Reducer, Action, Environment> { - return Reducer, Action, Environment> { state, action, environment in + func onRoutes() -> AnyReducer, Action, Environment> { + return AnyReducer, Action, Environment> { state, action, environment in self.run(&state.screen, action, environment) } } @@ -86,7 +92,7 @@ extension Reducer { updateRoutes: CasePath, state toLocalState: WritableKeyPath ) -> Self { - return self.combined(with: Reducer { state, action, environment in + return self.combined(with: AnyReducer { state, action, _ in if let routes = updateRoutes.extract(from: action) { state[keyPath: toLocalState] = routes } diff --git a/Sources/TCACoordinators/ReducerExtensions/Reducer+forEachIndexedRoute.swift b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+forEachIndexedRoute.swift similarity index 89% rename from Sources/TCACoordinators/ReducerExtensions/Reducer+forEachIndexedRoute.swift rename to Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+forEachIndexedRoute.swift index ec91e66..46b6bc0 100644 --- a/Sources/TCACoordinators/ReducerExtensions/Reducer+forEachIndexedRoute.swift +++ b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+forEachIndexedRoute.swift @@ -3,8 +3,17 @@ import FlowStacks import Foundation import SwiftUI -extension Reducer { - +@available( + *, + deprecated, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + See equivalent extensions on ReducerProtocol. + """ +) +extension Reducer +{ /// Lifts a screen reducer to one that operates on an `Array` of `Route`s. The resulting reducer will /// update the routes whenever the user navigates back, e.g. by swiping. /// @@ -20,10 +29,10 @@ extension Reducer { file: StaticString = #fileID, line: UInt = #line ) -> Reducer - where - CoordinatorAction.ScreenAction == Action, - CoordinatorAction.Screen == CoordinatorState.Screen, - State == CoordinatorState.Screen + where + CoordinatorAction.ScreenAction == Action, + CoordinatorAction.Screen == CoordinatorState.Screen, + State == CoordinatorState.Screen { self .forEachIndexedRoute( @@ -35,7 +44,7 @@ extension Reducer { line: line ) } - + /// Lifts a screen reducer to one that operates on an `Array` of `Route`s. The resulting reducer will /// update the routes whenever the user navigates back, e.g. by swiping. /// diff --git a/Sources/TCACoordinators/ReducerExtensions/Reducer+tagging.swift b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+tagging.swift similarity index 85% rename from Sources/TCACoordinators/ReducerExtensions/Reducer+tagging.swift rename to Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+tagging.swift index 7b1a95a..7c2a29d 100644 --- a/Sources/TCACoordinators/ReducerExtensions/Reducer+tagging.swift +++ b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+tagging.swift @@ -3,8 +3,7 @@ import FlowStacks import Foundation import SwiftUI -extension Reducer { - +extension AnyReducer { /// Transforms a reducer into one that tags route actions' effects for cancellation with the `coordinatorId` /// and route index. /// - Parameter coordinatorId: A stable identifier for the coordinator. It should match the one used in a @@ -15,7 +14,7 @@ extension Reducer { coordinatorId: CoordinatorID, routeAction: CasePath ) -> Self { - return Reducer { state, action, environment in + return AnyReducer { state, action, environment in let effect = self.run(&state, action, environment) if let (routeId, _) = routeAction.extract(from: action) { @@ -26,7 +25,7 @@ extension Reducer { } } } - + /// Transforms a reducer into one that cancels tagged route actions when that route is no /// longer shown, identifying routes by their index. /// - Parameter coordinatorId: A stable identifier for the coordinator. @@ -36,18 +35,17 @@ extension Reducer { coordinatorId: CoordinatorID, routes: @escaping (State) -> C, getIdentifier: @escaping (C.Element, C.Index) -> RouteID - ) -> Self - { - return Reducer { state, action, environment in + ) -> Self { + return AnyReducer { state, action, environment in let preRoutes = routes(state) let effect = self.run(&state, action, environment) let postRoutes = routes(state) var effects: [Effect] = [effect] - + let preIds = zip(preRoutes, preRoutes.indices).map(getIdentifier) let postIds = zip(postRoutes, postRoutes.indices).map(getIdentifier) - + let dismissedIds = Set(preIds).subtracting(postIds) for dismissedId in dismissedIds { let identity = CancellationIdentity(coordinatorId: coordinatorId, routeId: dismissedId) @@ -58,10 +56,3 @@ extension Reducer { } } } - -/// Identifier for a particular route within a particular coordinator. -private struct CancellationIdentity: Hashable { - - let coordinatorId: CoordinatorID - let routeId: RouteID -} diff --git a/Sources/TCACoordinators/ReducerExtensions/Reducer+withRouteReducer.swift b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+withRouteReducer.swift similarity index 80% rename from Sources/TCACoordinators/ReducerExtensions/Reducer+withRouteReducer.swift rename to Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+withRouteReducer.swift index a4da6db..4c719ec 100644 --- a/Sources/TCACoordinators/ReducerExtensions/Reducer+withRouteReducer.swift +++ b/Sources/TCACoordinators/DeprecatedReducerExtensions/Reducer+withRouteReducer.swift @@ -3,8 +3,17 @@ import FlowStacks import Foundation import SwiftUI +@available( + *, + deprecated, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + See `forEachIdentifiedRoute` and `forEachIdentifiedRoute` extensions on ReducerProtocol, + which include parameters to cancel effects on dismiss. + """ +) public extension Reducer where State: IndexedRouterState, Action: IndexedRouterAction { - /// Transforms a reducer so that it tags effects for cancellation based on their index, then combines /// it with the provided route reducer, cancelling route effects for any route that has been dismissed. /// @@ -25,8 +34,17 @@ public extension Reducer where State: IndexedRouterState, Action: IndexedRouterA } } +@available( + *, + deprecated, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + See `forEachIdentifiedRoute` and `forEachIdentifiedRoute` extensions on ReducerProtocol, + which include parameters to cancel effects on dismiss. + """ +) public extension Reducer where State: IdentifiedRouterState, Action: IdentifiedRouterAction, State.Screen == Action.Screen { - /// Transforms a reducer so that it tags effects for cancellation based on their identity, then combines /// it with the provided route reducer, cancelling route effects for any route that has been dismissed. /// @@ -47,8 +65,17 @@ public extension Reducer where State: IdentifiedRouterState, Action: IdentifiedR } } +@available( + *, + deprecated, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + See `forEachIdentifiedRoute` and `forEachIdentifiedRoute` extensions on ReducerProtocol, + which include parameters to cancel effects on dismiss. + """ +) public extension Reducer { - /// Transforms a reducer so that it tags effects for cancellation based on their `CoordinatorID` and RouteID`, /// then combines it with the provided route reducer, cancelling route effects for any route that has been dismissed. /// @@ -63,8 +90,7 @@ public extension Reducer { coordinatorIdForCancellation: CoordinatorID?, getIdentifier: @escaping (C.Element, C.Index) -> RouteID, routeReducer: Self - ) -> Self - { + ) -> Self { guard let coordinatorId = coordinatorIdForCancellation else { return self.combined(with: routeReducer) } diff --git a/Sources/TCACoordinators/Effect+routeWithDelaysIfUnsupported.swift b/Sources/TCACoordinators/Effect+routeWithDelaysIfUnsupported.swift index 7315261..30c6c6a 100644 --- a/Sources/TCACoordinators/Effect+routeWithDelaysIfUnsupported.swift +++ b/Sources/TCACoordinators/Effect+routeWithDelaysIfUnsupported.swift @@ -1,59 +1,98 @@ -import Foundation +import Combine import ComposableArchitecture import FlowStacks +import Foundation import SwiftUI -import Combine +import CombineSchedulers public extension Effect where Output: IndexedRouterAction, Failure == Never { - /// Allows arbitrary changes to be made to the routes collection, even if SwiftUI does not support such changes within a single /// state update. For example, SwiftUI only supports pushing, presenting or dismissing one screen at a time. Any changes can be /// made to the routes passed to the transform closure, and where those changes are not supported within a single update by /// SwiftUI, an Effect stream of smaller permissible updates will be returned, interspersed with sufficient delays. /// /// - Parameter routes: The routes in their current state. + /// - Parameter scheduler: The scheduler for scheduling delays. E.g. a test scheduler can be used in tests. /// - Parameter transform: A closure transforming the routes into their new state. /// - Returns: An Effect stream of actions with incremental updates to routes over time. If the proposed change is supported /// within a single update, the Effect stream will include only one element. - static func routeWithDelaysIfUnsupported(_ routes: [Route], _ transform: (inout [Route]) -> Void) -> Self { + static func routeWithDelaysIfUnsupported(_ routes: [Route], scheduler: AnySchedulerOf, _ transform: (inout [Route]) -> Void) -> Self { var transformedRoutes = routes transform(&transformedRoutes) let steps = RouteSteps.calculateSteps(from: routes, to: transformedRoutes) - return scheduledSteps(steps: steps) + return scheduledSteps(steps: steps, scheduler: scheduler) .map { Output.updateRoutes($0) } .eraseToEffect() } + /// Allows arbitrary changes to be made to the routes collection, even if SwiftUI does not support such changes within a single + /// state update. For example, SwiftUI only supports pushing, presenting or dismissing one screen at a time. Any changes can be + /// made to the routes passed to the transform closure, and where those changes are not supported within a single update by + /// SwiftUI, an Effect stream of smaller permissible updates will be returned, interspersed with sufficient delays. + /// + /// - Parameter routes: The routes in their current state. + /// - Parameter transform: A closure transforming the routes into their new state. + /// - Returns: An Effect stream of actions with incremental updates to routes over time. If the proposed change is supported + /// within a single update, the Effect stream will include only one element. + static func routeWithDelaysIfUnsupported(_ routes: [Route], _ transform: (inout [Route]) -> Void) -> Self { + routeWithDelaysIfUnsupported(routes, scheduler: DispatchQueue.main.eraseToAnyScheduler(), transform) + } } public extension Effect where Output: IdentifiedRouterAction, Failure == Never { - /// Allows arbitrary changes to be made to the routes collection, even if SwiftUI does not support such changes within a single /// state update. For example, SwiftUI only supports pushing, presenting or dismissing one screen at a time. Any changes can be /// made to the routes passed to the transform closure, and where those changes are not supported within a single update by /// SwiftUI, an Effect stream of smaller permissible updates will be returned, interspersed with sufficient delays. /// /// - Parameter routes: The routes in their current state. + /// - Parameter scheduler: The scheduler for scheduling delays. E.g. a test scheduler can be used in tests. /// - Parameter transform: A closure transforming the routes into their new state. /// - Returns: An Effect stream of actions with incremental updates to routes over time. If the proposed change is supported /// within a single update, the Effect stream will include only one element. - static func routeWithDelaysIfUnsupported(_ routes: IdentifiedArrayOf>, _ transform: (inout IdentifiedArrayOf>) -> Void) -> Self { + static func routeWithDelaysIfUnsupported(_ routes: IdentifiedArrayOf>, scheduler: AnySchedulerOf, _ transform: (inout IdentifiedArrayOf>) -> Void) -> Self { var transformedRoutes = routes transform(&transformedRoutes) let steps = RouteSteps.calculateSteps(from: Array(routes), to: Array(transformedRoutes)) - return scheduledSteps(steps: steps) + return scheduledSteps(steps: steps, scheduler: scheduler) .map { Output.updateRoutes(IdentifiedArray(uncheckedUniqueElements: $0)) } .eraseToEffect() } + /// Allows arbitrary changes to be made to the routes collection, even if SwiftUI does not support such changes within a single + /// state update. For example, SwiftUI only supports pushing, presenting or dismissing one screen at a time. Any changes can be + /// made to the routes passed to the transform closure, and where those changes are not supported within a single update by + /// SwiftUI, an Effect stream of smaller permissible updates will be returned, interspersed with sufficient delays. + /// + /// - Parameter routes: The routes in their current state. + /// - Parameter transform: A closure transforming the routes into their new state. + /// - Returns: An Effect stream of actions with incremental updates to routes over time. If the proposed change is supported + /// within a single update, the Effect stream will include only one element. + static func routeWithDelaysIfUnsupported(_ routes: IdentifiedArrayOf>, _ transform: (inout IdentifiedArrayOf>) -> Void) -> Self { + return routeWithDelaysIfUnsupported(routes, scheduler: DispatchQueue.main.eraseToAnyScheduler(), transform) + } } +///// Transforms a series of steps into an AnyPublisher of those steps, each one delayed in time. +//func scheduledSteps(steps: [[Route]]) -> AnyPublisher<[Route], Never> { +// guard let head = steps.first else { +// return Empty().eraseToAnyPublisher() +// } +// +// let timer = Just(Date()) +// .append(Timer.publish(every: 0.65, on: .main, in: .default).autoconnect()) +// let tail = Publishers.Zip(steps.dropFirst().publisher, timer) +// .map { $0.0 } +// return Just(head) +// .append(tail) +// .eraseToAnyPublisher() +//} + /// Transforms a series of steps into an AnyPublisher of those steps, each one delayed in time. -func scheduledSteps(steps: [[Route]]) -> AnyPublisher<[Route], Never> { +func scheduledSteps(steps: [[Route]], scheduler: AnySchedulerOf) -> AnyPublisher<[Route], Never> { guard let head = steps.first else { return Empty().eraseToAnyPublisher() } - - let timer = Just(Date()) - .append(Timer.publish(every: 0.65, on: .main, in: .default).autoconnect()) + let timer = Just(scheduler.now) + .append(Publishers.Timer(every: 0.65, scheduler: scheduler).autoconnect()) let tail = Publishers.Zip(steps.dropFirst().publisher, timer) .map { $0.0 } return Just(head) diff --git a/Sources/TCACoordinators/IdentifiedArray+RoutableCollection.swift b/Sources/TCACoordinators/IdentifiedArray+RoutableCollection.swift index 1adfeb5..3e0940d 100644 --- a/Sources/TCACoordinators/IdentifiedArray+RoutableCollection.swift +++ b/Sources/TCACoordinators/IdentifiedArray+RoutableCollection.swift @@ -1,6 +1,6 @@ -import Foundation import ComposableArchitecture import FlowStacks +import Foundation extension IdentifiedArray: RoutableCollection { public mutating func _append(element: Element) { @@ -8,18 +8,16 @@ extension IdentifiedArray: RoutableCollection { } } -extension RoutableCollection where Element: RouteProtocol { - +public extension RoutableCollection where Element: RouteProtocol { /// Goes back to the topmost (most recently shown) screen in the stack /// that matches the given case path. If no screens satisfy the condition, /// the routes will be unchanged. /// - Parameter condition: The predicate indicating which screen to pop to. /// - Returns: A `Bool` indicating whether a screen was found. @discardableResult - public mutating func goBackTo(_ screenCasePath: CasePath) -> Bool { + mutating func goBackTo(_ screenCasePath: CasePath) -> Bool { goBackTo(where: { screenCasePath.extract(from: $0.screen) != nil }) } - /// Pops to the topmost (most recently shown) screen in the stack /// that matches the given case path. If no screens satisfy the condition, @@ -28,7 +26,7 @@ extension RoutableCollection where Element: RouteProtocol { /// - Parameter condition: The predicate indicating which screen to pop to. /// - Returns: A `Bool` indicating whether a screen was found. @discardableResult - public mutating func popTo(_ screenCasePath: CasePath) -> Bool { + mutating func popTo(_ screenCasePath: CasePath) -> Bool { popTo(where: { screenCasePath.extract(from: $0.screen) != nil }) } } diff --git a/Sources/TCACoordinators/Protocols/IdentifiedRouterAction.swift b/Sources/TCACoordinators/Protocols/IdentifiedRouterAction.swift index 3ff9ad6..42fc9f7 100644 --- a/Sources/TCACoordinators/Protocols/IdentifiedRouterAction.swift +++ b/Sources/TCACoordinators/Protocols/IdentifiedRouterAction.swift @@ -1,6 +1,6 @@ -import Foundation import ComposableArchitecture import FlowStacks +import Foundation /// A protocol standardizing naming conventions for action types that can manage routes /// within an `IdentifiedArray`. diff --git a/Sources/TCACoordinators/Protocols/IdentifiedRouterState.swift b/Sources/TCACoordinators/Protocols/IdentifiedRouterState.swift index e598398..b0dc7e2 100644 --- a/Sources/TCACoordinators/Protocols/IdentifiedRouterState.swift +++ b/Sources/TCACoordinators/Protocols/IdentifiedRouterState.swift @@ -1,6 +1,6 @@ -import Foundation import ComposableArchitecture import FlowStacks +import Foundation /// A protocol standardizing naming conventions for state types that contain routes /// within an `IdentifiedArray`. diff --git a/Sources/TCACoordinators/Protocols/IndexedRouterAction.swift b/Sources/TCACoordinators/Protocols/IndexedRouterAction.swift index e654817..1caf1ee 100644 --- a/Sources/TCACoordinators/Protocols/IndexedRouterAction.swift +++ b/Sources/TCACoordinators/Protocols/IndexedRouterAction.swift @@ -1,10 +1,9 @@ -import Foundation import FlowStacks +import Foundation /// A protocol standardizing naming conventions for action types that can manage routes /// within an `Array`. public protocol IndexedRouterAction { - associatedtype Screen associatedtype ScreenAction diff --git a/Sources/TCACoordinators/Protocols/IndexedRouterState.swift b/Sources/TCACoordinators/Protocols/IndexedRouterState.swift index db12372..0ce8101 100644 --- a/Sources/TCACoordinators/Protocols/IndexedRouterState.swift +++ b/Sources/TCACoordinators/Protocols/IndexedRouterState.swift @@ -1,5 +1,5 @@ -import Foundation import FlowStacks +import Foundation /// A protocol standardizing naming conventions for state types that contain routes /// within an `IdentifiedArray`. diff --git a/Sources/TCACoordinators/Reducers/CancelEffectsOnDismiss.swift b/Sources/TCACoordinators/Reducers/CancelEffectsOnDismiss.swift new file mode 100644 index 0000000..7c2b59e --- /dev/null +++ b/Sources/TCACoordinators/Reducers/CancelEffectsOnDismiss.swift @@ -0,0 +1,89 @@ +import ComposableArchitecture +import Foundation + +/// Identifier for a particular route within a particular coordinator. +public struct CancellationIdentity: Hashable { + let coordinatorId: CoordinatorID + let routeId: RouteID +} + +struct CancelEffectsOnDismiss: ReducerProtocol where CoordinatorScreensReducer.State == CoordinatorReducer.State, CoordinatorScreensReducer.Action == CoordinatorReducer.Action { + let coordinatedScreensReducer: CoordinatorScreensReducer + let routes: (CoordinatorReducer.State) -> C + let routeAction: CasePath + let cancellationId: CoordinatorID? + let getIdentifier: (C.Element, C.Index) -> RouteID + let coordinatorReducer: CoordinatorReducer + + var body: some ReducerProtocol { + if let cancellationId { + CancelTaggedRouteEffectsOnDismiss( + coordinatorReducer: CombineReducers { + TagRouteEffectsForCancellation( + screenReducer: coordinatedScreensReducer, + coordinatorId: cancellationId, + routeAction: routeAction + ) + coordinatorReducer + }, + coordinatorId: cancellationId, + routes: routes, + getIdentifier: getIdentifier + ) + } else { + CombineReducers { + coordinatorReducer + coordinatedScreensReducer + } + } + } +} + +struct TagRouteEffectsForCancellation: ReducerProtocol { + typealias State = ScreenReducer.State + typealias Action = ScreenReducer.Action + + let screenReducer: ScreenReducer + let coordinatorId: CoordinatorID + let routeAction: CasePath + + func reduce(into state: inout State, action: Action) -> EffectTask { + let effect = screenReducer.reduce(into: &state, action: action) + + if let (routeId, _) = routeAction.extract(from: action) { + let identity = CancellationIdentity(coordinatorId: coordinatorId, routeId: routeId) + return effect.cancellable(id: AnyHashable(identity)) + } else { + return effect + } + } +} + +struct CancelTaggedRouteEffectsOnDismiss: ReducerProtocol { + typealias State = CoordinatorReducer.State + typealias Action = CoordinatorReducer.Action + + let coordinatorReducer: CoordinatorReducer + let coordinatorId: CoordinatorID + let routes: (State) -> C + let getIdentifier: (C.Element, C.Index) -> RouteID + + func reduce(into state: inout State, action: Action) -> EffectTask { + let preRoutes = routes(state) + let effect = coordinatorReducer.reduce(into: &state, action: action) + let postRoutes = routes(state) + + var effects: [Effect] = [effect] + + let preIds = zip(preRoutes, preRoutes.indices).map(getIdentifier) + let postIds = zip(postRoutes, postRoutes.indices).map(getIdentifier) + + let dismissedIds = Set(preIds).subtracting(postIds) + for dismissedId in dismissedIds { + let identity = CancellationIdentity(coordinatorId: coordinatorId, routeId: dismissedId) + effects.append(Effect.cancel(id: AnyHashable(identity))) + } + + return Effect.merge(effects) + } +} diff --git a/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift b/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift new file mode 100644 index 0000000..da602ba --- /dev/null +++ b/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift @@ -0,0 +1,96 @@ +import ComposableArchitecture +import Foundation + +struct ForEachIdentifiedRoute: ReducerProtocol where ScreenReducer.State: Identifiable { + let coordinatorReducer: CoordinatorReducer + let screenReducer: ScreenReducer + let cancellationId: CoordinatorID? + let toLocalState: WritableKeyPath>> + let toLocalAction: CasePath + let updateRoutes: CasePath>> + + var body: some ReducerProtocol { + CancelEffectsOnDismiss( + coordinatedScreensReducer: EmptyReducer() + .forEach(toLocalState, action: toLocalAction) { + OnRoutes(wrapped: screenReducer) + } + .updatingRoutesOnInteraction( + updateRoutes: updateRoutes, + toLocalState: toLocalState + ), + routes: { $0[keyPath: toLocalState] }, + routeAction: toLocalAction, + cancellationId: cancellationId, + getIdentifier: { element, _ in element.id }, + coordinatorReducer: coordinatorReducer + ) + } +} + +public extension ReducerProtocol { + func forEachRoute( + cancellationId: CoordinatorID?, + toLocalState: WritableKeyPath>>, + toLocalAction: CasePath, + updateRoutes: CasePath>>, + @ReducerBuilderOf screenReducer: () -> ScreenReducer + ) -> some ReducerProtocol where ScreenReducer.State: Identifiable { + return ForEachIdentifiedRoute( + coordinatorReducer: self, + screenReducer: screenReducer(), + cancellationId: cancellationId, + toLocalState: toLocalState, + toLocalAction: toLocalAction, + updateRoutes: updateRoutes + ) + } +} + +public extension ReducerProtocol where State: IdentifiedRouterState, Action: IdentifiedRouterAction, State.Screen == Action.Screen { + /// Allows a screen 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. + /// - Parameters: + /// - cancellationId: An ID to use for cancelling in-flight effects when a view is dismissed. It + /// will be combined with the screen's identifier. + /// - screenReducer: The reducer that operates on all of the individual screens. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + cancellationId: CoordinatorID?, + @ReducerBuilderOf screenReducer: () -> ScreenReducer + ) -> some ReducerProtocol where ScreenReducer.State: Identifiable, State.Screen == ScreenReducer.State, ScreenReducer.Action == Action.ScreenAction { + return ForEachIdentifiedRoute( + coordinatorReducer: self, + screenReducer: screenReducer(), + cancellationId: cancellationId, + toLocalState: \.routes, + toLocalAction: /Action.routeAction, + updateRoutes: /Action.updateRoutes + ) + } + + /// Allows a screen 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. + /// - Parameters: + /// - 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. + /// - screenReducer: The reducer that operates on all of the individual screens. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + cancellationIdType: Any.Type = Self.self, + @ReducerBuilderOf screenReducer: () -> ScreenReducer + ) -> some ReducerProtocol where ScreenReducer.State: Identifiable, State.Screen == ScreenReducer.State, ScreenReducer.Action == Action.ScreenAction { + return ForEachIdentifiedRoute( + coordinatorReducer: self, + screenReducer: screenReducer(), + cancellationId: ObjectIdentifier(cancellationIdType), + toLocalState: \.routes, + toLocalAction: /Action.routeAction, + updateRoutes: /Action.updateRoutes + ) + } +} diff --git a/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift b/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift new file mode 100644 index 0000000..436f8a7 --- /dev/null +++ b/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift @@ -0,0 +1,99 @@ +import ComposableArchitecture +import Foundation + +struct ForEachIndexedRoute: ReducerProtocol { + let coordinatorReducer: CoordinatorReducer + let screenReducer: ScreenReducer + let coordinatorIdForCancellation: CoordinatorID? + let toLocalState: WritableKeyPath]> + let toLocalAction: CasePath + let updateRoutes: CasePath]> + + var reducer: AnyReducer { + AnyReducer(screenReducer) + .forEachIndexedRoute( + state: toLocalState, + action: toLocalAction, + updateRoutes: updateRoutes, + environment: { _ in } + ) + .withRouteReducer( + routes: { $0[keyPath: toLocalState] }, + routeAction: toLocalAction, + coordinatorIdForCancellation: coordinatorIdForCancellation, + getIdentifier: { _, index in index }, + routeReducer: AnyReducer(coordinatorReducer) + ) + } + + var body: some ReducerProtocol { + Reduce(reducer, environment: ()) + } +} + +public extension ReducerProtocol { + func forEachRoute( + coordinatorIdForCancellation: CoordinatorID?, + toLocalState: WritableKeyPath]>, + toLocalAction: CasePath, + updateRoutes: CasePath]>, + @ReducerBuilderOf screenReducer: () -> ScreenReducer + ) -> some ReducerProtocol { + return ForEachIndexedRoute( + coordinatorReducer: self, + screenReducer: screenReducer(), + coordinatorIdForCancellation: coordinatorIdForCancellation, + toLocalState: toLocalState, + toLocalAction: toLocalAction, + updateRoutes: updateRoutes + ) + } +} + +public extension ReducerProtocol where State: IndexedRouterState, Action: IndexedRouterAction, State.Screen == Action.Screen { + /// 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. If a cancellation identifier is passed, in-flight effects + /// will be cancelled when the screen from which they originated is dismissed. + /// - Parameters: + /// - cancellationId: An ID to use for cancelling in-flight effects when a view is dismissed. It + /// will be combined with the screen's identifier. + /// - screenReducer: The reducer that operates on all of the individual screens. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + coordinatorIdForCancellation: CoordinatorID?, + @ReducerBuilderOf screenReducer: () -> ScreenReducer + ) -> some ReducerProtocol where State.Screen == ScreenReducer.State, ScreenReducer.Action == Action.ScreenAction { + return ForEachIndexedRoute( + coordinatorReducer: self, + screenReducer: screenReducer(), + coordinatorIdForCancellation: coordinatorIdForCancellation, + toLocalState: \.routes, + toLocalAction: /Action.routeAction, + updateRoutes: /Action.updateRoutes + ) + } + + /// 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. If a cancellation identifier is passed, in-flight effects + /// will be cancelled when the screen from which they originated is dismissed. + /// - Parameters: + /// - 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. + /// - screenReducer: The reducer that operates on all of the individual screens. + /// - Returns: A new reducer combining the coordinator-level and screen-level reducers. + func forEachRoute( + cancellationIdType: Any.Type = Self.self, + @ReducerBuilderOf screenReducer: () -> ScreenReducer + ) -> some ReducerProtocol where State.Screen == ScreenReducer.State, ScreenReducer.Action == Action.ScreenAction { + return ForEachIndexedRoute( + coordinatorReducer: self, + screenReducer: screenReducer(), + coordinatorIdForCancellation: ObjectIdentifier(cancellationIdType), + toLocalState: \.routes, + toLocalAction: /Action.routeAction, + updateRoutes: /Action.updateRoutes + ) + } +} diff --git a/Sources/TCACoordinators/Reducers/OnRoutes.swift b/Sources/TCACoordinators/Reducers/OnRoutes.swift new file mode 100644 index 0000000..1d92b96 --- /dev/null +++ b/Sources/TCACoordinators/Reducers/OnRoutes.swift @@ -0,0 +1,13 @@ +import ComposableArchitecture +import Foundation + +struct OnRoutes: ReducerProtocol { + typealias State = Route + typealias Action = WrappedReducer.Action + + let wrapped: WrappedReducer + + func reduce(into state: inout State, action: Action) -> EffectTask { + wrapped.reduce(into: &state.screen, action: action) + } +} diff --git a/Sources/TCACoordinators/Reducers/UpdateRoutesOnInteraction.swift b/Sources/TCACoordinators/Reducers/UpdateRoutesOnInteraction.swift new file mode 100644 index 0000000..956ff58 --- /dev/null +++ b/Sources/TCACoordinators/Reducers/UpdateRoutesOnInteraction.swift @@ -0,0 +1,31 @@ +import ComposableArchitecture +import Foundation + +extension ReducerProtocol { + func updatingRoutesOnInteraction(updateRoutes: CasePath, toLocalState: WritableKeyPath) -> some ReducerProtocol { + CombineReducers { + self + UpdateRoutesOnInteraction( + wrapped: self, + updateRoutes: updateRoutes, + toLocalState: toLocalState + ) + } + } +} + +struct UpdateRoutesOnInteraction: ReducerProtocol { + typealias State = WrappedReducer.State + typealias Action = WrappedReducer.Action + + let wrapped: WrappedReducer + let updateRoutes: CasePath + let toLocalState: WritableKeyPath + + func reduce(into state: inout WrappedReducer.State, action: WrappedReducer.Action) -> EffectTask { + if let routes = updateRoutes.extract(from: action) { + state[keyPath: toLocalState] = routes + } + return .none + } +} diff --git a/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift b/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift index 3739805..705db30 100644 --- a/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift +++ b/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift @@ -3,8 +3,8 @@ import FlowStacks import Foundation import SwiftUI -extension TCARouter -where +public extension TCARouter + where CoordinatorState: IdentifiedRouterState, CoordinatorAction: IdentifiedRouterAction, CoordinatorState.Screen == Screen, @@ -12,10 +12,9 @@ where CoordinatorAction.ScreenAction == ScreenAction, Screen.ID == ID { - /// Convenience initializer for managing screens in an `IdentifiedArray`, where State /// and Action conform to the `IdentifiedRouter...` protocols. - public init( + init( _ store: Store, screenContent: @escaping (Store) -> ScreenContent ) { diff --git a/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift b/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift index 3dfa232..65ed749 100644 --- a/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift +++ b/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift @@ -3,8 +3,8 @@ import FlowStacks import Foundation import SwiftUI -extension TCARouter -where +public extension TCARouter + where ID == Int, CoordinatorAction: IndexedRouterAction, CoordinatorAction.Screen == Screen, @@ -12,10 +12,9 @@ where CoordinatorState: IndexedRouterState, CoordinatorState.Screen == Screen { - /// Convenience initializer for managing screens in an `Array` identified by index, where /// State and Action conform to the `IdentifiedRouter...` protocols. - public init( + init( _ store: Store, screenContent: @escaping (Store) -> ScreenContent ) { diff --git a/Sources/TCACoordinators/TCARouter/TCARouter.swift b/Sources/TCACoordinators/TCARouter/TCARouter.swift index 2cff800..f38f1c1 100644 --- a/Sources/TCACoordinators/TCARouter/TCARouter.swift +++ b/Sources/TCACoordinators/TCARouter/TCARouter.swift @@ -35,7 +35,7 @@ public struct TCARouter< } public var body: some View { - WithViewStore(store, removeDuplicates: { routes($0).map(\.style) == routes($1).map(\.style) }) { viewStore in + WithViewStore(store, removeDuplicates: { routes($0).map(\.style) == routes($1).map(\.style) }) { _ in Router( ViewStore(store).binding( get: routes, @@ -49,10 +49,9 @@ public struct TCARouter< } } -extension TCARouter where Screen: Identifiable { - +public extension TCARouter where Screen: Identifiable { /// Convenience initializer for managing screens in an `IdentifiedArray`. - public init( + init( store: Store, routes: @escaping (CoordinatorState) -> IdentifiedArrayOf>, updateRoutes: @escaping (IdentifiedArrayOf>) -> CoordinatorAction, @@ -70,10 +69,9 @@ extension TCARouter where Screen: Identifiable { } } -extension TCARouter where ID == Int { - +public extension TCARouter where ID == Int { /// Convenience initializer for managing screens in an `Array`, identified by index. - public init( + init( store: Store, routes: @escaping (CoordinatorState) -> [Route], updateRoutes: @escaping ([Route]) -> CoordinatorAction, @@ -96,8 +94,8 @@ extension Route: Identifiable where Screen: Identifiable { } extension Collection { - /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript(safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil - } - } + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj index c11c3a0..4828096 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj +++ b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj @@ -23,13 +23,13 @@ 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 */; }; - 528FEDEE2880BD94007765AD /* AppFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE42880BC60007765AD /* AppFlow.swift */; }; + 528FEDEE2880BD94007765AD /* FormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE42880BC60007765AD /* FormScreen.swift */; }; 528FEDEF2880BD94007765AD /* FormAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */; }; 528FEDF12880BD94007765AD /* Step2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE92880BC61007765AD /* Step2.swift */; }; 528FEDF22880BD95007765AD /* Step3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDEA2880BC61007765AD /* Step3.swift */; }; 528FEDF32880BD95007765AD /* Step1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE82880BC61007765AD /* Step1.swift */; }; 528FEDF42880BD95007765AD /* FinalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE72880BC60007765AD /* FinalScreen.swift */; }; - 528FEDF52880BD95007765AD /* AppFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE42880BC60007765AD /* AppFlow.swift */; }; + 528FEDF52880BD95007765AD /* FormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE42880BC60007765AD /* FormScreen.swift */; }; 528FEDF62880BD95007765AD /* FormAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */; }; 528FEDF82880BD95007765AD /* Step2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE92880BC61007765AD /* Step2.swift */; }; 529822D2283D76AD0011112B /* GameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822CE283D76AD0011112B /* GameView.swift */; }; @@ -71,7 +71,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 = ""; }; - 528FEDE42880BC60007765AD /* AppFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlow.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 = ""; }; 528FEDE82880BC61007765AD /* Step1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Step1.swift; sourceTree = ""; }; @@ -192,7 +192,7 @@ isa = PBXGroup; children = ( 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */, - 528FEDE42880BC60007765AD /* AppFlow.swift */, + 528FEDE42880BC60007765AD /* FormScreen.swift */, 528FEDE72880BC60007765AD /* FinalScreen.swift */, 528FEDE82880BC61007765AD /* Step1.swift */, 528FEDE92880BC61007765AD /* Step2.swift */, @@ -355,7 +355,7 @@ 528FEDF82880BD95007765AD /* Step2.swift in Sources */, 528FEDF42880BD95007765AD /* FinalScreen.swift in Sources */, 529822D7283D76F60011112B /* WelcomeView.swift in Sources */, - 528FEDF52880BD95007765AD /* AppFlow.swift in Sources */, + 528FEDF52880BD95007765AD /* FormScreen.swift in Sources */, 524087E5278E3D950048C6EE /* Screen.swift in Sources */, 528FEDF22880BD95007765AD /* Step3.swift in Sources */, 529822D3283D76AD0011112B /* AppCoordinator.swift in Sources */, @@ -371,7 +371,7 @@ buildActionMask = 2147483647; files = ( 524087E8278E3D9F0048C6EE /* IndexedCoordinator.swift in Sources */, - 528FEDEE2880BD94007765AD /* AppFlow.swift in Sources */, + 528FEDEE2880BD94007765AD /* FormScreen.swift in Sources */, 5248864126F2A26500970899 /* TCACoordinatorsExampleTests.swift in Sources */, 528FEDED2880BD94007765AD /* FinalScreen.swift in Sources */, 524087E7278E3D9F0048C6EE /* IdentifiedCoordinator.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 502ab66..0fd325f 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", "state": { "branch": null, - "revision": "4cf088c29a20f52be0f2ca54992b492c54e0076b", - "version": "0.5.3" + "revision": "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version": "0.9.1" } }, { @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/johnpatrickmorgan/FlowStacks", "state": { "branch": null, - "revision": "30132357d65b22281ea52e1456aa658390f34805", - "version": "0.2.0" + "revision": "8d1bc577fcab0c09d599f1f2f6747db2d15f84f4", + "version": "0.3.0" } }, { @@ -24,8 +24,17 @@ "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", "state": { "branch": null, - "revision": "ce9c0d897db8a840c39de64caaa9b60119cf4be8", - "version": "0.8.1" + "revision": "bb436421f57269fbcfe7360735985321585a86e5", + "version": "0.10.1" + } + }, + { + "package": "swift-clocks", + "repositoryURL": "https://github.com/pointfreeco/swift-clocks", + "state": { + "branch": null, + "revision": "20b25ca0dd88ebfb9111ec937814ddc5a8880172", + "version": "0.2.0" } }, { @@ -33,8 +42,8 @@ "repositoryURL": "https://github.com/apple/swift-collections", "state": { "branch": null, - "revision": "48254824bb4248676bf7ce56014ff57b142b77eb", - "version": "1.0.2" + "revision": "f504716c27d2e5d4144fa4794b12129301d17729", + "version": "1.0.3" } }, { @@ -42,8 +51,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", "state": { "branch": null, - "revision": "2828dc44f6e3f81d84bcaba72c1ab1c0121d66f6", - "version": "0.34.0" + "revision": "1fcd53fc875bade47d850749ea53c324f74fd64d", + "version": "0.45.0" } }, { @@ -51,8 +60,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", "state": { "branch": null, - "revision": "c4f78db9b90ca57b7b6abc2223e235242739ea3c", - "version": "0.4.0" + "revision": "ead7d30cc224c3642c150b546f4f1080d1c411a8", + "version": "0.6.1" } }, { @@ -60,8 +69,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", "state": { "branch": null, - "revision": "680bf440178a78a627b1c2c64c0855f6523ad5b9", - "version": "0.3.2" + "revision": "bfb0d43e75a15b6dfac770bf33479e8393884a36", + "version": "0.4.1" } }, { @@ -69,8 +78,8 @@ "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", "state": { "branch": null, - "revision": "50a70a9d3583fe228ce672e8923010c8df2deddd", - "version": "0.2.1" + "revision": "16e6409ee82e1b81390bdffbf217b9c08ab32784", + "version": "0.5.0" } } ] diff --git a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/xcshareddata/xcschemes/TCACoordinatorsExample.xcscheme b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/xcshareddata/xcschemes/TCACoordinatorsExample.xcscheme index 6797924..5ddd92d 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/xcshareddata/xcschemes/TCACoordinatorsExample.xcscheme +++ b/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/xcshareddata/xcschemes/TCACoordinatorsExample.xcscheme @@ -48,6 +48,16 @@ ReferencedContainer = "container:TCACoordinatorsExample.xcodeproj"> + + + + - let getOccupations: () -> Effect<[String], Never> - let submit: (APIModel) -> Effect - - static let test = AppFlowEnvironment( - mainQueue: .main, - getOccupations: { - .task { - [ - "iOS Developer", - "Android Developer", - "Web Developer", - "Project Manager", - "Designer", - "The Big Cheese" - ] - } - }, - submit: { _ in - .task { true } - } - ) -} - -extension AppFlowEnvironment { - var step1: Step1Environment { - .init(mainQueue: mainQueue) - } - - var step2: Step2Environment { - .init(mainQueue: mainQueue) - } - - var step3: Step3Environment { - .init(mainQueue: mainQueue, getOccupations: getOccupations) - } - - var finalScreen: FinalScreenEnvironment { - .init(mainQueue: mainQueue, submit: submit) - } -} - -typealias AppFlowReducer = Reducer - -let appFlowReducer: AppFlowReducer = .combine( - Step1Reducer.step1 - .pullback(state: /AppFlowState.step1, action: /AppFlowAction.step1, environment: \.step1), - Step2Reducer.step2 - .pullback(state: /AppFlowState.step2, action: /AppFlowAction.step2, environment: \.step2), - Step3Reducer.step3 - .pullback(state: /AppFlowState.step3, action: /AppFlowAction.step3, environment: \.step3), - FinalScreenReducer.finalScreen - .pullback(state: /AppFlowState.finalScreen, action: /AppFlowAction.finalScreen, environment: \.finalScreen) -) diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift index 5cd9047..676501a 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import SwiftUI struct FinalScreenView: View { - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -86,40 +86,6 @@ struct LabelledRow: View { } } -struct FinalScreenView_Previews: PreviewProvider { - static var previews: some View { - FinalScreenView( - store: Store( - initialState: FinalScreenState( - firstName: "Rhys", - lastName: "Morgan", - dateOfBirth: .now, - job: "iOS Developer" - ), - reducer: .finalScreen, - environment: FinalScreenEnvironment( - mainQueue: .main, - submit: { _ in - Effect(value: true) - } - ) - ) - ) - } -} - -public struct FinalScreenState: Equatable { - let firstName: String - let lastName: String - let dateOfBirth: Date - let job: String? - - var submissionInFlight = false - var isIncomplete: Bool { - firstName.isEmpty || lastName.isEmpty || job?.isEmpty ?? true - } -} - struct APIModel: Codable, Equatable { let firstName: String let lastName: String @@ -127,47 +93,57 @@ struct APIModel: Codable, Equatable { let job: String } -enum FinalScreenAction: Equatable { - case returnToName - case returnToDateOfBirth - case returnToJob +struct FinalScreen: ReducerProtocol { + struct State: Equatable { + let firstName: String + let lastName: String + let dateOfBirth: Date + let job: String? - case submit - case receiveAPIResponse(Result) -} + var submissionInFlight = false + var isIncomplete: Bool { + firstName.isEmpty || lastName.isEmpty || job?.isEmpty ?? true + } + } + + enum Action: Equatable { + case returnToName + case returnToDateOfBirth + case returnToJob + + case submit + case receiveAPIResponse(Result) + } -struct FinalScreenEnvironment { let mainQueue: AnySchedulerOf let submit: (APIModel) -> Effect -} -typealias FinalScreenReducer = Reducer - -extension FinalScreenReducer { - static let finalScreen = Reducer { state, action, environment in - switch action { - case .submit: - guard let job = state.job else { return .none } - state.submissionInFlight = true - - let apiModel = APIModel( - firstName: state.firstName, - lastName: state.lastName, - dateOfBirth: state.dateOfBirth, - job: job - ) - - return environment.submit(apiModel) - .delay(for: .seconds(0.8), scheduler: RunLoop.main) - .receive(on: environment.mainQueue) - .catchToEffect(Action.receiveAPIResponse) - - case .receiveAPIResponse: - state.submissionInFlight = false - return .none - - case .returnToName, .returnToDateOfBirth, .returnToJob: - return .none + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .submit: + guard let job = state.job else { return .none } + state.submissionInFlight = true + + let apiModel = APIModel( + firstName: state.firstName, + lastName: state.lastName, + dateOfBirth: state.dateOfBirth, + job: job + ) + + return submit(apiModel) + .delay(for: .seconds(0.8), scheduler: RunLoop.main) + .receive(on: mainQueue) + .catchToEffect(Action.receiveAPIResponse) + + case .receiveAPIResponse: + state.submissionInFlight = false + return .none + + case .returnToName, .returnToDateOfBirth, .returnToJob: + return .none + } } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift index 03218e8..4051b38 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift @@ -2,125 +2,126 @@ import ComposableArchitecture import SwiftUI import TCACoordinators -struct FormAppCoordinatorState: IdentifiedRouterState, Equatable { - static let initialState = Self(routeIDs: [.root(.step1, embedInNavigationView: true)]) +struct FormAppCoordinator: ReducerProtocol { + struct State: IdentifiedRouterState, Equatable { + static let initialState = Self(routeIDs: [.root(.step1, embedInNavigationView: true)]) - var step1State = Step1State() - var step2State = Step2State() - var step3State = Step3State() + var step1State = Step1.State() + var step2State = Step2.State() + var step3State = Step3.State() - var finalScreenState: FinalScreenState { - return .init(firstName: step1State.firstName, lastName: step1State.lastName, dateOfBirth: step2State.dateOfBirth, job: step3State.selectedOccupation) - } + var finalScreenState: FinalScreen.State { + return .init(firstName: step1State.firstName, lastName: step1State.lastName, dateOfBirth: step2State.dateOfBirth, job: step3State.selectedOccupation) + } - var routeIDs: IdentifiedArrayOf> - - var routes: IdentifiedArrayOf> { - get { - let routes = routeIDs.map { route -> Route in - route.map { id in - switch id { - case .step1: - return .step1(step1State) - case .step2: - return .step2(step2State) - case .step3: - return .step3(step3State) - case .finalScreen: - return .finalScreen(finalScreenState) + var routeIDs: IdentifiedArrayOf> + + var routes: IdentifiedArrayOf> { + get { + let routes = routeIDs.map { route -> Route in + route.map { id in + switch id { + case .step1: + return .step1(step1State) + case .step2: + return .step2(step2State) + case .step3: + return .step3(step3State) + case .finalScreen: + return .finalScreen(finalScreenState) + } } } + return IdentifiedArray(uniqueElements: routes) } - return IdentifiedArray(uniqueElements: routes) - } - set { - let routeIDs = newValue.map { route -> Route in - route.map { id in - switch id { - case .step1(let step1State): - self.step1State = step1State - return .step1 - case .step2(let step2State): - self.step2State = step2State - return .step2 - case .step3(let step3State): - self.step3State = step3State - return .step3 - case .finalScreen: - return .finalScreen + set { + let routeIDs = newValue.map { route -> Route in + route.map { id in + switch id { + case .step1(let step1State): + self.step1State = step1State + return .step1 + case .step2(let step2State): + self.step2State = step2State + return .step2 + case .step3(let step3State): + self.step3State = step3State + return .step3 + case .finalScreen: + return .finalScreen + } } } + self.routeIDs = IdentifiedArray(uniqueElements: routeIDs) } - self.routeIDs = IdentifiedArray(uniqueElements: routeIDs) } - } - mutating func clear() { - step1State = .init() - step2State = .init() - step3State = .init() + mutating func clear() { + step1State = .init() + step2State = .init() + step3State = .init() + } } -} -enum FormAppCoordinatorAction: IdentifiedRouterAction { - case updateRoutes(IdentifiedArrayOf>) - case routeAction(AppFlowState.ID, action: AppFlowAction) -} - -typealias FormAppCoordinatorReducer = Reducer + enum Action: IdentifiedRouterAction { + case updateRoutes(IdentifiedArrayOf>) + case routeAction(FormScreen.State.ID, action: FormScreen.Action) + } -let formAppCoordinatorReducer: FormAppCoordinatorReducer = appFlowReducer - .forEachIdentifiedRoute(environment: { $0 }) - .withRouteReducer(Reducer { state, action, _ in - switch action { - case .routeAction(_, action: .step1(.nextButtonTapped)): - state.routeIDs.push(.step2) - return .none + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .routeAction(_, action: .step1(.nextButtonTapped)): + state.routeIDs.push(.step2) + return .none - case .routeAction(_, action: .step2(.nextButtonTapped)): - state.routeIDs.push(.step3) - return .none + case .routeAction(_, action: .step2(.nextButtonTapped)): + state.routeIDs.push(.step3) + return .none - case .routeAction(_, action: .step3(.nextButtonTapped)): - state.routeIDs.push(.finalScreen) - return .none + case .routeAction(_, action: .step3(.nextButtonTapped)): + state.routeIDs.push(.finalScreen) + return .none - case .routeAction(_, action: .finalScreen(.returnToName)): - state.routeIDs.goBackTo(id: .step1) - return .none + case .routeAction(_, action: .finalScreen(.returnToName)): + state.routeIDs.goBackTo(id: .step1) + return .none - case .routeAction(_, action: .finalScreen(.returnToDateOfBirth)): - state.routeIDs.goBackTo(id: .step2) - return .none + case .routeAction(_, action: .finalScreen(.returnToDateOfBirth)): + state.routeIDs.goBackTo(id: .step2) + return .none - case .routeAction(_, action: .finalScreen(.returnToJob)): - state.routeIDs.goBackTo(id: .step3) - state.clear() - return .none + case .routeAction(_, action: .finalScreen(.returnToJob)): + state.routeIDs.goBackTo(id: .step3) + return .none - case .routeAction(_, action: .finalScreen(.receiveAPIResponse(.success))): - state.routeIDs.goBackToRoot() - state.clear() - return .none + case .routeAction(_, action: .finalScreen(.receiveAPIResponse(.success))): + state.routeIDs.goBackToRoot() + state.clear() + return .none - default: - return .none + default: + return .none + } + }.forEachRoute { + FormScreen(environment: .test) } - }) + } +} struct FormAppCoordinatorView: View { - let store: Store + let store: Store var body: some View { TCARouter(store) { screen in SwitchStore(screen) { - CaseLet(state: /AppFlowState.step1, action: AppFlowAction.step1, then: Step1View.init(store:)) + CaseLet(state: /FormScreen.State.step1, action: FormScreen.Action.step1, then: Step1View.init(store:)) - CaseLet(state: /AppFlowState.step2, action: AppFlowAction.step2, then: Step2View.init(store:)) + CaseLet(state: /FormScreen.State.step2, action: FormScreen.Action.step2, then: Step2View.init(store:)) - CaseLet(state: /AppFlowState.step3, action: AppFlowAction.step3, then: Step3View.init(store:)) + CaseLet(state: /FormScreen.State.step3, action: FormScreen.Action.step3, then: Step3View.init(store:)) - CaseLet(state: /AppFlowState.finalScreen, action: AppFlowAction.finalScreen, then: FinalScreenView.init(store:)) + CaseLet(state: /FormScreen.State.finalScreen, action: FormScreen.Action.finalScreen, then: FinalScreenView.init(store:)) } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift new file mode 100644 index 0000000..d9ecd30 --- /dev/null +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift @@ -0,0 +1,85 @@ +import ComposableArchitecture +import Foundation + +struct FormScreenEnvironment { + let mainQueue: AnySchedulerOf + let getOccupations: () -> Effect<[String], Never> + let submit: (APIModel) -> Effect + + static let test = FormScreenEnvironment( + mainQueue: .main, + getOccupations: { + .task { + [ + "iOS Developer", + "Android Developer", + "Web Developer", + "Project Manager", + "Designer", + "The Big Cheese" + ] + } + }, + submit: { _ in + .task { true } + } + ) +} + +struct FormScreen: ReducerProtocol { + 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 ReducerProtocol { + EmptyReducer() + .ifCaseLet(/State.step1, action: /Action.step1) { + Step1() + } + .ifCaseLet(/State.step2, action: /Action.step2) { + Step2() + } + .ifCaseLet(/State.step3, action: /Action.step3) { + Step3(mainQueue: environment.mainQueue, getOccupations: environment.getOccupations) + } + .ifCaseLet(/State.finalScreen, action: /Action.finalScreen) { + FinalScreen(mainQueue: environment.mainQueue, submit: environment.submit) + } + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift index 41d08e1..48f3212 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift @@ -1,8 +1,24 @@ import ComposableArchitecture import SwiftUI +struct Step1: ReducerProtocol { + public struct State: Equatable { + @BindableState var firstName: String = "" + @BindableState var lastName: String = "" + } + + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + case nextButtonTapped + } + + var body: some ReducerProtocol { + BindingReducer() + } +} + struct Step1View: View { - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -20,29 +36,3 @@ struct Step1View: View { } } } - -struct Step1View_Previews: PreviewProvider { - static var previews: some View { - Step1View(store: Store(initialState: .init(), reducer: .step1, environment: Step1Environment(mainQueue: .main))) - } -} - -public struct Step1State: Equatable { - @BindableState var firstName: String = "" - @BindableState var lastName: String = "" -} - -public enum Step1Action: Equatable, BindableAction { - case binding(BindingAction) - case nextButtonTapped -} - -struct Step1Environment { - let mainQueue: AnySchedulerOf -} - -typealias Step1Reducer = Reducer - -extension Step1Reducer { - static let step1 = empty.binding() -} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift index a257960..d0b580f 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import SwiftUI struct Step2View: View { - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -28,35 +28,17 @@ struct Step2View: View { } } -struct Step2View_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - Step2View( - store: Store( - initialState: Step2State(), - reducer: .step2, - environment: Step2Environment(mainQueue: .main) - ) - ) - } +struct Step2: ReducerProtocol { + public struct State: Equatable { + @BindableState var dateOfBirth: Date = .now } -} - -public struct Step2State: Equatable { - @BindableState var dateOfBirth: Date = .now -} - -public enum Step2Action: Equatable, BindableAction { - case binding(BindingAction) - case nextButtonTapped -} - -struct Step2Environment { - let mainQueue: AnySchedulerOf -} -typealias Step2Reducer = Reducer + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + case nextButtonTapped + } -extension Step2Reducer { - static let step2 = Reducer.empty.binding() + var body: some ReducerProtocol { + BindingReducer() + } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift index f8da726..891ff5c 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import SwiftUI struct Step3View: View { - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -45,73 +45,44 @@ struct Step3View: View { } } -struct Step3View_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - Step3View( - store: .init( - initialState: .init(), - reducer: .step3, - environment: Step3Environment( - mainQueue: .main, - getOccupations: { - .task { - [ - "iOS Developer", - "Android Developer", - "Web Developer", - "Project Manager", - ] - } - } - ) - ) - ) - } +struct Step3: ReducerProtocol { + struct State: Equatable { + var selectedOccupation: String? + var occupations: [String] = [] } -} -public struct Step3State: Equatable { - var selectedOccupation: String? - var occupations: [String] = [] -} - -public enum Step3Action: Equatable { - case getOccupations - case receiveOccupations(Result<[String], Never>) - case selectOccupation(String) - case nextButtonTapped -} + enum Action: Equatable { + case getOccupations + case receiveOccupations(Result<[String], Never>) + case selectOccupation(String) + case nextButtonTapped + } -struct Step3Environment { let mainQueue: AnySchedulerOf let getOccupations: () -> Effect<[String], Never> -} - -typealias Step3Reducer = Reducer -extension Step3Reducer { - static let step3 = Reducer { state, action, environment in - switch action { - case .getOccupations: - return environment - .getOccupations() - .receive(on: environment.mainQueue) - .catchToEffect(Action.receiveOccupations) + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .getOccupations: + return getOccupations() + .receive(on: mainQueue) + .catchToEffect(Action.receiveOccupations) - case .receiveOccupations(.success(let occupations)): - state.occupations = occupations - return .none + case .receiveOccupations(.success(let occupations)): + state.occupations = occupations + return .none - case .selectOccupation(let occupation): - if state.occupations.contains(occupation) { - state.selectedOccupation = state.selectedOccupation == occupation ? nil : occupation - } + case .selectOccupation(let occupation): + if state.occupations.contains(occupation) { + state.selectedOccupation = state.selectedOccupation == occupation ? nil : occupation + } - return .none + return .none - case .nextButtonTapped: - return .none + case .nextButtonTapped: + return .none + } } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift index addf99f..a0be598 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift @@ -5,17 +5,17 @@ import TCACoordinators // This coordinator shows one of two child coordinators, depending on if logged in. It // animates a transition between the two child coordinators. struct AppCoordinatorView: View { - let store: Store + let store: Store var body: some View { WithViewStore(store, removeDuplicates: { $0.isLoggedIn == $1.isLoggedIn }) { viewStore in VStack { if viewStore.isLoggedIn { - GameCoordinatorView(store: store.scope(state: \.game, action: AppCoordinatorAction.game)) + GameCoordinatorView(store: store.scope(state: \.game, action: GameApp.Action.game)) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } else { - LogInCoordinatorView(store: store.scope(state: \.logIn, action: AppCoordinatorAction.logIn)) - .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))) + LogInCoordinatorView(store: store.scope(state: \.logIn, action: GameApp.Action.logIn)) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } } .animation(.default, value: viewStore.isLoggedIn) @@ -23,52 +23,40 @@ struct AppCoordinatorView: View { } } -struct AppCoordinatorState: Equatable { - var logIn: LogInCoordinatorState - var game: GameCoordinatorState - - var isLoggedIn: Bool - - static let initialState = AppCoordinatorState(logIn: .initialState, game: .initialState(), isLoggedIn: false) -} - -enum AppCoordinatorAction { - case logIn(LogInCoordinatorAction) - case game(GameCoordinatorAction) -} - -struct AppCoordinatorEnvironment {} +struct GameApp: ReducerProtocol { + struct State: Equatable { + static let initialState = State(logIn: .initialState, game: .initialState(), isLoggedIn: false) -typealias AppCoordinatorReducer = Reducer< - AppCoordinatorState, AppCoordinatorAction, AppCoordinatorEnvironment -> + var logIn: LogInCoordinator.State + var game: GameCoordinator.State -let appCoordinatorReducer: AppCoordinatorReducer = .combine( - logInCoordinatorReducer - .pullback( - state: \AppCoordinatorState.logIn, - action: /AppCoordinatorAction.logIn, - environment: { _ in .init() } - ), - gameCoordinatorReducer - .pullback( - state: \AppCoordinatorState.game, - action: /AppCoordinatorAction.game, - environment: { _ in .init() } - ), - Reducer { state, action, _ in - switch action { - case .logIn(.routeAction(_, .logIn(.logInTapped(let name)))): - state.game = .initialState(playerName: name) - state.isLoggedIn = true + var isLoggedIn: Bool + } - case .game(.routeAction(_, .game(.logOutButtonTapped))): - state.logIn = .initialState - state.isLoggedIn = false + enum Action { + case logIn(LogInCoordinator.Action) + case game(GameCoordinator.Action) + } - default: - break + var body: some ReducerProtocol { + Scope(state: \.logIn, action: /Action.logIn) { + LogInCoordinator() + } + Scope(state: \.game, action: /Action.game) { + GameCoordinator() + } + Reduce { state, action in + switch action { + case .logIn(.routeAction(_, .logIn(.logInTapped(let name)))): + state.game = .initialState(playerName: name) + state.isLoggedIn = true + case .game(.routeAction(_, .game(.logOutButtonTapped))): + state.logIn = .initialState + state.isLoggedIn = false + default: + break + } + return .none } - return .none } -) +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift index 000e19c..260b5f0 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift @@ -2,66 +2,66 @@ import ComposableArchitecture import SwiftUI import TCACoordinators -enum GameScreenAction { - case game(GameAction) -} - -enum GameScreenState: Equatable, Identifiable { - case game(GameState) +struct GameCoordinatorView: View { + let store: Store - var id: UUID { - switch self { - case .game(let state): - return state.id + var body: some View { + TCARouter(store) { screen in + SwitchStore(screen) { + CaseLet( + state: /GameScreen.State.game, + action: GameScreen.Action.game, + then: GameView.init + ) + } } } } -struct GameScreenEnvironment {} +struct GameScreen: ReducerProtocol { + enum State: Equatable, Identifiable { + case game(Game.State) -let gameScreenReducer = Reducer.combine( - gameReducer - .pullback( - state: /GameScreenState.game, - action: /GameScreenAction.game, - environment: { _ in GameEnvironment() } - ) -) - -struct GameCoordinatorState: Equatable, IndexedRouterState { - static func initialState(playerName: String = "") -> GameCoordinatorState { - return GameCoordinatorState( - routes: [.root(.game(.init(oPlayerName: "Opponent", xPlayerName: playerName.isEmpty ? "Player" : playerName)), embedInNavigationView: true)] - ) + var id: UUID { + switch self { + case .game(let state): + return state.id + } + } } - var routes: [Route] -} + enum Action { + case game(Game.Action) + } -enum GameCoordinatorAction: IndexedRouterAction { - case routeAction(Int, action: GameScreenAction) - case updateRoutes([Route]) + var body: some ReducerProtocol { + EmptyReducer() + .ifCaseLet(/State.game, action: /Action.game) { + Game() + } + } } -struct GameCoordinatorEnvironment {} - -typealias GameCoordinatorReducer = Reducer +struct GameCoordinator: ReducerProtocol { + struct State: Equatable, IndexedRouterState { + static func initialState(playerName: String = "") -> Self { + return .init( + routes: [.root(.game(.init(oPlayerName: "Opponent", xPlayerName: playerName.isEmpty ? "Player" : playerName)), embedInNavigationView: true)] + ) + } -let gameCoordinatorReducer: GameCoordinatorReducer = gameScreenReducer - .forEachIndexedRoute(environment: { _ in GameScreenEnvironment() }) + var routes: [Route] + } -struct GameCoordinatorView: View { - let store: Store + enum Action: IndexedRouterAction { + case routeAction(Int, action: GameScreen.Action) + case updateRoutes([Route]) + } - var body: some View { - TCARouter(store) { screen in - SwitchStore(screen) { - CaseLet( - state: /GameScreenState.game, - action: GameScreenAction.game, - then: GameView.init - ) + var body: some ReducerProtocol { + EmptyReducer() + .forEachRoute { + GameScreen() } - } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift index 7917c50..efd6d18 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift @@ -5,22 +5,22 @@ import UIKit // Adapted from: https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/TicTacToe/tic-tac-toe/Sources/GameCore -public struct GameView: UIViewControllerRepresentable { - let store: Store +struct GameView: UIViewControllerRepresentable { + let store: Store - public typealias UIViewControllerType = GameViewController + typealias UIViewControllerType = GameViewController - public func makeUIViewController(context: Context) -> GameViewController { + func makeUIViewController(context: Context) -> GameViewController { GameViewController(store: self.store) } - public func updateUIViewController(_ uiViewController: GameViewController, context: Context) {} + func updateUIViewController(_ uiViewController: GameViewController, context: Context) {} } -public final class GameViewController: UIViewController { - let store: Store - let viewStore: ViewStore - let _viewStore: ViewStore +final class GameViewController: UIViewController { + let store: Store + let viewStore: ViewStore + let _viewStore: ViewStore private var cancellables: Set = [] struct ViewState: Equatable { @@ -29,7 +29,7 @@ public final class GameViewController: UIViewController { let isPlayAgainButtonHidden: Bool let title: String? - init(state: GameState) { + 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 @@ -42,7 +42,7 @@ public final class GameViewController: UIViewController { } } - public init(store: Store) { + init(store: Store) { self.store = store self.viewStore = ViewStore(store.scope(state: ViewState.init)) self._viewStore = ViewStore(store) @@ -54,7 +54,7 @@ public final class GameViewController: UIViewController { fatalError("init(coder:) has not been implemented") } - override public func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() self.navigationItem.title = "Tic-Tac-Toe" @@ -200,29 +200,85 @@ public final class GameViewController: UIViewController { } } +struct Game: ReducerProtocol { + struct State: Equatable { + let id = UUID() + var board: Three> = .empty + var currentPlayer: Player = .x + var oPlayerName: String + var xPlayerName: String + + init(oPlayerName: String, xPlayerName: String) { + self.oPlayerName = oPlayerName + self.xPlayerName = xPlayerName + } + + var currentPlayerName: String { + switch self.currentPlayer { + case .o: return self.oPlayerName + case .x: return self.xPlayerName + } + } + } + + enum Action: Equatable { + case cellTapped(row: Int, column: Int) + case playAgainButtonTapped + case logOutButtonTapped + case quitButtonTapped + } + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case let .cellTapped(row, column): + guard + state.board[row][column] == nil, + !state.board.hasWinner + else { return .none } + + state.board[row][column] = state.currentPlayer + + if !state.board.hasWinner { + state.currentPlayer.toggle() + } + + return .none + + case .playAgainButtonTapped: + state = Game.State(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName) + return .none + + case .quitButtonTapped, .logOutButtonTapped: + return .none + } + } + } +} + /// A collection of three elements. -public struct Three: CustomStringConvertible { - public var first: Element - public var second: Element - public var third: Element +struct Three: CustomStringConvertible { + var first: Element + var second: Element + var third: Element - public init(_ first: Element, _ second: Element, _ third: Element) { + init(_ first: Element, _ second: Element, _ third: Element) { self.first = first self.second = second self.third = third } - public func map(_ transform: (Element) -> T) -> Three { + func map(_ transform: (Element) -> T) -> Three { .init(transform(self.first), transform(self.second), transform(self.third)) } - public var description: String { + var description: String { return "[\(self.first),\(self.second),\(self.third)]" } } extension Three: MutableCollection { - public subscript(offset: Int) -> Element { + subscript(offset: Int) -> Element { _read { switch offset { case 0: yield self.first @@ -241,9 +297,9 @@ extension Three: MutableCollection { } } - public var startIndex: Int { 0 } - public var endIndex: Int { 3 } - public func index(after i: Int) -> Int { i + 1 } + var startIndex: Int { 0 } + var endIndex: Int { 3 } + func index(after i: Int) -> Int { i + 1 } } extension Three: RandomAccessCollection {} @@ -251,18 +307,18 @@ extension Three: RandomAccessCollection {} extension Three: Equatable where Element: Equatable {} extension Three: Hashable where Element: Hashable {} -public enum Player: Equatable { +enum Player: Equatable { case o case x - public mutating func toggle() { + mutating func toggle() { switch self { case .o: self = .x case .x: self = .o } } - public var label: String { + var label: String { switch self { case .o: return "⭕️" case .x: return "❌" @@ -270,70 +326,14 @@ public enum Player: Equatable { } } -public struct GameState: Equatable { - let id = UUID() - public var board: Three> = .empty - public var currentPlayer: Player = .x - public var oPlayerName: String - public var xPlayerName: String - - public init(oPlayerName: String, xPlayerName: String) { - self.oPlayerName = oPlayerName - self.xPlayerName = xPlayerName - } - - public var currentPlayerName: String { - switch self.currentPlayer { - case .o: return self.oPlayerName - case .x: return self.xPlayerName - } - } -} - -public enum GameAction: Equatable { - case cellTapped(row: Int, column: Int) - case playAgainButtonTapped - case logOutButtonTapped - case quitButtonTapped -} - -public struct GameEnvironment { - public init() {} -} - -public let gameReducer = Reducer { state, action, _ in - switch action { - case let .cellTapped(row, column): - guard - state.board[row][column] == nil, - !state.board.hasWinner - else { return .none } - - state.board[row][column] = state.currentPlayer - - if !state.board.hasWinner { - state.currentPlayer.toggle() - } - - return .none - - case .playAgainButtonTapped: - state = GameState(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName) - return .none - - case .quitButtonTapped, .logOutButtonTapped: - return .none - } -} - extension Three where Element == Three { - public static let empty = Self( + static let empty = Self( .init(nil, nil, nil), .init(nil, nil, nil), .init(nil, nil, nil) ) - public var isFilled: Bool { + var isFilled: Bool { self.allSatisfy { $0.allSatisfy { $0 != nil } } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift index 2420d3d..583bda2 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift @@ -2,56 +2,51 @@ import ComposableArchitecture import SwiftUI import TCACoordinators -enum LogInScreenAction { - case welcome(WelcomeAction) - case logIn(LogInAction) -} +struct LogInScreen: ReducerProtocol { + enum Action { + case welcome(Welcome.Action) + case logIn(LogIn.Action) + } -enum LogInScreenState: Equatable, Identifiable { - case welcome(WelcomeState) - case logIn(LogInState) + enum State: Equatable, Identifiable { + case welcome(Welcome.State) + case logIn(LogIn.State) - var id: UUID { - switch self { - case .welcome(let state): - return state.id - case .logIn(let state): - return state.id + var id: UUID { + switch self { + case .welcome(let state): + return state.id + case .logIn(let state): + return state.id + } } } -} - -struct LogInScreenEnvironment {} -let logInScreenReducer = Reducer.combine( - welcomeReducer - .pullback( - state: /LogInScreenState.welcome, - action: /LogInScreenAction.welcome, - environment: { _ in WelcomeEnvironment() } - ), - logInReducer - .pullback( - state: /LogInScreenState.logIn, - action: /LogInScreenAction.logIn, - environment: { _ in LogInEnvironment() } - ) -) + var body: some ReducerProtocol { + EmptyReducer() + .ifCaseLet(/State.welcome, action: /Action.welcome) { + Welcome() + } + .ifCaseLet(/State.logIn, action: /Action.logIn) { + LogIn() + } + } +} struct LogInCoordinatorView: View { - let store: Store + let store: Store var body: some View { TCARouter(store) { screen in SwitchStore(screen) { CaseLet( - state: /LogInScreenState.welcome, - action: LogInScreenAction.welcome, + state: /LogInScreen.State.welcome, + action: LogInScreen.Action.welcome, then: WelcomeView.init ) CaseLet( - state: /LogInScreenState.logIn, - action: LogInScreenAction.logIn, + state: /LogInScreen.State.logIn, + action: LogInScreen.Action.logIn, then: LogInView.init ) } @@ -59,34 +54,31 @@ struct LogInCoordinatorView: View { } } -struct LogInCoordinatorState: Equatable, IdentifiedRouterState { - static let initialState = LogInCoordinatorState( - routes: [.root(.welcome(.init()), embedInNavigationView: true)] - ) - - var routes: IdentifiedArrayOf> -} - -enum LogInCoordinatorAction: IdentifiedRouterAction { - case routeAction(ScreenState.ID, action: LogInScreenAction) - case updateRoutes(IdentifiedArrayOf>) -} - -struct LogInCoordinatorEnvironment {} +struct LogInCoordinator: ReducerProtocol { + struct State: Equatable, IdentifiedRouterState { + static let initialState = LogInCoordinator.State( + routes: [.root(.welcome(.init()), embedInNavigationView: true)] + ) + var routes: IdentifiedArrayOf> + } -typealias LogInCoordinatorReducer = Reducer< - LogInCoordinatorState, LogInCoordinatorAction, LogInCoordinatorEnvironment -> + enum Action: IdentifiedRouterAction { + case routeAction(LogInScreen.State.ID, action: LogInScreen.Action) + case updateRoutes(IdentifiedArrayOf>) + } -let logInCoordinatorReducer: LogInCoordinatorReducer = logInScreenReducer - .forEachIdentifiedRoute(environment: { _ in .init() }) - .withRouteReducer(Reducer { state, action, _ in - switch action { - case .routeAction(_, .welcome(.logInTapped)): - state.routes.push(.logIn(.init())) + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .routeAction(_, .welcome(.logInTapped)): + state.routes.push(.logIn(.init())) - default: - break + default: + break + } + return .none + }.forEachRoute { + LogInScreen() } - return .none - }) + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift index 13f4da6..a61de6f 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift @@ -5,13 +5,13 @@ import SwiftUI struct LogInView: View { @State private var name = "" - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in VStack { TextField("Enter name", text: $name) - .padding() + .padding(24) Button("Log in", action: { viewStore.send(.logInTapped(name: name)) }) @@ -22,18 +22,16 @@ struct LogInView: View { } } -enum LogInAction { - case logInTapped(name: String) -} - -struct LogInState: Equatable { - let id = UUID() -} +struct LogIn: ReducerProtocol { + struct State: Equatable { + let id = UUID() + } -struct LogInEnvironment {} + enum Action { + case logInTapped(name: String) + } -let logInReducer = Reducer< - LogInState, LogInAction, LogInEnvironment -> { _, _, _ in - .none + var body: some ReducerProtocol { + EmptyReducer() + } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift index c43f0c1..f641771 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift @@ -3,7 +3,7 @@ import Foundation import SwiftUI struct WelcomeView: View { - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -18,18 +18,16 @@ struct WelcomeView: View { } } -enum WelcomeAction { - case logInTapped -} - -struct WelcomeState: Equatable { - let id = UUID() -} +struct Welcome: ReducerProtocol { + struct State: Equatable { + let id = UUID() + } -struct WelcomeEnvironment {} + enum Action { + case logInTapped + } -let welcomeReducer = Reducer< - WelcomeState, WelcomeAction, WelcomeEnvironment -> { _, _, _ in - .none + var body: some ReducerProtocol { + EmptyReducer() + } } diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift index bca02cf..f04dd0c 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift @@ -1,27 +1,26 @@ -import SwiftUI import ComposableArchitecture +import SwiftUI import TCACoordinators struct IdentifiedCoordinatorView: View { - - let store: Store + let store: Store var body: some View { TCARouter(store) { screen in SwitchStore(screen) { CaseLet( - state: /ScreenState.home, - action: ScreenAction.home, + state: /Screen.State.home, + action: Screen.Action.home, then: HomeView.init ) CaseLet( - state: /ScreenState.numbersList, - action: ScreenAction.numbersList, + state: /Screen.State.numbersList, + action: Screen.Action.numbersList, then: NumbersListView.init ) CaseLet( - state: /ScreenState.numberDetail, - action: ScreenAction.numberDetail, + state: /Screen.State.numberDetail, + action: Screen.Action.numberDetail, then: NumberDetailView.init ) } @@ -29,48 +28,40 @@ struct IdentifiedCoordinatorView: View { } } -struct IdentifiedCoordinatorState: Equatable, IdentifiedRouterState { - - static let initialState = IdentifiedCoordinatorState( - routes: [.root(.home(.init()), embedInNavigationView: true)] - ) - - var routes: IdentifiedArrayOf> -} +struct IdentifiedCoordinator: ReducerProtocol { + struct State: Equatable, IdentifiedRouterState { + static let initialState = State( + routes: [.root(.home(.init()), embedInNavigationView: true)] + ) + + var routes: IdentifiedArrayOf> + } -enum IdentifiedCoordinatorAction: IdentifiedRouterAction { + enum Action: IdentifiedRouterAction { + case routeAction(Screen.State.ID, action: Screen.Action) + case updateRoutes(IdentifiedArrayOf>) + } - case routeAction(ScreenState.ID, action: ScreenAction) - case updateRoutes(IdentifiedArrayOf>) -} - -struct IdentifiedCoordinatorEnvironment {} - -typealias IdentifiedCoordinatorReducer = Reducer< - IdentifiedCoordinatorState, IdentifiedCoordinatorAction, IdentifiedCoordinatorEnvironment -> - -let identifiedCoordinatorReducer: IdentifiedCoordinatorReducer = screenReducer - .forEachIdentifiedRoute(environment: { _ in .init() }) - .withRouteReducer(Reducer { state, action, environment in + var body: some ReducerProtocol { + return Reduce { state, action in switch action { case .routeAction(_, .home(.startTapped)): - state.routes.presentSheet(.numbersList(.init(numbers: Array(0..<4))), embedInNavigationView: true) - + state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true) + case .routeAction(_, .numbersList(.numberSelected(let number))): state.routes.push(.numberDetail(.init(number: number))) - + case .routeAction(_, .numberDetail(.showDouble(let number))): state.routes.presentSheet(.numberDetail(.init(number: number * 2))) - + case .routeAction(_, .numberDetail(.goBackTapped)): state.routes.goBack() - + case .routeAction(_, .numberDetail(.goBackToNumbersList)): return .routeWithDelaysIfUnsupported(state.routes) { - $0.goBackTo(/ScreenState.numbersList) + $0.goBackTo(/Screen.State.numbersList) } - + case .routeAction(_, .numberDetail(.goBackToRootTapped)): return .routeWithDelaysIfUnsupported(state.routes) { $0.goBackToRoot() @@ -80,5 +71,8 @@ let identifiedCoordinatorReducer: IdentifiedCoordinatorReducer = screenReducer break } return .none + }.forEachRoute { + Screen() } - ) + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift b/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift index a0673fe..2492e3b 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift @@ -1,27 +1,26 @@ -import SwiftUI import ComposableArchitecture +import SwiftUI import TCACoordinators struct IndexedCoordinatorView: View { - - let store: Store - + let store: Store + var body: some View { TCARouter(store) { screen in SwitchStore(screen) { CaseLet( - state: /ScreenState.home, - action: ScreenAction.home, + state: /Screen.State.home, + action: Screen.Action.home, then: HomeView.init ) CaseLet( - state: /ScreenState.numbersList, - action: ScreenAction.numbersList, + state: /Screen.State.numbersList, + action: Screen.Action.numbersList, then: NumbersListView.init ) CaseLet( - state: /ScreenState.numberDetail, - action: ScreenAction.numberDetail, + state: /Screen.State.numberDetail, + action: Screen.Action.numberDetail, then: NumberDetailView.init ) } @@ -29,57 +28,51 @@ struct IndexedCoordinatorView: View { } } -enum IndexedCoordinatorAction: IndexedRouterAction { - - case routeAction(Int, action: ScreenAction) - case updateRoutes([Route]) -} - -struct IndexedCoordinatorState: Equatable, IndexedRouterState { - - static let initialState = IndexedCoordinatorState( - routes: [.root(.home(.init()), embedInNavigationView: true)] - ) - - var routes: [Route] -} - -struct IndexedCoordinatorEnvironment {} - -typealias IndexedCoordinatorReducer = Reducer< - IndexedCoordinatorState, IndexedCoordinatorAction, IndexedCoordinatorEnvironment -> +struct IndexedCoordinator: ReducerProtocol { + struct State: Equatable, IndexedRouterState { + static let initialState = State( + routes: [.root(.home(.init()), embedInNavigationView: true)] + ) + + var routes: [Route] + } -let indexedCoordinatorReducer: IndexedCoordinatorReducer = screenReducer - .forEachIndexedRoute(environment: { _ in ScreenEnvironment() }) - .withRouteReducer( - Reducer { state, action, environment in + enum Action: IndexedRouterAction { + case routeAction(Int, action: Screen.Action) + case updateRoutes([Route]) + } + + var body: some ReducerProtocol { + return Reduce { state, action in switch action { case .routeAction(_, .home(.startTapped)): - state.routes.push(.numbersList(.init(numbers: Array(0..<4)))) - + state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true) + case .routeAction(_, .numbersList(.numberSelected(let number))): state.routes.push(.numberDetail(.init(number: number))) - + case .routeAction(_, .numberDetail(.showDouble(let number))): state.routes.presentSheet(.numberDetail(.init(number: number * 2))) - + case .routeAction(_, .numberDetail(.goBackTapped)): state.routes.goBack() - + case .routeAction(_, .numberDetail(.goBackToNumbersList)): return .routeWithDelaysIfUnsupported(state.routes) { - $0.goBackTo(/ScreenState.numbersList) + $0.goBackTo(/Screen.State.numbersList) } - + case .routeAction(_, .numberDetail(.goBackToRootTapped)): return .routeWithDelaysIfUnsupported(state.routes) { $0.goBackToRoot() } - + default: break } return .none + }.forEachRoute { + Screen() } - ) + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift b/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift index 104154c..12c633d 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift @@ -1,60 +1,49 @@ +import ComposableArchitecture import Foundation import SwiftUI -import ComposableArchitecture -enum ScreenAction { - - case home(HomeAction) - case numbersList(NumbersListAction) - case numberDetail(NumberDetailAction) -} +struct Screen: ReducerProtocol { + enum Action { + case home(Home.Action) + case numbersList(NumbersList.Action) + case numberDetail(NumberDetail.Action) + } -enum ScreenState: Equatable, Identifiable { - - case home(HomeState) - case numbersList(NumbersListState) - case numberDetail(NumberDetailState) - - var id: UUID { - switch self { - case .home(let state): - return state.id - case .numbersList(let state): - return state.id - case .numberDetail(let state): - return state.id + enum State: Equatable, Identifiable { + case home(Home.State) + case numbersList(NumbersList.State) + case numberDetail(NumberDetail.State) + + var id: UUID { + switch self { + case .home(let state): + return state.id + case .numbersList(let state): + return state.id + case .numberDetail(let state): + return state.id + } } } + + var body: some ReducerProtocol { + EmptyReducer() + .ifCaseLet(/State.home, action: /Action.home) { + Home() + } + .ifCaseLet(/State.numbersList, action: /Action.numbersList) { + NumbersList() + } + .ifCaseLet(/State.numberDetail, action: /Action.numberDetail) { + NumberDetail() + } + } } -struct ScreenEnvironment {} - -let screenReducer = Reducer.combine( - homeReducer - .pullback( - state: /ScreenState.home, - action: /ScreenAction.home, - environment: { _ in HomeEnvironment() } - ), - numbersListReducer - .pullback( - state: /ScreenState.numbersList, - action: /ScreenAction.numbersList, - environment: { _ in NumbersListEnvironment() } - ), - numberDetailReducer - .pullback( - state: /ScreenState.numberDetail, - action: /ScreenAction.numberDetail, - environment: { _ in NumberDetailEnvironment() } - ) -) - // Home struct HomeView: View { - - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -68,29 +57,24 @@ struct HomeView: View { } } -enum HomeAction { - - case startTapped -} +struct Home: ReducerProtocol { + struct State: Equatable { + let id = UUID() + } -struct HomeState: Equatable { + enum Action { + case startTapped + } - let id = UUID() -} - -struct HomeEnvironment {} - -let homeReducer = Reducer< - HomeState, HomeAction, HomeEnvironment -> { state, action, environment in - return .none + var body: some ReducerProtocol { + EmptyReducer() + } } // NumbersList struct NumbersListView: View { - - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -106,30 +90,25 @@ struct NumbersListView: View { } } -enum NumbersListAction { - - case numberSelected(Int) -} +struct NumbersList: ReducerProtocol { + struct State: Equatable { + let id = UUID() + let numbers: [Int] + } -struct NumbersListState: Equatable { + enum Action { + case numberSelected(Int) + } - let id = UUID() - let numbers: [Int] -} - -struct NumbersListEnvironment {} - -let numbersListReducer = Reducer< - NumbersListState, NumbersListAction, NumbersListEnvironment -> { state, action, environment in - return .none + var body: some ReducerProtocol { + EmptyReducer() + } } // NumberDetail struct NumberDetailView: View { - - let store: Store + let store: Store var body: some View { WithViewStore(store) { viewStore in @@ -159,38 +138,36 @@ struct NumberDetailView: View { } } -enum NumberDetailAction { - - case goBackTapped - case goBackToRootTapped - case goBackToNumbersList - case incrementAfterDelayTapped - case incrementTapped - case showDouble(Int) -} - -struct NumberDetailState: Equatable { - - let id = UUID() - var number: Int -} +struct NumberDetail: ReducerProtocol { + struct State: Equatable { + let id = UUID() + var number: Int + } -struct NumberDetailEnvironment {} + enum Action { + case goBackTapped + case goBackToRootTapped + case goBackToNumbersList + case incrementAfterDelayTapped + case incrementTapped + case showDouble(Int) + } -let numberDetailReducer = Reducer { - state, action, environment in - switch action { - case .goBackToRootTapped, .goBackTapped, .goBackToNumbersList, .showDouble: - return .none - - case .incrementAfterDelayTapped: - return Effect(value: NumberDetailAction.incrementTapped) - .delay(for: 3.0, tolerance: nil, scheduler: DispatchQueue.main, options: nil) - .eraseToEffect() - - case .incrementTapped: - state.number += 1 - return .none + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .goBackToRootTapped, .goBackTapped, .goBackToNumbersList, .showDouble: + return .none + + case .incrementAfterDelayTapped: + return Effect(value: NumberDetail.Action.incrementTapped) + .delay(for: 3.0, tolerance: nil, scheduler: DispatchQueue.main, options: nil) + .eraseToEffect() + + case .incrementTapped: + state.number += 1 + return .none + } + } } } - diff --git a/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift b/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift index f3ab8f7..bb841e2 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift @@ -9,8 +9,7 @@ struct TCACoordinatorsExampleApp: App { MainTabCoordinatorView( store: .init( initialState: .initialState, - reducer: mainTabCoordinatorReducer, - environment: .init() + reducer: MainTabCoordinator() ) ) } @@ -20,88 +19,72 @@ struct TCACoordinatorsExampleApp: App { // MainTabCoordinator struct MainTabCoordinatorView: View { - let store: Store + let store: Store var body: some View { TabView { IndexedCoordinatorView( store: store.scope( - state: \MainTabCoordinatorState.indexed, - action: MainTabCoordinatorAction.indexed + state: \MainTabCoordinator.State.indexed, + action: MainTabCoordinator.Action.indexed ) ).tabItem { Text("Indexed") } IdentifiedCoordinatorView( store: store.scope( - state: \MainTabCoordinatorState.identified, - action: MainTabCoordinatorAction.identified + state: \MainTabCoordinator.State.identified, + action: MainTabCoordinator.Action.identified ) ).tabItem { Text("Identified") } AppCoordinatorView( store: store.scope( - state: \MainTabCoordinatorState.app, - action: MainTabCoordinatorAction.app + state: \MainTabCoordinator.State.app, + action: MainTabCoordinator.Action.app ) ).tabItem { Text("Game") } FormAppCoordinatorView( store: store.scope( - state: \MainTabCoordinatorState.form, - action: MainTabCoordinatorAction.form + state: \MainTabCoordinator.State.form, + action: MainTabCoordinator.Action.form ) ).tabItem { Text("Form") } } } } -enum MainTabCoordinatorAction { - case identified(IdentifiedCoordinatorAction) - case indexed(IndexedCoordinatorAction) - case app(AppCoordinatorAction) - case form(FormAppCoordinatorAction) -} - -struct MainTabCoordinatorState: Equatable { - static let initialState = MainTabCoordinatorState( - identified: .initialState, - indexed: .initialState, - app: .initialState, - form: .initialState - ) - - var identified: IdentifiedCoordinatorState - var indexed: IndexedCoordinatorState - var app: AppCoordinatorState - var form: FormAppCoordinatorState -} +struct MainTabCoordinator: ReducerProtocol { + enum Action { + case identified(IdentifiedCoordinator.Action) + case indexed(IndexedCoordinator.Action) + case app(GameApp.Action) + case form(FormAppCoordinator.Action) + } -struct MainTabCoordinatorEnvironment {} + struct State: Equatable { + static let initialState = State( + identified: .initialState, + indexed: .initialState, + app: .initialState, + form: .initialState + ) -typealias MainTabCoordinatorReducer = Reducer< - MainTabCoordinatorState, MainTabCoordinatorAction, MainTabCoordinatorEnvironment -> + var identified: IdentifiedCoordinator.State + var indexed: IndexedCoordinator.State + var app: GameApp.State + var form: FormAppCoordinator.State + } -let mainTabCoordinatorReducer: MainTabCoordinatorReducer = .combine( - indexedCoordinatorReducer - .pullback( - state: \MainTabCoordinatorState.indexed, - action: /MainTabCoordinatorAction.indexed, - environment: { _ in .init() } - ), - identifiedCoordinatorReducer - .pullback( - state: \MainTabCoordinatorState.identified, - action: /MainTabCoordinatorAction.identified, - environment: { _ in .init() } - ), - appCoordinatorReducer - .pullback( - state: \MainTabCoordinatorState.app, - action: /MainTabCoordinatorAction.app, - environment: { _ in .init() } - ), - formAppCoordinatorReducer - .pullback( - state: \MainTabCoordinatorState.form, - action: /MainTabCoordinatorAction.form, - environment: { _ in .test } - ) -) + var body: some ReducerProtocol { + Scope(state: \.indexed, action: /Action.indexed) { + IndexedCoordinator() + } + Scope(state: \.identified, action: /Action.identified) { + IdentifiedCoordinator() + } + Scope(state: \.app, action: /Action.app) { + GameApp() + } + Scope(state: \.form, action: /Action.form) { + FormAppCoordinator() + } + } +} diff --git a/TCACoordinatorsExample/TCACoordinatorsExampleTests/TCACoordinatorsExampleTests.swift b/TCACoordinatorsExample/TCACoordinatorsExampleTests/TCACoordinatorsExampleTests.swift index 7d1ceee..eba4278 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExampleTests/TCACoordinatorsExampleTests.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExampleTests/TCACoordinatorsExampleTests.swift @@ -2,6 +2,4 @@ import XCTest @testable import TCACoordinatorsExample -class TCACoordinatorsExampleTests: XCTestCase { - -} +class TCACoordinatorsExampleTests: XCTestCase {} diff --git a/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift b/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift index 9d028cd..9f08d92 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift @@ -1,5 +1,3 @@ import XCTest -class TCACoordinatorsExampleUITests: XCTestCase { - -} +class TCACoordinatorsExampleUITests: XCTestCase {} diff --git a/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITestsLaunchTests.swift b/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITestsLaunchTests.swift index 1bcdc3a..4a4ba8a 100644 --- a/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITestsLaunchTests.swift +++ b/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITestsLaunchTests.swift @@ -1,5 +1,3 @@ import XCTest -class TCACoordinatorsExampleUITestsLaunchTests: XCTestCase { - -} +class TCACoordinatorsExampleUITestsLaunchTests: XCTestCase {} diff --git a/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift b/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift new file mode 100644 index 0000000..d44bec5 --- /dev/null +++ b/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift @@ -0,0 +1,124 @@ +import ComposableArchitecture +@testable import TCACoordinators +import XCTest + +@MainActor +final class IdentifiedRouterTests: XCTestCase { + func testActionPropagation() { + let scheduler = DispatchQueue.test + let store = TestStore( + initialState: Parent.State(routes: [.root(.init(id: "first", count: 42)), .sheet(.init(id: "second", count: 11))]), + reducer: Parent(scheduler: scheduler) + ) + store.send(.routeAction("first", action: .increment)) { + $0.routes[id: "first"]?.screen.count += 1 + } + store.send(.routeAction("second", action: .increment)) { + $0.routes[id: "second"]?.screen.count += 1 + } + } + + func testActionCancellation() async { + let scheduler = DispatchQueue.test + let store = TestStore( + initialState: Parent.State( + routes: [ + .root(.init(id: "first", count: 42)), + .sheet(.init(id: "second", count: 11)) + ] + ), + reducer: Parent(scheduler: scheduler) + ) + // Expect increment action after 1 second. + await store.send(.routeAction("second", action: .incrementLaterTapped)) + await scheduler.advance(by: .seconds(1)) + await store.receive(.routeAction("second", action: .increment)) { + $0.routes[id: "second"]?.screen.count += 1 + } + // Expect increment action to be cancelled if screen is removed. + await store.send(.routeAction("second", action: .incrementLaterTapped)) + await store.send(.updateRoutes([.root(.init(id: "first", count: 42))])) { + $0.routes = [.root(.init(id: "first", count: 42))] + } + } + + @available(iOS 16.0, *) + func testWithDelaysIfUnsupported() async throws { + let initialRoutes: IdentifiedArrayOf> = [ + .root(.init(id: "first", count: 1)), + .sheet(.init(id: "second", count: 2)), + .sheet(.init(id: "third", count: 3)) + ] + let scheduler = DispatchQueue.test + let store = TestStore( + initialState: Parent.State(routes: initialRoutes + ), + reducer: Parent(scheduler: scheduler) + ) + await store.send(.goBackToRoot) + await store.receive(.updateRoutes(initialRoutes)) + let firstTwo = IdentifiedArrayOf(initialRoutes.prefix(2)) + await store.receive(.updateRoutes(firstTwo)) { + $0.routes = firstTwo + } + await scheduler.advance(by: .milliseconds(650)) + let firstOne = IdentifiedArrayOf(initialRoutes.prefix(1)) + await store.receive(.updateRoutes(firstOne)) { + $0.routes = firstOne + } + } +} + +private struct Child: ReducerProtocol { + let scheduler: TestSchedulerOf + struct State: Equatable, Identifiable { + var id: String + var count = 0 + } + + enum Action: Equatable { + case incrementLaterTapped + case increment + } + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .increment: + state.count += 1 + return .none + case .incrementLaterTapped: + return .task { + try await scheduler.sleep(for: .seconds(1)) + return .increment + } + } + } + } +} + +private struct Parent: ReducerProtocol { + let scheduler: TestSchedulerOf + struct State: IdentifiedRouterState, Equatable { + var routes: IdentifiedArrayOf> + } + + enum Action: IdentifiedRouterAction, Equatable { + case routeAction(Child.State.ID, action: Child.Action) + case updateRoutes(IdentifiedArrayOf>) + case goBackToRoot + } + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .goBackToRoot: + return .routeWithDelaysIfUnsupported(state.routes, scheduler: scheduler.eraseToAnyScheduler()) { + $0.goBackToRoot() + } + default: + return .none + } + }.forEachRoute(screenReducer: { Child(scheduler: scheduler) }) + } +} diff --git a/Tests/TCACoordinatorsTests/IndexedRouterTests.swift b/Tests/TCACoordinatorsTests/IndexedRouterTests.swift new file mode 100644 index 0000000..8621cba --- /dev/null +++ b/Tests/TCACoordinatorsTests/IndexedRouterTests.swift @@ -0,0 +1,125 @@ +import ComposableArchitecture +@testable import TCACoordinators +import XCTest + +@MainActor +final class IndexedRouterTests: XCTestCase { + func testActionPropagation() { + let scheduler = DispatchQueue.test + let store = TestStore( + initialState: Parent.State(routes: [.root(.init(count: 42)), .sheet(.init(count: 11))]), + reducer: Parent(scheduler: scheduler) + ) + store.send(.routeAction(0, action: .increment)) { + $0.routes[0].screen.count += 1 + } + store.send(.routeAction(1, action: .increment)) { + $0.routes[1].screen.count += 1 + } + } + + func testActionCancellation() async { + let scheduler = DispatchQueue.test + let store = TestStore( + initialState: Parent.State( + routes: [ + .root(.init(count: 42)), + .sheet(.init(count: 11)) + ] + ), + reducer: Parent(scheduler: scheduler) + ) + // Expect increment action after 1 second. + await store.send(.routeAction(1, action: .incrementLaterTapped)) + await scheduler.advance(by: .seconds(1)) + await store.receive(.routeAction(1, action: .increment)) { + $0.routes[1].screen.count += 1 + } + // Expect increment action to be cancelled if screen is removed. + await store.send(.routeAction(1, action: .incrementLaterTapped)) + await store.send(.updateRoutes([.root(.init(count: 42))])) { + $0.routes = [.root(.init(count: 42))] + } + } + + @available(iOS 16.0, *) + func testWithDelaysIfUnsupported() async throws { + let initialRoutes: [Route] = [ + .root(.init(count: 1)), + .sheet(.init(count: 2)), + .sheet(.init(count: 3)) + ] + let scheduler = DispatchQueue.test + let store = TestStore( + initialState: Parent.State(routes: initialRoutes + ), + reducer: Parent(scheduler: scheduler) + ) + await store.send(.goBackToRoot) + await store.receive(.updateRoutes(initialRoutes)) + let firstTwo = Array(initialRoutes.prefix(2)) + await store.receive(.updateRoutes(firstTwo)) { + $0.routes = firstTwo + } + await scheduler.advance(by: .milliseconds(650)) + let firstOne = Array(initialRoutes.prefix(1)) + await store.receive(.updateRoutes(firstOne)) { + $0.routes = firstOne + } + } +} + +private struct Child: ReducerProtocol { + let scheduler: TestSchedulerOf + struct State: Equatable { + var count = 0 + } + + enum Action: Equatable { + case incrementLaterTapped + case increment + } + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .increment: + state.count += 1 + return .none + case .incrementLaterTapped: + return .task { + try await scheduler.sleep(for: .seconds(1)) + return .increment + } + } + } + } +} + +private struct Parent: ReducerProtocol { + struct State: Equatable, IndexedRouterState { + var routes: [Route] + } + + enum Action: IndexedRouterAction, Equatable { + case routeAction(Int, action: Child.Action) + case updateRoutes([Route]) + case goBackToRoot + } + let scheduler: TestSchedulerOf + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .goBackToRoot: + return .routeWithDelaysIfUnsupported(state.routes, scheduler: scheduler.eraseToAnyScheduler()) { + $0.goBackToRoot() + } + default: + return .none + } + }.forEachRoute { + Child(scheduler: scheduler) + } + } +} diff --git a/Tests/TCACoordinatorsTests/TCACoordinatorsTests.swift b/Tests/TCACoordinatorsTests/TCACoordinatorsTests.swift deleted file mode 100644 index 2b7471c..0000000 --- a/Tests/TCACoordinatorsTests/TCACoordinatorsTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import XCTest -@testable import TCACoordinators - -final class TCACoordinatorsTests: XCTestCase { - -}