├── .swift-version
├── Sources
└── TCACoordinators
│ ├── TCACoordinators.swift
│ ├── Collection+safeSubscript.swift
│ ├── Route+Hashable.swift
│ ├── Reducers
│ ├── OnRoutes.swift
│ ├── UpdateRoutesOnInteraction.swift
│ ├── CancelEffectsOnDismiss.swift
│ ├── ForEachReducer.swift
│ ├── ForEachIndexedRoute.swift
│ └── ForEachIdentifiedRoute.swift
│ ├── TCARouter
│ ├── IndexedRouterAction.swift
│ ├── IdentifiedRouterAction.swift
│ ├── TCARouter+IndexedScreen.swift
│ ├── RouterAction.swift
│ ├── TCARouter+IdentifiedScreen.swift
│ ├── UnobservedTCARouter.swift
│ └── TCARouter.swift
│ ├── Deprecations
│ ├── IndexedRouterState.swift
│ └── IdentifiedRouterState.swift
│ ├── IdentifiedArray+RoutableCollection.swift
│ └── Effect+routeWithDelaysIfUnsupported.swift
├── .gitignore
├── TCACoordinatorsExample
├── TCACoordinatorsExample
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── Game
│ │ ├── LogInScreen+StateIdentifiable.swift
│ │ ├── GameViewState.swift
│ │ ├── WelcomeView.swift
│ │ ├── LogInView.swift
│ │ ├── OutcomeView.swift
│ │ ├── LogInCoordinator.swift
│ │ ├── GameCoordinator.swift
│ │ ├── AppCoordinator.swift
│ │ └── GameView.swift
│ ├── Form
│ │ ├── FormScreen+Identifiable.swift
│ │ ├── FormScreen.swift
│ │ ├── Step1.swift
│ │ ├── Step2.swift
│ │ ├── Step3.swift
│ │ ├── FinalScreen.swift
│ │ └── FormAppCoordinator.swift
│ ├── Info.plist
│ ├── IndexedCoordinator.swift
│ ├── IdentifiedCoordinator.swift
│ ├── Screen.swift
│ └── TCACoordinatorsExampleApp.swift
├── Package.swift
├── TCACoordinatorsExampleUITests
│ ├── TCACoordinatorsExampleUITestsLaunchTests.swift
│ └── TCACoordinatorsExampleUITests.swift
├── TCACoordinatorsExampleTests
│ └── TCACoordinatorsExampleTests.swift
└── TCACoordinatorsExample.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
│ ├── xcshareddata
│ └── xcschemes
│ │ └── TCACoordinatorsExample.xcscheme
│ └── project.pbxproj
├── Docs
└── Migration
│ ├── Migrating from 0.11.md
│ └── Migrating from 0.8.md
├── Package.swift
├── LICENSE
├── .swift-format.json
├── .swiftformat
├── Tests
└── TCACoordinatorsTests
│ ├── IndexedRouterTests.swift
│ └── IdentifiedRouterTests.swift
├── Package.resolved
└── README.md
/.swift-version:
--------------------------------------------------------------------------------
1 | 5.10
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCACoordinators.swift:
--------------------------------------------------------------------------------
1 | @_exported import FlowStacks
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm
8 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/Package.swift:
--------------------------------------------------------------------------------
1 | import PackageDescription
2 |
3 | let package = Package(
4 | name: "",
5 | products: [],
6 | dependencies: [],
7 | targets: []
8 | )
9 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITestsLaunchTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | class TCACoordinatorsExampleUITestsLaunchTests: XCTestCase {}
4 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExampleTests/TCACoordinatorsExampleTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | @testable import TCACoordinatorsExample
4 |
5 | class TCACoordinatorsExampleTests: XCTestCase {}
6 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Collection+safeSubscript.swift:
--------------------------------------------------------------------------------
1 | extension Collection {
2 | /// Returns the element at the specified index if it is within bounds, otherwise nil.
3 | subscript(safe index: Index) -> Element? {
4 | indices.contains(index) ? self[index] : nil
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInScreen+StateIdentifiable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension LogInScreen.State: Identifiable {
4 | var id: UUID {
5 | switch self {
6 | case let .welcome(state):
7 | state.id
8 | case let .logIn(state):
9 | state.id
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Route+Hashable.swift:
--------------------------------------------------------------------------------
1 | extension Route: @retroactive Hashable where Screen: Hashable {
2 | public func hash(into hasher: inout Hasher) {
3 | hasher.combine(style)
4 | hasher.combine(embedInNavigationView)
5 | hasher.combine(screen)
6 | }
7 | }
8 |
9 | extension Route: @unchecked @retroactive Sendable where Screen: Sendable { }
10 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameViewState.swift:
--------------------------------------------------------------------------------
1 | extension Game.State {
2 | var gameBoard: Three> {
3 | board.map { $0.map { $0?.label ?? "" } }
4 | }
5 |
6 | var isGameEnabled: Bool {
7 | !board.hasWinner && !board.isFilled
8 | }
9 |
10 | var title: String {
11 | "\(currentPlayerName), place your \(currentPlayer.label)"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Reducers/OnRoutes.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | struct OnRoutes: Reducer {
5 | typealias State = Route
6 | typealias Action = WrappedReducer.Action
7 |
8 | let wrapped: WrappedReducer
9 |
10 | var body: some ReducerOf {
11 | Scope(state: \.screen, action: \.self) {
12 | wrapped
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/IndexedRouterAction.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | /// A ``RouterAction`` that identifies screens by their index in the routes array.
4 | public typealias IndexedRouterAction = RouterAction
5 |
6 | /// A ``RouterAction`` that identifies screens by their index in the routes array.
7 | public typealias IndexedRouterActionOf = RouterAction
8 |
--------------------------------------------------------------------------------
/Docs/Migration/Migrating from 0.11.md:
--------------------------------------------------------------------------------
1 | # Migrating from 0.11
2 |
3 | Version 0.12 of this library introduced an API change to allow it to work with the latest version of the Composable Architecture (>=0.19). This change improves the way the routes store is scoped into individual screen stores. This change introduced a new requirement: that the screen reducer's state conform to `Hashable`. This allows for more efficient scoping using key paths.
4 |
5 | **TL;DR: the screen reducer's' state must now conform to `Hashable`.**
6 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen+Identifiable.swift:
--------------------------------------------------------------------------------
1 | extension FormScreen.State: Identifiable {
2 | var id: ID {
3 | switch self {
4 | case .step1:
5 | .step1
6 | case .step2:
7 | .step2
8 | case .step3:
9 | .step3
10 | case .finalScreen:
11 | .finalScreen
12 | }
13 | }
14 |
15 | enum ID: Identifiable {
16 | case step1
17 | case step2
18 | case step3
19 | case finalScreen
20 |
21 | var id: ID { self }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/IdentifiedRouterAction.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | /// A ``RouterAction`` that identifies Identifiable screens by their identity.
4 | public typealias IdentifiedRouterAction = RouterAction where Screen: Identifiable
5 |
6 | /// A ``RouterAction`` that identifies Identifiable screens by their identity.
7 | public typealias IdentifiedRouterActionOf = RouterAction where R.State: Identifiable
8 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Deprecations/IndexedRouterState.swift:
--------------------------------------------------------------------------------
1 | import FlowStacks
2 | import Foundation
3 |
4 | /// A protocol standardizing naming conventions for state types that contain routes
5 | /// within an `IdentifiedArray`.
6 | @available(*, deprecated, message: "Obsoleted, can be removed from your State type")
7 | public protocol IndexedRouterState {
8 | associatedtype Screen
9 |
10 | /// An array of screens, identified by index, representing a navigation/presentation stack.
11 | var routes: [Route] { get set }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Deprecations/IdentifiedRouterState.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import FlowStacks
3 | import Foundation
4 |
5 | /// A protocol standardizing naming conventions for state types that contain routes
6 | /// within an `IdentifiedArray`.
7 | @available(*, deprecated, message: "Obsoleted, can be removed from your State type")
8 | public protocol IdentifiedRouterState {
9 | associatedtype Screen: Identifiable
10 |
11 | /// An identified array of routes representing a navigation/presentation stack.
12 | var routes: IdentifiedArrayOf> { get set }
13 | }
14 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/WelcomeView.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 | import SwiftUI
4 |
5 | struct WelcomeView: View {
6 | let store: StoreOf
7 |
8 | var body: some View {
9 | VStack {
10 | Text("Welcome").font(.headline)
11 | Button("Log in") {
12 | store.send(.logInTapped)
13 | }
14 | }
15 | .navigationTitle("Welcome")
16 | }
17 | }
18 |
19 | @Reducer
20 | struct Welcome {
21 | struct State: Hashable {
22 | let id = UUID()
23 | }
24 |
25 | enum Action {
26 | case logInTapped
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleURLTypes
6 |
7 |
8 | CFBundleTypeRole
9 | Viewer
10 | CFBundleURLName
11 | uk.johnpatrickmorgan.TCACoordinatorsExample
12 | CFBundleURLSchemes
13 |
14 | tcacoordinators
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/TCARouter+IndexedScreen.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import FlowStacks
3 | import Foundation
4 | import SwiftUI
5 |
6 | public extension TCARouter where ID == Int {
7 | /// Convenience initializer for managing screens in an `Array`, identified by index.
8 | init(
9 | _ store: Store<[Route], IndexedRouterAction>,
10 | @ViewBuilder screenContent: @escaping (Store) -> ScreenContent
11 | ) {
12 | self.init(
13 | store: store,
14 | identifier: { $1 },
15 | screenContent: screenContent
16 | )
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInView.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 | import SwiftUI
4 |
5 | struct LogInView: View {
6 | @State private var name = ""
7 |
8 | let store: StoreOf
9 |
10 | var body: some View {
11 | VStack {
12 | TextField("Enter name", text: $name)
13 | .padding(24)
14 | Button("Log in") {
15 | store.send(.logInTapped(name: name))
16 | }
17 | .disabled(name.isEmpty)
18 | }
19 | .navigationTitle("LogIn")
20 | }
21 | }
22 |
23 | @Reducer
24 | struct LogIn {
25 | struct State: Hashable {
26 | let id = UUID()
27 | }
28 |
29 | enum Action {
30 | case logInTapped(name: String)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/RouterAction.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import FlowStacks
3 |
4 | /// A special action type used in coordinators, which ensures screen-level actions are dispatched to the correct screen reducer,
5 | /// and allows routes to be updated when a user navigates back.
6 | @CasePathable
7 | public enum RouterAction {
8 | case updateRoutes(_ routes: [Route])
9 | case routeAction(id: ID, action: ScreenAction)
10 | }
11 |
12 | public extension RouterAction.AllCasePaths {
13 | subscript(id id: ID) -> AnyCasePath {
14 | AnyCasePath(
15 | embed: { .routeAction(id: id, action: $0) },
16 | extract: {
17 | guard case .routeAction(id, let action) = $0 else { return nil }
18 | return action
19 | }
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/TCARouter+IdentifiedScreen.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import FlowStacks
3 | import Foundation
4 | import SwiftUI
5 |
6 | public extension TCARouter where Screen: Identifiable {
7 | /// Convenience initializer for managing screens in an `IdentifiedArray`.
8 | init(
9 | _ store: Store>, IdentifiedRouterAction>,
10 | @ViewBuilder screenContent: @escaping (Store) -> ScreenContent
11 | ) where Screen.ID == ID {
12 | self.init(
13 | store: store.scope(state: \.elements, action: \.self),
14 | identifier: { state, _ in state.id },
15 | screenContent: screenContent
16 | )
17 | }
18 | }
19 |
20 | extension Route: @retroactive Identifiable where Screen: Identifiable {
21 | public var id: Screen.ID { screen.id }
22 | }
23 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormScreen.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | @DependencyClient
5 | struct FormScreenEnvironment: DependencyKey, Sendable {
6 | var getOccupations: @Sendable () async -> [String] = { [] }
7 | var submit: @Sendable (APIModel) async -> Bool = { _ in false }
8 |
9 | static let liveValue = FormScreenEnvironment(
10 | getOccupations: {
11 | [
12 | "iOS Developer",
13 | "Android Developer",
14 | "Web Developer",
15 | "Project Manager",
16 | "Designer",
17 | "The Big Cheese",
18 | ]
19 | },
20 | submit: { _ in true }
21 | )
22 | }
23 |
24 | @Reducer(state: .equatable, .hashable)
25 | enum FormScreen {
26 | case step1(Step1)
27 | case step2(Step2)
28 | case step3(Step3)
29 | case finalScreen(FinalScreen)
30 | }
31 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.9
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "TCACoordinators",
7 | platforms: [
8 | .iOS(.v13), .watchOS(.v7), .macOS(.v11), .tvOS(.v13),
9 | ],
10 | products: [
11 | .library(
12 | name: "TCACoordinators",
13 | targets: ["TCACoordinators"]
14 | ),
15 | ],
16 | dependencies: [
17 | .package(url: "https://github.com/johnpatrickmorgan/FlowStacks", "0.3.6" ..< "0.6.0"),
18 | .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.12.0"),
19 | ],
20 | targets: [
21 | .target(
22 | name: "TCACoordinators",
23 | dependencies: [
24 | .product(name: "FlowStacks", package: "FlowStacks"),
25 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
26 | ]
27 | ),
28 | .testTarget(
29 | name: "TCACoordinatorsTests",
30 | dependencies: ["TCACoordinators"]
31 | ),
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step1.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 |
4 | @Reducer
5 | struct Step1 {
6 | @ObservableState
7 | public struct State: Hashable {
8 | var firstName: String = ""
9 | var lastName: String = ""
10 | }
11 |
12 | public enum Action: Equatable, BindableAction {
13 | case binding(BindingAction)
14 | case nextButtonTapped
15 | }
16 |
17 | var body: some ReducerOf {
18 | BindingReducer()
19 | }
20 | }
21 |
22 | struct Step1View: View {
23 | @Perception.Bindable var store: StoreOf
24 |
25 | var body: some View {
26 | WithPerceptionTracking {
27 | Form {
28 | TextField("First Name", text: $store.firstName)
29 | TextField("Last Name", text: $store.lastName)
30 |
31 | Section {
32 | Button("Next") {
33 | store.send(.nextButtonTapped)
34 | }
35 | }
36 | }
37 | .navigationTitle("Step 1")
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Reducers/UpdateRoutesOnInteraction.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | extension Reducer {
5 | @ReducerBuilder
6 | func updatingRoutesOnInteraction(
7 | updateRoutes: CaseKeyPath,
8 | toLocalState: WritableKeyPath
9 | ) -> some ReducerOf where Action: CasePathable {
10 | self
11 | UpdateRoutesOnInteraction(
12 | wrapped: self,
13 | updateRoutes: updateRoutes,
14 | toLocalState: toLocalState
15 | )
16 | }
17 | }
18 |
19 | struct UpdateRoutesOnInteraction: Reducer where WrappedReducer.Action: CasePathable {
20 | let wrapped: WrappedReducer
21 | let updateRoutes: CaseKeyPath
22 | let toLocalState: WritableKeyPath
23 |
24 | var body: some ReducerOf {
25 | Reduce { state, action in
26 | if let routes = action[case: updateRoutes] {
27 | state[keyPath: toLocalState] = routes
28 | }
29 | return .none
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/OutcomeView.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import ComposableArchitecture
3 | import SwiftUI
4 | import UIKit
5 |
6 | struct OutcomeView: View {
7 | let store: StoreOf
8 |
9 | var body: some View {
10 | WithPerceptionTracking {
11 | VStack {
12 | if let winner = store.winnerName {
13 | Text("Congratulations \(winner)!")
14 | } else {
15 | Text("The game ended in a draw")
16 | }
17 | Button("New game") {
18 | store.send(.newGameTapped)
19 | }
20 | }
21 | .navigationTitle("Game over")
22 | .navigationBarBackButtonHidden()
23 | }
24 | }
25 | }
26 |
27 | @Reducer
28 | struct Outcome {
29 | @ObservableState
30 | struct State: Hashable {
31 | let id = UUID()
32 | var winner: Player?
33 | var oPlayerName: String
34 | var xPlayerName: String
35 |
36 | var winnerName: String? {
37 | guard let winner else { return nil }
38 | return winner == .x ? xPlayerName : oPlayerName
39 | }
40 | }
41 |
42 | enum Action: Equatable {
43 | case newGameTapped
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2021 johnpatrickmorgan
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step2.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 |
4 | struct Step2View: View {
5 | @Perception.Bindable var store: StoreOf
6 |
7 | var body: some View {
8 | WithPerceptionTracking {
9 | Form {
10 | Section {
11 | DatePicker(
12 | "Date of Birth",
13 | selection: $store.dateOfBirth,
14 | in: ...Date.now,
15 | displayedComponents: .date
16 | )
17 | .datePickerStyle(.graphical)
18 | } header: {
19 | Text("Date of Birth")
20 | }
21 |
22 | Button("Next") {
23 | store.send(.nextButtonTapped)
24 | }
25 | }
26 | .navigationTitle("Step 2")
27 | }
28 | }
29 | }
30 |
31 | @Reducer
32 | struct Step2 {
33 | @ObservableState
34 | public struct State: Hashable {
35 | var dateOfBirth: Date = .now
36 | }
37 |
38 | public enum Action: Equatable, BindableAction {
39 | case binding(BindingAction)
40 | case nextButtonTapped
41 | }
42 |
43 | var body: some ReducerOf {
44 | BindingReducer()
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/LogInCoordinator.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | @Reducer(state: .equatable, .hashable)
6 | enum LogInScreen {
7 | case welcome(Welcome)
8 | case logIn(LogIn)
9 | }
10 |
11 | struct LogInCoordinatorView: View {
12 | let store: StoreOf
13 |
14 | var body: some View {
15 | TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
16 | switch screen.case {
17 | case let .welcome(store):
18 | WelcomeView(store: store)
19 |
20 | case let .logIn(store):
21 | LogInView(store: store)
22 | }
23 | }
24 | }
25 | }
26 |
27 | @Reducer
28 | struct LogInCoordinator {
29 | @ObservableState
30 | struct State: Equatable, Sendable {
31 | static let initialState = LogInCoordinator.State(
32 | routes: [.root(.welcome(.init()), embedInNavigationView: true)]
33 | )
34 | var routes: IdentifiedArrayOf>
35 | }
36 |
37 | enum Action {
38 | case router(IdentifiedRouterActionOf)
39 | }
40 |
41 | var body: some ReducerOf {
42 | Reduce { state, action in
43 | switch action {
44 | case .router(.routeAction(_, .welcome(.logInTapped))):
45 | state.routes.push(.logIn(.init()))
46 |
47 | default:
48 | break
49 | }
50 | return .none
51 | }
52 | .forEachRoute(\.routes, action: \.router)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/UnobservedTCARouter.swift:
--------------------------------------------------------------------------------
1 | @_spi(Internals) import ComposableArchitecture
2 | import FlowStacks
3 | import Foundation
4 | import SwiftUI
5 |
6 | /// UnobservedTCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented.
7 | /// The TCARouter translates that collection into a hierarchy of SwiftUI views, and updates it when the user navigates back.
8 | /// The unobserved router is used when the Screen does not conform to ObservableState.
9 | struct UnobservedTCARouter<
10 | Screen: Hashable,
11 | ScreenAction,
12 | ID: Hashable,
13 | ScreenContent: View
14 | >: View {
15 | let store: Store<[Route], RouterAction>
16 | let identifier: (Screen, Int) -> ID
17 | let screenContent: (Store) -> ScreenContent
18 |
19 | init(
20 | store: Store<[Route], RouterAction>,
21 | identifier: @escaping (Screen, Int) -> ID,
22 | @ViewBuilder screenContent: @escaping (Store) -> ScreenContent
23 | ) {
24 | self.store = store
25 | self.identifier = identifier
26 | self.screenContent = screenContent
27 | }
28 |
29 | func scopedStore(index: Int, screen: Screen) -> Store {
30 | store.scope(
31 | state: \.[index, defaultingTo: screen],
32 | action: \.[id: identifier(screen, index)]
33 | )
34 | }
35 |
36 | var body: some View {
37 | WithViewStore(store, observe: { $0 }) { viewStore in
38 | Router(
39 | viewStore
40 | .binding(
41 | get: { $0 },
42 | send: RouterAction.updateRoutes
43 | ),
44 | buildView: { screen, index in
45 | screenContent(scopedStore(index: index, screen: screen))
46 | }
47 | )
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameCoordinator.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | struct GameCoordinatorView: View {
6 | let store: StoreOf
7 |
8 | var body: some View {
9 | TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
10 | switch screen.case {
11 | case let .game(store):
12 | GameView(store: store)
13 | case let .outcome(store):
14 | OutcomeView(store: store)
15 | }
16 | }
17 | }
18 | }
19 |
20 | @Reducer(state: .equatable, .hashable)
21 | enum GameScreen {
22 | case game(Game)
23 | case outcome(Outcome)
24 | }
25 |
26 | @Reducer
27 | struct GameCoordinator {
28 | struct State: Equatable, Sendable {
29 | static func initialState(playerName: String = "") -> Self {
30 | Self(
31 | routes: [.root(.game(.init(oPlayerName: "Opponent", xPlayerName: playerName.isEmpty ? "Player" : playerName)), embedInNavigationView: true)]
32 | )
33 | }
34 |
35 | var routes: [Route]
36 | }
37 |
38 | enum Action {
39 | case router(IndexedRouterActionOf)
40 | }
41 |
42 | var body: some ReducerOf {
43 | Reduce { state, action in
44 | guard case let .game(game) = state.routes.first?.screen else { return .none }
45 | switch action {
46 | case .router(.routeAction(id: _, action: .outcome(.newGameTapped))):
47 | state.routes = [.root(.game(.init(oPlayerName: game.xPlayerName, xPlayerName: game.oPlayerName)), embedInNavigationView: true)]
48 | case .router(.routeAction(id: _, action: .game(.gameCompleted(let winner)))):
49 | state.routes.push(.outcome(.init(winner: winner, oPlayerName: game.oPlayerName, xPlayerName: game.xPlayerName)))
50 | default:
51 | break
52 | }
53 | return .none
54 | }
55 | .forEachRoute(\.routes, action: \.router)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/IdentifiedArray+RoutableCollection.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import FlowStacks
3 | import Foundation
4 |
5 | extension IdentifiedArray: @retroactive RoutableCollection {
6 | public mutating func _append(element: Element) {
7 | append(element)
8 | }
9 | }
10 |
11 | public extension RoutableCollection where Element: RouteProtocol {
12 | /// Goes back to the topmost (most recently shown) screen in the stack
13 | /// that matches the given case path. If no screens satisfy the condition,
14 | /// the routes will be unchanged.
15 | /// - Parameter condition: The predicate indicating which screen to pop to.
16 | /// - Returns: A `Bool` indicating whether a screen was found.
17 | @discardableResult
18 | mutating func goBackTo(_ screenCasePath: AnyCasePath) -> Bool {
19 | goBackTo(where: { screenCasePath.extract(from: $0.screen) != nil })
20 | }
21 |
22 | @discardableResult
23 | mutating func goBackTo(_ screenCasePath: CaseKeyPath) -> Bool
24 | where Element.Screen: CasePathable
25 | {
26 | goBackTo(where: { $0.screen[case: screenCasePath] != nil })
27 | }
28 |
29 | /// Pops to the topmost (most recently shown) screen in the stack
30 | /// that matches the given case path. If no screens satisfy the condition,
31 | /// the routes will be unchanged. Only screens that have been pushed will
32 | /// be popped.
33 | /// - Parameter condition: The predicate indicating which screen to pop to.
34 | /// - Returns: A `Bool` indicating whether a screen was found.
35 | @discardableResult
36 | mutating func popTo(_ screenCasePath: AnyCasePath) -> Bool {
37 | popTo(where: { screenCasePath.extract(from: $0.screen) != nil })
38 | }
39 |
40 | @discardableResult
41 | mutating func popTo(_ screenCasePath: CaseKeyPath) -> Bool
42 | where Element.Screen: CasePathable
43 | {
44 | popTo(where: { $0.screen[case: screenCasePath] != nil })
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/.swift-format.json:
--------------------------------------------------------------------------------
1 | {
2 | "fileScopedDeclarationPrivacy" : {
3 | "accessLevel" : "private"
4 | },
5 | "indentation" : {
6 | "spaces" : 2
7 | },
8 | "indentConditionalCompilationBlocks" : true,
9 | "indentSwitchCaseLabels" : false,
10 | "lineBreakAroundMultilineExpressionChainComponents" : false,
11 | "lineBreakBeforeControlFlowKeywords" : false,
12 | "lineBreakBeforeEachArgument" : false,
13 | "lineBreakBeforeEachGenericRequirement" : false,
14 | "lineLength" : 100,
15 | "maximumBlankLines" : 1,
16 | "prioritizeKeepingFunctionOutputTogether" : false,
17 | "respectsExistingLineBreaks" : true,
18 | "rules" : {
19 | "AllPublicDeclarationsHaveDocumentation" : false,
20 | "AlwaysUseLowerCamelCase" : true,
21 | "AmbiguousTrailingClosureOverload" : true,
22 | "BeginDocumentationCommentWithOneLineSummary" : false,
23 | "DoNotUseSemicolons" : true,
24 | "DontRepeatTypeInStaticProperties" : true,
25 | "FileScopedDeclarationPrivacy" : true,
26 | "FullyIndirectEnum" : true,
27 | "GroupNumericLiterals" : true,
28 | "IdentifiersMustBeASCII" : true,
29 | "NeverForceUnwrap" : false,
30 | "NeverUseForceTry" : false,
31 | "NeverUseImplicitlyUnwrappedOptionals" : false,
32 | "NoAccessLevelOnExtensionDeclaration" : false,
33 | "NoBlockComments" : true,
34 | "NoCasesWithOnlyFallthrough" : true,
35 | "NoEmptyTrailingClosureParentheses" : true,
36 | "NoLabelsInCasePatterns" : true,
37 | "NoLeadingUnderscores" : false,
38 | "NoParensAroundConditions" : true,
39 | "NoVoidReturnOnFunctionSignature" : true,
40 | "OneCasePerLine" : true,
41 | "OneVariableDeclarationPerLine" : true,
42 | "OnlyOneTrailingClosureArgument" : true,
43 | "OrderedImports" : false,
44 | "ReturnVoidInsteadOfEmptyTuple" : true,
45 | "UseLetInEveryBoundCaseVariable" : true,
46 | "UseShorthandTypeNames" : true,
47 | "UseSingleLinePropertyGetter" : true,
48 | "UseSynthesizedInitializer" : true,
49 | "UseTripleSlashForDocumentationComments" : true,
50 | "ValidateDocumentationComments" : false
51 | },
52 | "tabWidth" : 8,
53 | "version" : 1
54 | }
55 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/AppCoordinator.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | // This coordinator shows one of two child coordinators, depending on if logged in. It
6 | // animates a transition between the two child coordinators.
7 | struct AppCoordinatorView: View {
8 | let store: StoreOf
9 |
10 | var body: some View {
11 | WithPerceptionTracking {
12 | VStack {
13 | if store.isLoggedIn {
14 | GameCoordinatorView(store: store.scope(state: \.game, action: \.game))
15 | .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
16 | } else {
17 | LogInCoordinatorView(store: store.scope(state: \.logIn, action: \.logIn))
18 | .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
19 | }
20 | }
21 | .animation(.default, value: store.isLoggedIn)
22 | }
23 | }
24 | }
25 |
26 | @Reducer
27 | struct GameApp {
28 | @ObservableState
29 | struct State: Equatable, Sendable {
30 | static let initialState = State(logIn: .initialState, game: .initialState(), isLoggedIn: false)
31 |
32 | var logIn: LogInCoordinator.State
33 | var game: GameCoordinator.State
34 |
35 | var isLoggedIn: Bool
36 | }
37 |
38 | enum Action {
39 | case logIn(LogInCoordinator.Action)
40 | case game(GameCoordinator.Action)
41 | }
42 |
43 | var body: some ReducerOf {
44 | Scope(state: \.logIn, action: \.logIn) {
45 | LogInCoordinator()
46 | }
47 | Scope(state: \.game, action: \.game) {
48 | GameCoordinator()
49 | }
50 | Reduce { state, action in
51 | switch action {
52 | case let .logIn(.router(.routeAction(_, .logIn(.logInTapped(name))))):
53 | state.game = .initialState(playerName: name)
54 | state.isLoggedIn = true
55 | case .game(.router(.routeAction(_, .game(.logOutButtonTapped)))):
56 | state.logIn = .initialState
57 | state.isLoggedIn = false
58 | default:
59 | break
60 | }
61 | return .none
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | --acronyms ID,URL,UUID
2 | --allman false
3 | --anonymousforeach convert
4 | --assetliterals visual-width
5 | --asynccapturing
6 | --beforemarks
7 | --binarygrouping 4,8
8 | --categorymark "MARK: %c"
9 | --classthreshold 0
10 | --closingparen balanced
11 | --closurevoid remove
12 | --commas always
13 | --conflictmarkers reject
14 | --decimalgrouping 3,6
15 | --doccomments before-declarations
16 | --elseposition same-line
17 | --emptybraces no-space
18 | --enumnamespaces always
19 | --enumthreshold 0
20 | --exponentcase lowercase
21 | --exponentgrouping disabled
22 | --extensionacl on-extension
23 | --extensionlength 0
24 | --extensionmark "MARK: - %t + %c"
25 | --fractiongrouping disabled
26 | --fragment false
27 | --funcattributes preserve
28 | --generictypes
29 | --groupedextension "MARK: %c"
30 | --guardelse auto
31 | --header ignore
32 | --hexgrouping 4,8
33 | --hexliteralcase uppercase
34 | --ifdef indent
35 | --importgrouping alpha
36 | --indent 2
37 | --indentcase false
38 | --indentstrings false
39 | --lifecycle
40 | --lineaftermarks true
41 | --linebreaks lf
42 | --markcategories true
43 | --markextensions always
44 | --marktypes always
45 | --maxwidth none
46 | --modifierorder
47 | --nevertrailing
48 | --nospaceoperators
49 | --nowrapoperators
50 | --octalgrouping 4,8
51 | --onelineforeach ignore
52 | --operatorfunc spaced
53 | --organizetypes actor,class,enum,struct
54 | --patternlet hoist
55 | --ranges spaced
56 | --redundanttype infer-locals-only
57 | --self remove
58 | --selfrequired
59 | --semicolons inline
60 | --shortoptionals except-properties
61 | --smarttabs enabled
62 | --someany true
63 | --stripunusedargs always
64 | --structthreshold 0
65 | --tabwidth unspecified
66 | --throwcapturing
67 | --trailingclosures
68 | --trimwhitespace always
69 | --typeattributes preserve
70 | --typeblanklines remove
71 | --typemark "MARK: - %t"
72 | --varattributes preserve
73 | --voidtype void
74 | --wraparguments preserve
75 | --wrapcollections preserve
76 | --wrapconditions preserve
77 | --wrapeffects preserve
78 | --wrapenumcases always
79 | --wrapparameters default
80 | --wrapreturntype preserve
81 | --wrapternary default
82 | --wraptypealiases preserve
83 | --xcodeindentation disabled
84 | --yodaswap always
85 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/IndexedCoordinator.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | struct IndexedCoordinatorView: View {
6 | @State var store: StoreOf
7 |
8 | var body: some View {
9 | TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
10 | switch screen.case {
11 | case let .home(store):
12 | HomeView(store: store)
13 |
14 | case let .numbersList(store):
15 | NumbersListView(store: store)
16 |
17 | case let .numberDetail(store):
18 | NumberDetailView(store: store)
19 | }
20 | }
21 | }
22 | }
23 |
24 | @Reducer
25 | struct IndexedCoordinator {
26 | @ObservableState
27 | struct State: Equatable, Sendable {
28 | static let initialState = State(
29 | routes: [.root(.home(.init()), embedInNavigationView: true)]
30 | )
31 |
32 | var routes: [Route]
33 | }
34 |
35 | enum Action {
36 | case router(IndexedRouterActionOf)
37 | }
38 |
39 | var body: some ReducerOf {
40 | Reduce { state, action in
41 | switch action {
42 | case .router(.routeAction(_, .home(.startTapped))):
43 | state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true)
44 |
45 | case let .router(.routeAction(_, .numbersList(.numberSelected(number)))):
46 | state.routes.push(.numberDetail(.init(number: number)))
47 |
48 | case let .router(.routeAction(_, .numberDetail(.showDouble(number)))):
49 | state.routes.presentSheet(.numberDetail(.init(number: number * 2)), embedInNavigationView: true)
50 |
51 | case .router(.routeAction(_, .numberDetail(.goBackTapped))):
52 | state.routes.goBack()
53 |
54 | case .router(.routeAction(_, .numberDetail(.goBackToNumbersList))):
55 | return .routeWithDelaysIfUnsupported(state.routes, action: \.router) {
56 | $0.goBackTo(\.numbersList)
57 | }
58 |
59 | case .router(.routeAction(_, .numberDetail(.goBackToRootTapped))):
60 | return .routeWithDelaysIfUnsupported(state.routes, action: \.router) {
61 | $0.goBackToRoot()
62 | }
63 |
64 | default:
65 | break
66 | }
67 | return .none
68 | }
69 | .forEachRoute(\.routes, action: \.router)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExampleUITests/TCACoordinatorsExampleUITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | class TCACoordinatorsExampleUITests: XCTestCase {
4 | override func setUpWithError() throws {
5 | continueAfterFailure = false
6 | }
7 |
8 | func testIdentifiedCoordinator() {
9 | launchAndRunTests(tabTitle: "Identified", app: XCUIApplication())
10 | }
11 |
12 | func testIndexedCoordinator() {
13 | launchAndRunTests(tabTitle: "Indexed", app: XCUIApplication())
14 | }
15 |
16 | func launchAndRunTests(tabTitle: String, app: XCUIApplication) {
17 | let navigationTimeout = 1.5
18 | app.launch()
19 |
20 | XCTAssertTrue(app.tabBars.buttons[tabTitle].waitForExistence(timeout: 3))
21 | app.tabBars.buttons[tabTitle].tap()
22 |
23 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2))
24 | app.buttons["Start"].tap()
25 |
26 | XCTAssertTrue(app.navigationBars["Numbers"].waitForExistence(timeout: navigationTimeout))
27 |
28 | app.buttons["2"].tap()
29 | XCTAssertTrue(app.navigationBars["Number 2"].waitForExistence(timeout: navigationTimeout))
30 |
31 | app.buttons["Increment after delay"].tap()
32 | app.buttons["Show double (4)"].tap()
33 | XCTAssertTrue(app.navigationBars["Number 4"].waitForExistence(timeout: navigationTimeout))
34 |
35 | // Ensures increment will have happened off-screen.
36 | Thread.sleep(forTimeInterval: 3)
37 |
38 | app.navigationBars["Number 4"].swipeSheetDown()
39 | XCTAssertTrue(app.navigationBars["Number 3"].waitForExistence(timeout: navigationTimeout))
40 |
41 | app.buttons["Show double (6)"].tap()
42 | XCTAssertTrue(app.navigationBars["Number 6"].waitForExistence(timeout: navigationTimeout))
43 |
44 | app.buttons["Show double (12)"].tap()
45 | XCTAssertTrue(app.navigationBars["Number 12"].waitForExistence(timeout: navigationTimeout))
46 |
47 | app.buttons["Go back to root from 12"].tap()
48 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout * 3))
49 | }
50 | }
51 |
52 | extension XCUIElement {
53 | func swipeSheetDown() {
54 | if #available(iOS 17.0, *) {
55 | // This doesn't work in iOS 16
56 | self.swipeDown(velocity: .fast)
57 | } else {
58 | let start = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
59 | let end = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 5))
60 | start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0.0)
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/Step3.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 |
4 | struct Step3View: View {
5 | let store: StoreOf
6 |
7 | var body: some View {
8 | WithPerceptionTracking {
9 | Form {
10 | Section {
11 | if !store.occupations.isEmpty {
12 | List(store.occupations, id: \.self) { occupation in
13 | Button {
14 | store.send(.selectOccupation(occupation))
15 | } label: {
16 | HStack {
17 | WithPerceptionTracking {
18 | Text(occupation)
19 |
20 | Spacer()
21 |
22 | if let selected = store.selectedOccupation, selected == occupation {
23 | Image(systemName: "checkmark")
24 | }
25 | }
26 | }
27 | }
28 | .buttonStyle(.plain)
29 | }
30 | } else {
31 | ProgressView()
32 | .progressViewStyle(.automatic)
33 | }
34 | } header: {
35 | Text("Jobs")
36 | }
37 |
38 | Button("Next") {
39 | store.send(.nextButtonTapped)
40 | }
41 | }
42 | .onAppear {
43 | store.send(.getOccupations)
44 | }
45 | .navigationTitle("Step 3")
46 | }
47 | }
48 | }
49 |
50 | @Reducer
51 | struct Step3 {
52 | @ObservableState
53 | struct State: Hashable {
54 | var selectedOccupation: String?
55 | var occupations: [String] = []
56 | }
57 |
58 | enum Action: Equatable {
59 | case getOccupations
60 | case receiveOccupations([String])
61 | case selectOccupation(String)
62 | case nextButtonTapped
63 | }
64 |
65 | @Dependency(FormScreenEnvironment.self) var environment
66 |
67 | var body: some ReducerOf {
68 | Reduce { state, action in
69 | switch action {
70 | case .getOccupations:
71 | return .run { send in
72 | await send(.receiveOccupations(environment.getOccupations()))
73 | }
74 |
75 | case let .receiveOccupations(occupations):
76 | state.occupations = occupations
77 | return .none
78 |
79 | case let .selectOccupation(occupation):
80 | if state.occupations.contains(occupation) {
81 | state.selectedOccupation = state.selectedOccupation == occupation ? nil : occupation
82 | }
83 |
84 | return .none
85 |
86 | case .nextButtonTapped:
87 | return .none
88 | }
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/TCARouter/TCARouter.swift:
--------------------------------------------------------------------------------
1 | @_spi(Internals) import ComposableArchitecture
2 | import FlowStacks
3 | import SwiftUI
4 |
5 | /// TCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented.
6 | /// The TCARouter translates that collection into a hierarchy of SwiftUI views, and updates it when the user navigates back.
7 | public struct TCARouter<
8 | Screen: Hashable,
9 | ScreenAction,
10 | ID: Hashable,
11 | ScreenContent: View
12 | >: View {
13 | @Perception.Bindable private var store: Store<[Route], RouterAction>
14 | let identifier: (Screen, Int) -> ID
15 | let screenContent: (Store) -> ScreenContent
16 |
17 | public init(
18 | store: Store<[Route], RouterAction>,
19 | identifier: @escaping (Screen, Int) -> ID,
20 | @ViewBuilder screenContent: @escaping (Store) -> ScreenContent
21 | ) {
22 | self.store = store
23 | self.identifier = identifier
24 | self.screenContent = screenContent
25 | }
26 |
27 | private func scopedStore(index: Int, screen: Screen) -> Store {
28 | store.scope(
29 | state: \.[index, defaultingTo: screen],
30 | action: \.[id: identifier(screen, index)]
31 | )
32 | }
33 |
34 | public var body: some View {
35 | if Screen.self is ObservableState.Type {
36 | WithPerceptionTracking {
37 | Router(
38 | $store[],
39 | buildView: { screen, index in
40 | WithPerceptionTracking {
41 | screenContent(scopedStore(index: index, screen: screen))
42 | }
43 | }
44 | )
45 | }
46 | } else {
47 | UnobservedTCARouter(store: store, identifier: identifier, screenContent: screenContent)
48 | }
49 | }
50 | }
51 |
52 | private extension Store {
53 | subscript() -> [Route]
54 | where State == [Route], Action == RouterAction
55 | {
56 | get { currentState }
57 | set {
58 | send(.updateRoutes(newValue))
59 | }
60 | }
61 |
62 | subscript(index: Int, defaultingTo defaultScreen: Screen) -> Screen
63 | where State == [Route], Action == RouterAction
64 | {
65 | guard currentState.indices.contains(index) else { return defaultScreen }
66 | return currentState[index].screen
67 | }
68 | }
69 |
70 | extension Array where Element: RouteProtocol {
71 | subscript(index: Int, defaultingTo defaultScreen: Element.Screen) -> Element.Screen {
72 | guard indices.contains(index) else { return defaultScreen }
73 | return self[index].screen
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/IdentifiedCoordinator.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | struct IdentifiedCoordinatorView: View {
6 | @State var store: StoreOf
7 |
8 | var body: some View {
9 | TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
10 | switch screen.case {
11 | case let .home(store):
12 | HomeView(store: store)
13 |
14 | case let .numbersList(store):
15 | NumbersListView(store: store)
16 |
17 | case let .numberDetail(store):
18 | NumberDetailView(store: store)
19 | }
20 | }
21 | }
22 | }
23 |
24 | extension Screen.State: Identifiable {
25 | var id: UUID {
26 | switch self {
27 | case let .home(state):
28 | state.id
29 | case let .numbersList(state):
30 | state.id
31 | case let .numberDetail(state):
32 | state.id
33 | }
34 | }
35 | }
36 |
37 | @Reducer
38 | struct IdentifiedCoordinator {
39 | enum Deeplink {
40 | case showNumber(Int)
41 | }
42 |
43 | @ObservableState
44 | struct State: Equatable, Sendable {
45 | static let initialState = State(
46 | routes: [.root(.home(.init()), embedInNavigationView: true)]
47 | )
48 |
49 | var routes: IdentifiedArrayOf>
50 | }
51 |
52 | enum Action {
53 | case router(IdentifiedRouterActionOf)
54 | }
55 |
56 | var body: some ReducerOf {
57 | Reduce { state, action in
58 | switch action {
59 | case .router(.routeAction(_, .home(.startTapped))):
60 | state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true)
61 |
62 | case let .router(.routeAction(_, .numbersList(.numberSelected(number)))):
63 | state.routes.push(.numberDetail(.init(number: number)))
64 |
65 | case let .router(.routeAction(_, .numberDetail(.showDouble(number)))):
66 | state.routes.presentSheet(.numberDetail(.init(number: number * 2)), embedInNavigationView: true)
67 |
68 | case .router(.routeAction(_, .numberDetail(.goBackTapped))):
69 | state.routes.goBack()
70 |
71 | case .router(.routeAction(_, .numberDetail(.goBackToNumbersList))):
72 | return .routeWithDelaysIfUnsupported(state.routes, action: \.router, scheduler: .main) {
73 | $0.goBackTo(\.numbersList)
74 | }
75 |
76 | case .router(.routeAction(_, .numberDetail(.goBackToRootTapped))):
77 | return .routeWithDelaysIfUnsupported(state.routes, action: \.router, scheduler: .main) {
78 | $0.goBackToRoot()
79 | }
80 |
81 | default:
82 | break
83 | }
84 | return .none
85 | }
86 | .forEachRoute(\.routes, action: \.router)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Screen.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 | import SwiftUI
4 |
5 | @Reducer(state: .equatable, .hashable)
6 | enum Screen {
7 | case home(Home)
8 | case numbersList(NumbersList)
9 | case numberDetail(NumberDetail)
10 | }
11 |
12 | // Home
13 |
14 | struct HomeView: View {
15 | let store: StoreOf
16 |
17 | var body: some View {
18 | VStack {
19 | Button("Start") {
20 | store.send(.startTapped)
21 | }
22 | }
23 | .navigationTitle("Home")
24 | }
25 | }
26 |
27 | @Reducer
28 | struct Home {
29 | struct State: Hashable {
30 | let id = UUID()
31 | }
32 |
33 | enum Action {
34 | case startTapped
35 | }
36 | }
37 |
38 | // NumbersList
39 |
40 | struct NumbersListView: View {
41 | let store: StoreOf
42 |
43 | var body: some View {
44 | WithPerceptionTracking {
45 | List(store.numbers, id: \.self) { number in
46 | Button(
47 | "\(number)",
48 | action: {
49 | store.send(.numberSelected(number))
50 | }
51 | )
52 | }
53 | }
54 | .navigationTitle("Numbers")
55 | }
56 | }
57 |
58 | @Reducer
59 | struct NumbersList {
60 | @ObservableState
61 | struct State: Hashable {
62 | let id = UUID()
63 | let numbers: [Int]
64 | }
65 |
66 | enum Action {
67 | case numberSelected(Int)
68 | }
69 | }
70 |
71 | // NumberDetail
72 |
73 | struct NumberDetailView: View {
74 | let store: StoreOf
75 |
76 | var body: some View {
77 | WithPerceptionTracking {
78 | VStack(spacing: 8.0) {
79 | Text("Number \(store.number)")
80 | Button("Increment") {
81 | store.send(.incrementTapped)
82 | }
83 | Button("Increment after delay") {
84 | store.send(.incrementAfterDelayTapped)
85 | }
86 | Button("Show double (\(store.number * 2))") {
87 | store.send(.showDouble(store.number))
88 | }
89 | Button("Go back") {
90 | store.send(.goBackTapped)
91 | }
92 | Button("Go back to root from \(store.number)") {
93 | store.send(.goBackToRootTapped)
94 | }
95 | Button("Go back to numbers list") {
96 | store.send(.goBackToNumbersList)
97 | }
98 | }
99 | .navigationTitle("Number \(store.number)")
100 | }
101 | }
102 | }
103 |
104 | @Reducer
105 | struct NumberDetail {
106 | @ObservableState
107 | struct State: Hashable {
108 | let id = UUID()
109 | var number: Int
110 | }
111 |
112 | enum Action {
113 | case goBackTapped
114 | case goBackToRootTapped
115 | case goBackToNumbersList
116 | case incrementAfterDelayTapped
117 | case incrementTapped
118 | case showDouble(Int)
119 | }
120 |
121 | @Dependency(\.mainQueue) var mainQueue
122 |
123 | var body: some ReducerOf {
124 | Reduce { state, action in
125 | switch action {
126 | case .goBackToRootTapped, .goBackTapped, .goBackToNumbersList, .showDouble:
127 | return .none
128 |
129 | case .incrementAfterDelayTapped:
130 | return .run { send in
131 | try await mainQueue.sleep(for: .seconds(3))
132 | await send(.incrementTapped)
133 | }
134 |
135 | case .incrementTapped:
136 | state.number += 1
137 | return .none
138 | }
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Reducers/CancelEffectsOnDismiss.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | /// Identifier for a particular route within a particular coordinator.
5 | public struct CancellationIdentity: Hashable {
6 | let coordinatorId: CoordinatorID
7 | let routeId: RouteID
8 | }
9 |
10 | struct CancelEffectsOnDismiss<
11 | CoordinatorScreensReducer: Reducer,
12 | CoordinatorReducer: Reducer,
13 | CoordinatorID: Hashable,
14 | ScreenAction: CasePathable,
15 | RouteID: Hashable,
16 | C: Collection
17 | >: Reducer
18 | where CoordinatorScreensReducer.State == CoordinatorReducer.State,
19 | CoordinatorScreensReducer.Action == CoordinatorReducer.Action,
20 | CoordinatorScreensReducer.Action: CasePathable
21 | {
22 | let coordinatedScreensReducer: CoordinatorScreensReducer
23 | let routes: KeyPath
24 | let routeAction: CaseKeyPath
25 | let cancellationId: CoordinatorID?
26 | let getIdentifier: (C.Element, C.Index) -> RouteID
27 | let coordinatorReducer: CoordinatorReducer
28 |
29 | var body: some ReducerOf {
30 | if let cancellationId {
31 | CancelTaggedRouteEffectsOnDismiss(
32 | coordinatorReducer: CombineReducers {
33 | TagRouteEffectsForCancellation(
34 | screenReducer: coordinatedScreensReducer,
35 | coordinatorId: cancellationId,
36 | routeAction: routeAction
37 | )
38 | coordinatorReducer
39 | },
40 | coordinatorId: cancellationId,
41 | routes: routes,
42 | getIdentifier: getIdentifier
43 | )
44 | } else {
45 | CombineReducers {
46 | coordinatorReducer
47 | coordinatedScreensReducer
48 | }
49 | }
50 | }
51 | }
52 |
53 | struct TagRouteEffectsForCancellation<
54 | ScreenReducer: Reducer,
55 | CoordinatorID: Hashable,
56 | RouteID: Hashable,
57 | RouteAction
58 | >: Reducer
59 | where ScreenReducer.Action: CasePathable
60 | {
61 | let screenReducer: ScreenReducer
62 | let coordinatorId: CoordinatorID
63 | let routeAction: CaseKeyPath
64 |
65 | var body: some ReducerOf {
66 | Reduce { state, action in
67 | let effect = screenReducer.reduce(into: &state, action: action)
68 |
69 | if let (id: routeId, _) = action[case: routeAction] {
70 | let identity = CancellationIdentity(coordinatorId: coordinatorId, routeId: routeId)
71 | return effect.cancellable(id: identity)
72 | } else {
73 | return effect
74 | }
75 | }
76 | }
77 | }
78 |
79 | struct CancelTaggedRouteEffectsOnDismiss<
80 | CoordinatorReducer: Reducer,
81 | CoordinatorID: Hashable,
82 | C: Collection,
83 | RouteID: Hashable
84 | >: Reducer {
85 | let coordinatorReducer: CoordinatorReducer
86 | let coordinatorId: CoordinatorID
87 | let routes: KeyPath
88 | let getIdentifier: (C.Element, C.Index) -> RouteID
89 |
90 | var body: some ReducerOf {
91 | Reduce { state, action in
92 | let preRoutes = state[keyPath: routes]
93 | let effect = coordinatorReducer.reduce(into: &state, action: action)
94 | let postRoutes = state[keyPath: routes]
95 |
96 | var effects: [Effect] = [effect]
97 |
98 | let preIds = zip(preRoutes, preRoutes.indices).map(getIdentifier)
99 | let postIds = zip(postRoutes, postRoutes.indices).map(getIdentifier)
100 |
101 | let dismissedIds = Set(preIds).subtracting(postIds)
102 | for dismissedId in dismissedIds {
103 | let identity = CancellationIdentity(coordinatorId: coordinatorId, routeId: dismissedId)
104 | effects.append(Effect.cancel(id: identity))
105 | }
106 |
107 | return .merge(effects)
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/TCACoordinatorsExampleApp.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | @main
6 | struct TCACoordinatorsExampleApp: App {
7 | var body: some Scene {
8 | WindowGroup {
9 | MainTabCoordinatorView(
10 | store: Store(initialState: .initialState) {
11 | MainTabCoordinator()
12 | }
13 | )
14 | }
15 | }
16 | }
17 |
18 | // MainTabCoordinator
19 |
20 | struct MainTabCoordinatorView: View {
21 | @Perception.Bindable var store: StoreOf
22 |
23 | var body: some View {
24 | WithPerceptionTracking {
25 | TabView(selection: $store.selectedTab.sending(\.tabSelected)) {
26 | IndexedCoordinatorView(
27 | store: store.scope(
28 | state: \.indexed,
29 | action: \.indexed
30 | )
31 | )
32 | .tabItem { Text("Indexed") }
33 | .tag(MainTabCoordinator.Tab.indexed)
34 |
35 | IdentifiedCoordinatorView(
36 | store: store.scope(
37 | state: \.identified,
38 | action: \.identified
39 | )
40 | )
41 | .tabItem { Text("Identified") }
42 | .tag(MainTabCoordinator.Tab.identified)
43 |
44 | AppCoordinatorView(
45 | store: store.scope(
46 | state: \.app,
47 | action: \.app
48 | )
49 | )
50 | .tabItem { Text("Game") }
51 | .tag(MainTabCoordinator.Tab.app)
52 |
53 | FormAppCoordinatorView(
54 | store: store.scope(
55 | state: \.form,
56 | action: \.form
57 | )
58 | )
59 | .tabItem { Text("Form") }
60 | .tag(MainTabCoordinator.Tab.form)
61 |
62 | }.onOpenURL { _ in
63 | // In reality, the URL would be parsed into a Deeplink.
64 | let deeplink = MainTabCoordinator.Deeplink.identified(.showNumber(42))
65 | store.send(.deeplinkOpened(deeplink))
66 | }
67 | }
68 | }
69 | }
70 |
71 | @Reducer
72 | struct MainTabCoordinator {
73 | enum Tab: Hashable {
74 | case identified, indexed, app, form, deeplinkOpened
75 | }
76 |
77 | enum Deeplink {
78 | case identified(IdentifiedCoordinator.Deeplink)
79 | }
80 |
81 | enum Action {
82 | case identified(IdentifiedCoordinator.Action)
83 | case indexed(IndexedCoordinator.Action)
84 | case app(GameApp.Action)
85 | case form(FormAppCoordinator.Action)
86 | case deeplinkOpened(Deeplink)
87 | case tabSelected(Tab)
88 | }
89 |
90 | @ObservableState
91 | struct State: Equatable {
92 | static let initialState = State(
93 | identified: .initialState,
94 | indexed: .initialState,
95 | app: .initialState,
96 | form: .initialState,
97 | selectedTab: .app
98 | )
99 |
100 | var identified: IdentifiedCoordinator.State
101 | var indexed: IndexedCoordinator.State
102 | var app: GameApp.State
103 | var form: FormAppCoordinator.State
104 |
105 | var selectedTab: Tab
106 | }
107 |
108 | var body: some ReducerOf {
109 | Scope(state: \.indexed, action: \.indexed) {
110 | IndexedCoordinator()
111 | }
112 | Scope(state: \.identified, action: \.identified) {
113 | IdentifiedCoordinator()
114 | }
115 | Scope(state: \.app, action: \.app) {
116 | GameApp()
117 | }
118 | Scope(state: \.form, action: \.form) {
119 | FormAppCoordinator()
120 | }
121 | Reduce { state, action in
122 | switch action {
123 | case let .deeplinkOpened(.identified(.showNumber(number))):
124 | state.selectedTab = .identified
125 | if state.identified.routes.canPush == true {
126 | state.identified.routes.push(.numberDetail(.init(number: number)))
127 | } else {
128 | state.identified.routes.presentSheet(.numberDetail(.init(number: number)), embedInNavigationView: true)
129 | }
130 | case let .tabSelected(tab):
131 | state.selectedTab = tab
132 | default:
133 | break
134 | }
135 | return .none
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Tests/TCACoordinatorsTests/IndexedRouterTests.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | @testable import TCACoordinators
3 | import XCTest
4 |
5 | final class IndexedRouterTests: XCTestCase {
6 | @MainActor
7 | func testActionPropagation() async {
8 | let scheduler = DispatchQueue.test
9 | let store = TestStore(
10 | initialState: Parent.State(
11 | routes: [
12 | .root(.init(count: 42)),
13 | .sheet(.init(count: 11)),
14 | ]
15 | )
16 | ) {
17 | Parent(scheduler: scheduler)
18 | }
19 |
20 | await store.send(\.router[id: 0].increment) {
21 | $0.routes[0].screen.count += 1
22 | }
23 | await store.send(\.router[id: 1].increment) {
24 | $0.routes[1].screen.count += 1
25 | }
26 | }
27 |
28 | @MainActor
29 | func testActionCancellation() async {
30 | let scheduler = DispatchQueue.test
31 | let store = TestStore(
32 | initialState: Parent.State(
33 | routes: [
34 | .root(.init(count: 42)),
35 | .sheet(.init(count: 11)),
36 | ]
37 | )
38 | ) {
39 | Parent(scheduler: scheduler)
40 | }
41 | // Expect increment action after 1 second.
42 | await store.send(\.router[id: 1].incrementLaterTapped)
43 | await scheduler.advance(by: .seconds(1))
44 |
45 | await store.receive(\.router[id: 1].increment) {
46 | $0.routes[1].screen.count += 1
47 | }
48 | // Expect increment action to be cancelled if screen is removed.
49 | await store.send(\.router[id: 1].incrementLaterTapped)
50 | await store.send(\.router.updateRoutes, [.root(.init(count: 42))]) {
51 | $0.routes = [.root(.init(count: 42))]
52 | }
53 | }
54 |
55 | @available(iOS 16.0, *)
56 | @MainActor
57 | func testWithDelaysIfUnsupported() async throws {
58 | let initialRoutes: [Route] = [
59 | .root(.init(count: 1)),
60 | .sheet(.init(count: 2)),
61 | .sheet(.init(count: 3)),
62 | ]
63 | let scheduler = DispatchQueue.test
64 | let store = TestStore(initialState: Parent.State(routes: initialRoutes)) {
65 | Parent(scheduler: scheduler)
66 | }
67 | let goBackToRoot = await store.send(.goBackToRoot)
68 | await store.receive(\.router.updateRoutes, initialRoutes)
69 | let firstTwo = Array(initialRoutes.prefix(2))
70 | await store.receive(\.router.updateRoutes, firstTwo) {
71 | $0.routes = firstTwo
72 | }
73 | await scheduler.advance(by: .milliseconds(650))
74 | let firstOne = Array(initialRoutes.prefix(1))
75 | await store.receive(\.router.updateRoutes, firstOne) {
76 | $0.routes = firstOne
77 | }
78 | await goBackToRoot.finish()
79 | }
80 | }
81 |
82 | @Reducer
83 | private struct Child {
84 | let scheduler: TestSchedulerOf
85 | struct State: Equatable {
86 | var count = 0
87 | }
88 |
89 | enum Action: Equatable {
90 | case incrementLaterTapped
91 | case increment
92 | }
93 |
94 | var body: some ReducerOf {
95 | Reduce { state, action in
96 | switch action {
97 | case .increment:
98 | state.count += 1
99 | return .none
100 | case .incrementLaterTapped:
101 | return .run { send in
102 | try await scheduler.sleep(for: .seconds(1))
103 | await send(.increment)
104 | }
105 | }
106 | }
107 | }
108 | }
109 |
110 | @Reducer
111 | private struct Parent {
112 | struct State: Equatable {
113 | var routes: [Route]
114 | }
115 |
116 | enum Action {
117 | case router(IndexedRouterActionOf)
118 | case goBackToRoot
119 | }
120 |
121 | let scheduler: TestSchedulerOf
122 |
123 | var body: some ReducerOf {
124 | Reduce { state, action in
125 | switch action {
126 | case .goBackToRoot:
127 | .routeWithDelaysIfUnsupported(state.routes, action: \.router, scheduler: scheduler.eraseToAnyScheduler()) {
128 | $0.goBackToRoot()
129 | }
130 | default:
131 | .none
132 | }
133 | }
134 | .forEachRoute(\.routes, action: \.router) {
135 | Child(scheduler: scheduler)
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/FinalScreen.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 |
4 | struct FinalScreenView: View {
5 | let store: StoreOf
6 |
7 | var body: some View {
8 | WithPerceptionTracking {
9 | Form {
10 | Section {
11 | Button {
12 | store.send(.returnToName)
13 | } label: {
14 | LabelledRow("First name") {
15 | Text(store.firstName)
16 | }.foregroundColor(store.firstName.isEmpty ? .red : .black)
17 | }
18 |
19 | Button {
20 | store.send(.returnToName)
21 | } label: {
22 | LabelledRow("Last Name") {
23 | Text(store.lastName)
24 | }.foregroundColor(store.lastName.isEmpty ? .red : .black)
25 | }
26 |
27 | Button {
28 | store.send(.returnToDateOfBirth)
29 | } label: {
30 | LabelledRow("Date of Birth") {
31 | Text(store.dateOfBirth, format: .dateTime.day().month().year())
32 | }
33 | }
34 |
35 | Button {
36 | store.send(.returnToJob)
37 | } label: {
38 | LabelledRow("Job") {
39 | Text(store.job ?? "-")
40 | }.foregroundColor((store.job?.isEmpty ?? true) ? .red : .black)
41 | }
42 | } header: {
43 | Text("Confirm Your Info")
44 | }
45 | .buttonStyle(.plain)
46 |
47 | Button("Submit") {
48 | store.send(.submit)
49 | }.disabled(store.isIncomplete)
50 | }
51 | .navigationTitle("Submit")
52 | .disabled(store.submissionInFlight)
53 | .overlay {
54 | if store.submissionInFlight {
55 | Text("Submitting")
56 | .padding()
57 | .background(.thinMaterial)
58 | .cornerRadius(8)
59 | }
60 | }
61 | .animation(.spring(), value: store.submissionInFlight)
62 | }
63 | }
64 | }
65 |
66 | struct LabelledRow: View {
67 | let label: String
68 | let content: Content
69 |
70 | init(
71 | _ label: String,
72 | @ViewBuilder content: () -> Content
73 | ) {
74 | self.label = label
75 | self.content = content()
76 | }
77 |
78 | var body: some View {
79 | HStack {
80 | Text(label)
81 | Spacer()
82 | content
83 | }
84 | .contentShape(.rect)
85 | }
86 | }
87 |
88 | struct APIModel: Codable, Equatable {
89 | let firstName: String
90 | let lastName: String
91 | let dateOfBirth: Date
92 | let job: String
93 | }
94 |
95 | @Reducer
96 | struct FinalScreen {
97 | @ObservableState
98 | struct State: Hashable {
99 | let firstName: String
100 | let lastName: String
101 | let dateOfBirth: Date
102 | let job: String?
103 |
104 | var submissionInFlight = false
105 | var isIncomplete: Bool {
106 | firstName.isEmpty || lastName.isEmpty || job?.isEmpty ?? true
107 | }
108 | }
109 |
110 | enum Action: Equatable {
111 | case returnToName
112 | case returnToDateOfBirth
113 | case returnToJob
114 |
115 | case submit
116 | case receiveAPIResponse(Bool)
117 | }
118 |
119 | @Dependency(\.mainQueue) var mainQueue
120 | @Dependency(FormScreenEnvironment.self) var environment
121 |
122 | var body: some ReducerOf {
123 | Reduce { state, action in
124 | switch action {
125 | case .submit:
126 | guard let job = state.job else { return .none }
127 | state.submissionInFlight = true
128 |
129 | let apiModel = APIModel(
130 | firstName: state.firstName,
131 | lastName: state.lastName,
132 | dateOfBirth: state.dateOfBirth,
133 | job: job
134 | )
135 |
136 | return .run { send in
137 | try await mainQueue.sleep(for: .seconds(0.8))
138 | await send(.receiveAPIResponse(environment.submit(apiModel)))
139 | }
140 |
141 | case .receiveAPIResponse:
142 | state.submissionInFlight = false
143 | return .none
144 |
145 | case .returnToName, .returnToDateOfBirth, .returnToJob:
146 | return .none
147 | }
148 | }
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Reducers/ForEachReducer.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | /// Adapted from a similar function in The Composable Architecture, that was deprecated in favour of an
5 | /// IdentifiedArray-based version. In general, it might be considered unwise to identify child reducers by
6 | /// their Array index in case they move position, but if the only changes made to the Array are
7 | /// index-stable, e.g. pushes and pops, then that's not a problem.
8 | /// https://github.com/pointfreeco/swift-composable-architecture/blob/f7c75217a8087167aacbdad3fe4950867f468a52/Sources/ComposableArchitecture/Internal/Deprecations.swift#L704-L765
9 | extension Reducer {
10 | func forEachIndex(
11 | _ toElementsState: WritableKeyPath,
12 | action toElementAction: CaseKeyPath,
13 | @ReducerBuilder element: () -> Element,
14 | file: StaticString = #file,
15 | fileID: StaticString = #fileID,
16 | line: UInt = #line
17 | ) -> _ForEachIndexReducer
18 | where ElementState == Element.State, ElementAction == Element.Action
19 | {
20 | _ForEachIndexReducer(
21 | parent: self,
22 | toElementsState: toElementsState,
23 | toElementAction: toElementAction,
24 | element: element(),
25 | file: file,
26 | fileID: fileID,
27 | line: line
28 | )
29 | }
30 | }
31 |
32 | struct _ForEachIndexReducer<
33 | Parent: Reducer, Element: Reducer
34 | >: Reducer where Parent.Action: CasePathable {
35 | let parent: Parent
36 | let toElementsState: WritableKeyPath
37 | let toElementAction: CaseKeyPath
38 | let element: Element
39 | let file: StaticString
40 | let fileID: StaticString
41 | let line: UInt
42 |
43 | init(
44 | parent: Parent,
45 | toElementsState: WritableKeyPath,
46 | toElementAction: CaseKeyPath,
47 | element: Element,
48 | file: StaticString,
49 | fileID: StaticString,
50 | line: UInt
51 | ) {
52 | self.parent = parent
53 | self.toElementsState = toElementsState
54 | self.toElementAction = toElementAction
55 | self.element = element
56 | self.file = file
57 | self.fileID = fileID
58 | self.line = line
59 | }
60 |
61 | public var body: some ReducerOf {
62 | Reduce { state, action in
63 | reduceForEach(into: &state, action: action)
64 | .merge(with: parent.reduce(into: &state, action: action))
65 | }
66 | }
67 |
68 | func reduceForEach(
69 | into state: inout Parent.State, action: Parent.Action
70 | ) -> Effect {
71 | guard let (index, elementAction) = action[case: toElementAction] else { return .none }
72 | let array = state[keyPath: toElementsState]
73 | if array[safe: index] == nil {
74 | runtimeWarn(
75 | """
76 | A "forEachRoute" at "\(fileID):\(line)" received an action for a screen at \
77 | index \(index) but the screens array only contains \(array.count) elements.
78 |
79 | Action:
80 | \(action)
81 |
82 | This may be because a parent reducer (e.g. coordinator reducer) removed the screen at \
83 | this index before the action was sent.
84 | """,
85 | file: file,
86 | line: line
87 | )
88 | return .none
89 | }
90 | return element
91 | .reduce(into: &state[keyPath: toElementsState][index], action: elementAction)
92 | .map { toElementAction((id: index, action: $0)) }
93 | }
94 | }
95 |
96 | public func runtimeWarn(
97 | _ message: @autoclosure () -> String,
98 | file: StaticString? = nil,
99 | line: UInt? = nil
100 | ) {
101 | #if DEBUG
102 | let message = message()
103 | if _XCTIsTesting {
104 | if let file, let line {
105 | XCTFail(message, file: file, line: line)
106 | } else {
107 | XCTFail(message)
108 | }
109 | } else {
110 | fputs("[TCACoordinators] \(message)\n", stderr)
111 | }
112 | #endif
113 | }
114 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Form/FormAppCoordinator.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 | import TCACoordinators
4 |
5 | @Reducer
6 | struct FormAppCoordinator {
7 | @ObservableState
8 | struct State: Equatable, Sendable {
9 | static let initialState = Self(routeIDs: [.root(.step1, embedInNavigationView: true)])
10 |
11 | var step1State = Step1.State()
12 | var step2State = Step2.State()
13 | var step3State = Step3.State()
14 |
15 | var finalScreenState: FinalScreen.State {
16 | .init(firstName: step1State.firstName, lastName: step1State.lastName, dateOfBirth: step2State.dateOfBirth, job: step3State.selectedOccupation)
17 | }
18 |
19 | var routeIDs: IdentifiedArrayOf>
20 |
21 | var routes: IdentifiedArrayOf> {
22 | get {
23 | let routes = routeIDs.map { route -> Route in
24 | route.map { id in
25 | switch id {
26 | case .step1:
27 | .step1(step1State)
28 | case .step2:
29 | .step2(step2State)
30 | case .step3:
31 | .step3(step3State)
32 | case .finalScreen:
33 | .finalScreen(finalScreenState)
34 | }
35 | }
36 | }
37 | return IdentifiedArray(uniqueElements: routes)
38 | }
39 | set {
40 | let routeIDs = newValue.map { route -> Route in
41 | route.map { id in
42 | switch id {
43 | case let .step1(step1State):
44 | self.step1State = step1State
45 | return .step1
46 | case let .step2(step2State):
47 | self.step2State = step2State
48 | return .step2
49 | case let .step3(step3State):
50 | self.step3State = step3State
51 | return .step3
52 | case .finalScreen:
53 | return .finalScreen
54 | }
55 | }
56 | }
57 | self.routeIDs = IdentifiedArray(uniqueElements: routeIDs)
58 | }
59 | }
60 |
61 | mutating func clear() {
62 | step1State = .init()
63 | step2State = .init()
64 | step3State = .init()
65 | }
66 | }
67 |
68 | enum Action {
69 | case router(IdentifiedRouterActionOf)
70 | }
71 |
72 | var body: some ReducerOf {
73 | Reduce { state, action in
74 | switch action {
75 | case .router(.routeAction(_, action: .step1(.nextButtonTapped))):
76 | state.routeIDs.push(.step2)
77 | return .none
78 |
79 | case .router(.routeAction(_, action: .step2(.nextButtonTapped))):
80 | state.routeIDs.push(.step3)
81 | return .none
82 |
83 | case .router(.routeAction(_, action: .step3(.nextButtonTapped))):
84 | state.routeIDs.push(.finalScreen)
85 | return .none
86 |
87 | case .router(.routeAction(_, action: .finalScreen(.returnToName))):
88 | state.routeIDs.goBackTo(id: .step1)
89 | return .none
90 |
91 | case .router(.routeAction(_, action: .finalScreen(.returnToDateOfBirth))):
92 | state.routeIDs.goBackTo(id: .step2)
93 | return .none
94 |
95 | case .router(.routeAction(_, action: .finalScreen(.returnToJob))):
96 | state.routeIDs.goBackTo(id: .step3)
97 | return .none
98 |
99 | case .router(.routeAction(_, action: .finalScreen(.receiveAPIResponse))):
100 | state.routeIDs.goBackToRoot()
101 | state.clear()
102 | return .none
103 |
104 | default:
105 | return .none
106 | }
107 | }
108 | .forEachRoute(\.routes, action: \.router)
109 | }
110 | }
111 |
112 | struct FormAppCoordinatorView: View {
113 | let store: StoreOf
114 |
115 | var body: some View {
116 | TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
117 | switch screen.case {
118 | case let .step1(store):
119 | Step1View(store: store)
120 |
121 | case let .step2(store):
122 | Step2View(store: store)
123 |
124 | case let .step3(store):
125 | Step3View(store: store)
126 |
127 | case let .finalScreen(store):
128 | FinalScreenView(store: store)
129 | }
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Effect+routeWithDelaysIfUnsupported.swift:
--------------------------------------------------------------------------------
1 | import CombineSchedulers
2 | import ComposableArchitecture
3 | import FlowStacks
4 | import Foundation
5 | import SwiftUI
6 |
7 | public extension Effect {
8 | /// Allows arbitrary changes to be made to the routes collection, even if SwiftUI does not support such changes within a single
9 | /// state update. For example, SwiftUI only supports pushing, presenting or dismissing one screen at a time. Any changes can be
10 | /// made to the routes passed to the transform closure, and where those changes are not supported within a single update by
11 | /// SwiftUI, an Effect stream of smaller permissible updates will be returned, interspersed with sufficient delays.
12 | ///
13 | /// - Parameter routes: The routes in their current state.
14 | /// - Parameter scheduler: The scheduler for scheduling delays. E.g. a test scheduler can be used in tests.
15 | /// - Parameter transform: A closure transforming the routes into their new state.
16 | /// - Returns: An Effect stream of actions with incremental updates to routes over time. If the proposed change is supported
17 | /// within a single update, the Effect stream will include only one element.
18 | static func routeWithDelaysIfUnsupported(
19 | _ routes: [Route],
20 | action: CaseKeyPath>,
21 | scheduler: AnySchedulerOf = .main,
22 | _ transform: (inout [Route]) -> Void
23 | ) -> Self {
24 | var transformedRoutes = routes
25 | transform(&transformedRoutes)
26 | let steps = RouteSteps.calculateSteps(from: routes, to: transformedRoutes)
27 | return .run { send in
28 | for await step in scheduledSteps(steps: steps, scheduler: scheduler) {
29 | await send(action.appending(path: \.updateRoutes)(step))
30 | }
31 | }
32 | }
33 |
34 | /// Allows arbitrary changes to be made to the routes collection, even if SwiftUI does not support such changes within a single
35 | /// state update. For example, SwiftUI only supports pushing, presenting or dismissing one screen at a time. Any changes can be
36 | /// made to the routes passed to the transform closure, and where those changes are not supported within a single update by
37 | /// SwiftUI, an Effect stream of smaller permissible updates will be returned, interspersed with sufficient delays.
38 | ///
39 | /// - Parameter routes: The routes in their current state.
40 | /// - Parameter scheduler: The scheduler for scheduling delays. E.g. a test scheduler can be used in tests.
41 | /// - Parameter transform: A closure transforming the routes into their new state.
42 | /// - Returns: An Effect stream of actions with incremental updates to routes over time. If the proposed change is supported
43 | /// within a single update, the Effect stream will include only one element.
44 | static func routeWithDelaysIfUnsupported(
45 | _ routes: IdentifiedArrayOf>,
46 | action: CaseKeyPath>,
47 | scheduler: AnySchedulerOf = .main,
48 | _ transform: (inout IdentifiedArrayOf>) -> Void
49 | ) -> Self {
50 | var transformedRoutes = routes
51 | transform(&transformedRoutes)
52 | let steps = RouteSteps.calculateSteps(from: Array(routes), to: Array(transformedRoutes))
53 |
54 | return .run { send in
55 | for await step in scheduledSteps(steps: steps, scheduler: scheduler) {
56 | await send(action.appending(path: \.updateRoutes)(step))
57 | }
58 | }
59 | }
60 | }
61 |
62 | func scheduledSteps(steps: [[Route]], scheduler: AnySchedulerOf) -> AsyncStream<[Route]> {
63 | guard let first = steps.first else { return .finished }
64 | let second = steps.dropFirst().first
65 | let remainder = steps.dropFirst(2)
66 |
67 | return AsyncStream { continuation in
68 | Task {
69 | do {
70 | continuation.yield(first)
71 | if let second {
72 | continuation.yield(second)
73 | }
74 |
75 | for step in remainder {
76 | try await scheduler.sleep(for: .milliseconds(650))
77 | continuation.yield(step)
78 | }
79 |
80 | continuation.finish()
81 | } catch {
82 | continuation.finish()
83 | }
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Tests/TCACoordinatorsTests/IdentifiedRouterTests.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | @testable import TCACoordinators
3 | import XCTest
4 |
5 | final class IdentifiedRouterTests: XCTestCase {
6 | @MainActor
7 | func testActionPropagation() async {
8 | let scheduler = DispatchQueue.test
9 | let store = TestStore(
10 | initialState: Parent.State(routes: [
11 | .root(.init(id: "first", count: 42)),
12 | .sheet(.init(id: "second", count: 11)),
13 | ])
14 | ) {
15 | Parent(scheduler: scheduler)
16 | }
17 |
18 | await store.send(\.router[id: "first"].increment) {
19 | $0.routes[id: "first"]?.screen.count += 1
20 | }
21 |
22 | await store.send(\.router[id: "second"].increment) {
23 | $0.routes[id: "second"]?.screen.count += 1
24 | }
25 | }
26 |
27 | @MainActor
28 | func testActionCancellation() async {
29 | let scheduler = DispatchQueue.test
30 | let store = TestStore(
31 | initialState: Parent.State(
32 | routes: [
33 | .root(.init(id: "first", count: 42)),
34 | .sheet(.init(id: "second", count: 11)),
35 | ]
36 | )
37 | ) {
38 | Parent(scheduler: scheduler)
39 | }
40 |
41 | // Expect increment action after 1 second.
42 | await store.send(\.router[id: "second"].incrementLaterTapped)
43 | await scheduler.advance(by: .seconds(1))
44 | await store.receive(\.router[id: "second"].increment) {
45 | $0.routes[id: "second"]?.screen.count += 1
46 | }
47 | // Expect increment action to be cancelled if screen is removed.
48 | await store.send(\.router[id: "second"].incrementLaterTapped)
49 | await store.send(\.router.updateRoutes, [.root(.init(id: "first", count: 42))]) {
50 | $0.routes = [.root(.init(id: "first", count: 42))]
51 | }
52 | }
53 |
54 | @available(iOS 16.0, *)
55 | @MainActor
56 | func testWithDelaysIfUnsupported() async throws {
57 | let initialRoutes: IdentifiedArrayOf> = [
58 | .root(.init(id: "first", count: 1)),
59 | .sheet(.init(id: "second", count: 2)),
60 | .sheet(.init(id: "third", count: 3)),
61 | ]
62 | let scheduler = DispatchQueue.test
63 | let store = TestStore(initialState: Parent.State(routes: initialRoutes)) {
64 | Parent(scheduler: scheduler)
65 | }
66 | let goBackToRoot = await store.send(.goBackToRoot)
67 | await store.receive(\.router.updateRoutes, initialRoutes.elements)
68 | let firstTwo = IdentifiedArrayOf(initialRoutes.prefix(2))
69 | await store.receive(\.router.updateRoutes, firstTwo.elements) {
70 | $0.routes = firstTwo
71 | }
72 | await scheduler.advance(by: .milliseconds(650))
73 | let firstOne = IdentifiedArrayOf(initialRoutes.prefix(1))
74 | await store.receive(\.router.updateRoutes, firstOne.elements) {
75 | $0.routes = firstOne
76 | }
77 | await goBackToRoot.finish()
78 | }
79 | }
80 |
81 | @Reducer
82 | private struct Child {
83 | let scheduler: TestSchedulerOf
84 | struct State: Equatable, Identifiable {
85 | var id: String
86 | var count = 0
87 | }
88 |
89 | enum Action {
90 | case incrementLaterTapped
91 | case increment
92 | }
93 |
94 | var body: some ReducerOf {
95 | Reduce { state, action in
96 | switch action {
97 | case .increment:
98 | state.count += 1
99 | return .none
100 | case .incrementLaterTapped:
101 | return .run { send in
102 | try await scheduler.sleep(for: .seconds(1))
103 | await send(.increment)
104 | }
105 | }
106 | }
107 | }
108 | }
109 |
110 | @Reducer
111 | private struct Parent {
112 | let scheduler: TestSchedulerOf
113 | struct State: Equatable {
114 | var routes: IdentifiedArrayOf>
115 | }
116 |
117 | enum Action {
118 | case router(IdentifiedRouterActionOf)
119 | case goBackToRoot
120 | }
121 |
122 | var body: some ReducerOf {
123 | Reduce { state, action in
124 | switch action {
125 | case .goBackToRoot:
126 | .routeWithDelaysIfUnsupported(state.routes, action: \.router, scheduler: scheduler.eraseToAnyScheduler()) {
127 | $0.goBackToRoot()
128 | }
129 | default:
130 | .none
131 | }
132 | }
133 | .forEachRoute(\.routes, action: \.router) {
134 | Child(scheduler: scheduler)
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/xcshareddata/xcschemes/TCACoordinatorsExample.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
53 |
59 |
60 |
61 |
62 |
63 |
73 |
75 |
81 |
82 |
83 |
84 |
90 |
92 |
98 |
99 |
100 |
101 |
103 |
104 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "combine-schedulers",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/pointfreeco/combine-schedulers",
7 | "state" : {
8 | "revision" : "5928286acce13def418ec36d05a001a9641086f2",
9 | "version" : "1.0.3"
10 | }
11 | },
12 | {
13 | "identity" : "flowstacks",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/johnpatrickmorgan/FlowStacks",
16 | "state" : {
17 | "revision" : "468de8179d68f9e150ffafcb4574dee52dfa3976",
18 | "version" : "0.4.0"
19 | }
20 | },
21 | {
22 | "identity" : "swift-case-paths",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/pointfreeco/swift-case-paths",
25 | "state" : {
26 | "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b",
27 | "version" : "1.6.1"
28 | }
29 | },
30 | {
31 | "identity" : "swift-clocks",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/pointfreeco/swift-clocks",
34 | "state" : {
35 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
36 | "version" : "1.0.6"
37 | }
38 | },
39 | {
40 | "identity" : "swift-collections",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/apple/swift-collections",
43 | "state" : {
44 | "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb",
45 | "version" : "1.1.0"
46 | }
47 | },
48 | {
49 | "identity" : "swift-composable-architecture",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/pointfreeco/swift-composable-architecture",
52 | "state" : {
53 | "revision" : "294ac2cbfe48a41a6bd3c294fbb7bc5f0f5194d6",
54 | "version" : "1.20.1"
55 | }
56 | },
57 | {
58 | "identity" : "swift-concurrency-extras",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
61 | "state" : {
62 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
63 | "version" : "1.3.1"
64 | }
65 | },
66 | {
67 | "identity" : "swift-custom-dump",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/pointfreeco/swift-custom-dump",
70 | "state" : {
71 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
72 | "version" : "1.3.3"
73 | }
74 | },
75 | {
76 | "identity" : "swift-dependencies",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/pointfreeco/swift-dependencies",
79 | "state" : {
80 | "revision" : "52b5e1a09dc016e64ce253e19ab3124b7fae9ac9",
81 | "version" : "1.7.0"
82 | }
83 | },
84 | {
85 | "identity" : "swift-identified-collections",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/pointfreeco/swift-identified-collections",
88 | "state" : {
89 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
90 | "version" : "1.1.1"
91 | }
92 | },
93 | {
94 | "identity" : "swift-navigation",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/pointfreeco/swift-navigation",
97 | "state" : {
98 | "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4",
99 | "version" : "2.3.0"
100 | }
101 | },
102 | {
103 | "identity" : "swift-perception",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/pointfreeco/swift-perception",
106 | "state" : {
107 | "revision" : "21811d6230a625fa0f2e6ffa85be857075cc02c4",
108 | "version" : "1.5.0"
109 | }
110 | },
111 | {
112 | "identity" : "swift-sharing",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/pointfreeco/swift-sharing",
115 | "state" : {
116 | "revision" : "10ba53dd428aed9fc4a1543d3271860a6d4b8dd2",
117 | "version" : "2.3.0"
118 | }
119 | },
120 | {
121 | "identity" : "swift-syntax",
122 | "kind" : "remoteSourceControl",
123 | "location" : "https://github.com/swiftlang/swift-syntax",
124 | "state" : {
125 | "revision" : "0687f71944021d616d34d922343dcef086855920",
126 | "version" : "600.0.1"
127 | }
128 | },
129 | {
130 | "identity" : "xctest-dynamic-overlay",
131 | "kind" : "remoteSourceControl",
132 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
133 | "state" : {
134 | "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938",
135 | "version" : "1.5.1"
136 | }
137 | }
138 | ],
139 | "version" : 2
140 | }
141 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "combine-schedulers",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/pointfreeco/combine-schedulers",
7 | "state" : {
8 | "revision" : "5928286acce13def418ec36d05a001a9641086f2",
9 | "version" : "1.0.3"
10 | }
11 | },
12 | {
13 | "identity" : "flowstacks",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/johnpatrickmorgan/FlowStacks",
16 | "state" : {
17 | "revision" : "a2b974b81612e1e8ee3ecd757d936a2919527356",
18 | "version" : "0.4.1"
19 | }
20 | },
21 | {
22 | "identity" : "swift-case-paths",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/pointfreeco/swift-case-paths",
25 | "state" : {
26 | "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
27 | "version" : "1.7.0"
28 | }
29 | },
30 | {
31 | "identity" : "swift-clocks",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/pointfreeco/swift-clocks",
34 | "state" : {
35 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
36 | "version" : "1.0.6"
37 | }
38 | },
39 | {
40 | "identity" : "swift-collections",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/apple/swift-collections",
43 | "state" : {
44 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
45 | "version" : "1.2.0"
46 | }
47 | },
48 | {
49 | "identity" : "swift-composable-architecture",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/pointfreeco/swift-composable-architecture",
52 | "state" : {
53 | "revision" : "6574de2396319a58e86e2178577268cb4aeccc30",
54 | "version" : "1.20.2"
55 | }
56 | },
57 | {
58 | "identity" : "swift-concurrency-extras",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
61 | "state" : {
62 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
63 | "version" : "1.3.1"
64 | }
65 | },
66 | {
67 | "identity" : "swift-custom-dump",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/pointfreeco/swift-custom-dump",
70 | "state" : {
71 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
72 | "version" : "1.3.3"
73 | }
74 | },
75 | {
76 | "identity" : "swift-dependencies",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/pointfreeco/swift-dependencies",
79 | "state" : {
80 | "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596",
81 | "version" : "1.9.2"
82 | }
83 | },
84 | {
85 | "identity" : "swift-identified-collections",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/pointfreeco/swift-identified-collections",
88 | "state" : {
89 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
90 | "version" : "1.1.1"
91 | }
92 | },
93 | {
94 | "identity" : "swift-navigation",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/pointfreeco/swift-navigation",
97 | "state" : {
98 | "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21",
99 | "version" : "2.3.1"
100 | }
101 | },
102 | {
103 | "identity" : "swift-perception",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/pointfreeco/swift-perception",
106 | "state" : {
107 | "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01",
108 | "version" : "1.6.0"
109 | }
110 | },
111 | {
112 | "identity" : "swift-sharing",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/pointfreeco/swift-sharing",
115 | "state" : {
116 | "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9",
117 | "version" : "2.5.2"
118 | }
119 | },
120 | {
121 | "identity" : "swift-syntax",
122 | "kind" : "remoteSourceControl",
123 | "location" : "https://github.com/swiftlang/swift-syntax",
124 | "state" : {
125 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
126 | "version" : "601.0.1"
127 | }
128 | },
129 | {
130 | "identity" : "xctest-dynamic-overlay",
131 | "kind" : "remoteSourceControl",
132 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
133 | "state" : {
134 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
135 | "version" : "1.5.2"
136 | }
137 | }
138 | ],
139 | "version" : 2
140 | }
141 |
--------------------------------------------------------------------------------
/Docs/Migration/Migrating from 0.8.md:
--------------------------------------------------------------------------------
1 | # Migrating from 0.8
2 |
3 | Version 0.9 introduced an API change to bring the library's APIs more in-line with the Composable Architecture, including the use of case paths. These are breaking changes, so it requires some migration. There are two migration routes:
4 |
5 | - Full migration, to bring your project fully in-line with the new APIs.
6 | - Easy migration, for if you want to migrate as quickly as possible, so you can work on full migration at your leisure.
7 |
8 | ## Full migration
9 |
10 | 1. The state and action protocols have been deprecated/removed. You can remove conformances to the `IndexedRouterState`, `IndexedRouterAction`, `IdentifiedRouterState` and `IdentifiedRouterAction` protocols.
11 | 2. To enable access to case paths on your coordinator's action type, add the @Reducer macro to your coordinator reducer.
12 | 3. Your coordinator's action can be simplified. Instead of the `routeAction` and `updateRoutes` cases, it should have just one case: either `case router(IndexedRouterActionOf)` or `case router(IdentifiedRouterActionOf)`, where `Screen` is your screen reducer. You will also need to update any references to those cases, e.g. rather than pattern-matching on `case .routeAction(_, let action):` you would pattern-match on `case .router(.routeAction(_, let action)):`, since they are now nested within the `.router(...)` case.
13 | 4. Where you previously called `forEachRoute { ... }`, you should now pass a keypath and case path for the relevant parts of your state and action, e.g. `forEachRoute(\.routes, action: \.router) { ... }`.
14 | 5. Where you previously instantiated a `TCARouter` in your view with the coordinator's entire store, you now scope the store using the same keypath and case path, e.g. `TCARouter(store.scope(state: \.routes, action: \.router)) { ... }`.
15 | 6. If you were previously using `Effect.routeWithDelaysIfUnsupported(state.routes) { ... }`, you will now need to additionally pass a casepath for the relevant action: e.g. `Effect.routeWithDelaysIfUnsupported(state.routes, action: \.router) { ... }`.
16 |
17 | Here's a full diff for migrating a coordinator:
18 |
19 | ```diff
20 | struct IndexedCoordinatorView: View {
21 | let store: StoreOf
22 |
23 | var body: some View {
24 | - TCARouter(store) { screen in
25 | + TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
26 | SwitchStore(screen) { screen in
27 | switch screen {
28 | case .home:
29 | CaseLet(
30 | \Screen.State.home,
31 | action: Screen.Action.home,
32 | then: HomeView.init
33 | )
34 | case .numbersList:
35 | CaseLet(
36 | \Screen.State.numbersList,
37 | action: Screen.Action.numbersList,
38 | then: NumbersListView.init
39 | )
40 | case .numberDetail:
41 | CaseLet(
42 | \Screen.State.numberDetail,
43 | action: Screen.Action.numberDetail,
44 | then: NumberDetailView.init
45 | )
46 | }
47 | }
48 | }
49 | }
50 | }
51 |
52 | +@Reducer
53 | struct IndexedCoordinator {
54 | - struct State: Equatable, IndexedRouterState {
55 | + struct State: Equatable {
56 | var routes: [Route]
57 | }
58 |
59 | - enum Action: IndexedRouterAction {
60 | + enum Action {
61 | - case routeAction(Int, action: Screen.Action)
62 | - case updateRoutes([Route])
63 | + case router(IndexedRouterActionOf)
64 | }
65 |
66 | var body: some ReducerOf {
67 | Reduce { state, action in
68 | switch action {
69 | - case .routeAction(_, .home(.startTapped)):
70 | + case .router(.routeAction(_, .home(.startTapped))):
71 | state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true)
72 |
73 | - case let .routeAction(_, .numbersList(.numberSelected(number))):
74 | + case let .router(.routeAction(_, .numbersList(.numberSelected(number)))):
75 | state.routes.push(.numberDetail(.init(number: number)))
76 |
77 | - case .routeAction(_, .numberDetail(.goBackTapped)):
78 | + case .router(.routeAction(_, .numberDetail(.goBackTapped))):
79 | state.routes.goBack()
80 |
81 | - case .routeAction(_, .numberDetail(.goBackToRootTapped)):
82 | + case .router(.routeAction(_, .numberDetail(.goBackToRootTapped))):
83 | - return .routeWithDelaysIfUnsupported(state.routes) {
84 | + return .routeWithDelaysIfUnsupported(state.routes, action: \.router) {
85 | $0.goBackToRoot()
86 | }
87 |
88 | default:
89 | break
90 | }
91 | return .none
92 | }
93 | - .forEachRoute {
94 | + .forEachRoute(\.routes, action: \.router) {
95 | Screen()
96 | }
97 | }
98 | }
99 | ```
100 |
101 | ## Easy migration
102 |
103 | As an alternative to the above, you might prefer to perform a simpler migration in the short-term. If so, you can skip steps 2 and 3 above, and instead manually add a casepath to your coordinator's action type:
104 |
105 | ```swift
106 | // Quick update for an action that formerly conformed to `IdentifiedRouterAction`.
107 | enum Action: CasePathable {
108 | case updateRoutes(IdentifiedArrayOf>)
109 | case routeAction(Screen.State.ID, action: Screen.Action)
110 |
111 | static var allCasePaths = AllCasePaths()
112 |
113 | struct AllCasePaths {
114 | var router: AnyCasePath> {
115 | AnyCasePath { routerAction in
116 | switch routerAction {
117 | case let .routeAction(id, action):
118 | return .routeAction(id, action: action)
119 | case let .updateRoutes(newRoutes):
120 | return .updateRoutes(IdentifiedArray(uniqueElements: newRoutes))
121 | }
122 | } extract: { action in
123 | switch action {
124 | case let .routeAction(id, action: action):
125 | return .routeAction(id: id, action: action)
126 | case let .updateRoutes(newRoutes):
127 | return .updateRoutes(newRoutes.elements)
128 | }
129 | }
130 | }
131 | }
132 | }
133 | ```
134 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | struct ForEachIndexedRoute<
5 | CoordinatorReducer: Reducer,
6 | ScreenReducer: Reducer,
7 | CoordinatorID: Hashable
8 | >: Reducer
9 | where CoordinatorReducer.Action: CasePathable,
10 | ScreenReducer.Action: CasePathable
11 | {
12 | let coordinatorReducer: CoordinatorReducer
13 | let screenReducer: ScreenReducer
14 | let cancellationId: CoordinatorID?
15 | let toLocalState: WritableKeyPath]>
16 | let toLocalAction: CaseKeyPath>
17 |
18 | var body: some ReducerOf {
19 | CancelEffectsOnDismiss(
20 | coordinatedScreensReducer: EmptyReducer()
21 | .forEachIndex(toLocalState, action: toLocalAction.appending(path: \.routeAction)) {
22 | OnRoutes(wrapped: screenReducer)
23 | }
24 | .updatingRoutesOnInteraction(
25 | updateRoutes: toLocalAction.appending(path: \.updateRoutes),
26 | toLocalState: toLocalState
27 | ),
28 | routes: toLocalState,
29 | routeAction: toLocalAction.appending(path: \.routeAction),
30 | cancellationId: cancellationId,
31 | getIdentifier: { _, index in index },
32 | coordinatorReducer: coordinatorReducer
33 | )
34 | }
35 | }
36 |
37 | public extension Reducer {
38 | /// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in
39 | /// the coordinator's routes array will have its actions and state propagated. When screens are
40 | /// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects
41 | /// will be cancelled when the screen from which they originated is dismissed.
42 | /// - Parameters:
43 | /// - routes: A writable keypath for the routes array.
44 | /// - action: A casepath for the router action from this reducer's Action type.
45 | /// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It
46 | /// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation.
47 | /// - screenReducer: The reducer that operates on all of the individual screens.
48 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
49 | func forEachRoute(
50 | _ routes: WritableKeyPath]>,
51 | action: CaseKeyPath>,
52 | cancellationId: (some Hashable)?,
53 | @ReducerBuilder screenReducer: () -> ScreenReducer
54 | ) -> some ReducerOf
55 | where Action: CasePathable,
56 | ScreenState == ScreenReducer.State,
57 | ScreenAction == ScreenReducer.Action,
58 | ScreenAction: CasePathable
59 | {
60 | ForEachIndexedRoute(
61 | coordinatorReducer: self,
62 | screenReducer: screenReducer(),
63 | cancellationId: cancellationId,
64 | toLocalState: routes,
65 | toLocalAction: action
66 | )
67 | }
68 |
69 | /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
70 | /// the coordinator's routes array will have its actions and state propagated. When screens are
71 | /// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects
72 | /// will be cancelled when the screen from which they originated is dismissed.
73 | /// - Parameters:
74 | /// - routes: A writable keypath for the routes array.
75 | /// - action: A casepath for the router action from this reducer's Action type.
76 | /// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It
77 | /// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation.
78 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
79 | func forEachRoute(
80 | _ routes: WritableKeyPath]>,
81 | action: CaseKeyPath>,
82 | cancellationId: (some Hashable)?
83 | ) -> some ReducerOf
84 | where Action: CasePathable,
85 | ScreenState: CaseReducerState,
86 | ScreenState.StateReducer.Action == ScreenAction,
87 | ScreenAction: CasePathable
88 | {
89 | forEachRoute(
90 | routes,
91 | action: action,
92 | cancellationId: cancellationId
93 | ) {
94 | ScreenState.StateReducer.body
95 | }
96 | }
97 |
98 | /// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in
99 | /// the coordinator's routes Array will have its actions and state propagated. When screens are
100 | /// dismissed, the routes will be updated. In-flight effects
101 | /// will be cancelled when the screen from which they originated is dismissed.
102 | /// - Parameters:
103 | /// - routes: A writable keypath for the routes array.
104 | /// - action: A casepath for the router action from this reducer's Action type.
105 | /// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It
106 | /// will be combined with the screen's identifier. Defaults to the type of the parent reducer.
107 | /// - screenReducer: The reducer that operates on all of the individual screens.
108 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
109 | func forEachRoute(
110 | _ routes: WritableKeyPath]>,
111 | action: CaseKeyPath>,
112 | cancellationIdType: Any.Type = Self.self,
113 | @ReducerBuilder screenReducer: () -> ScreenReducer
114 | ) -> some ReducerOf
115 | where Action: CasePathable,
116 | ScreenState == ScreenReducer.State,
117 | ScreenAction == ScreenReducer.Action,
118 | ScreenAction: CasePathable
119 | {
120 | ForEachIndexedRoute(
121 | coordinatorReducer: self,
122 | screenReducer: screenReducer(),
123 | cancellationId: ObjectIdentifier(cancellationIdType),
124 | toLocalState: routes,
125 | toLocalAction: action
126 | )
127 | }
128 |
129 | /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
130 | /// the coordinator's routes Array will have its actions and state propagated. When screens are
131 | /// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which
132 | /// they originated is dismissed.
133 | /// - Parameters:
134 | /// - routes: A writable keypath for the routes array.
135 | /// - action: A casepath for the router action from this reducer's Action type.
136 | /// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It
137 | /// will be combined with the screen's identifier. Defaults to the type of the parent reducer.
138 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
139 | func forEachRoute(
140 | _ routes: WritableKeyPath]>,
141 | action: CaseKeyPath>,
142 | cancellationIdType: Any.Type = Self.self
143 | ) -> some ReducerOf
144 | where Action: CasePathable,
145 | ScreenState: CaseReducerState,
146 | ScreenState.StateReducer.Action == ScreenAction,
147 | ScreenAction: CasePathable
148 | {
149 | forEachRoute(routes, action: action, cancellationIdType: cancellationIdType) {
150 | ScreenState.StateReducer.body
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Foundation
3 |
4 | struct ForEachIdentifiedRoute<
5 | CoordinatorReducer: Reducer,
6 | ScreenReducer: Reducer,
7 | CoordinatorID: Hashable
8 | >: Reducer
9 | where ScreenReducer.State: Identifiable,
10 | CoordinatorReducer.Action: CasePathable,
11 | ScreenReducer.Action: CasePathable
12 | {
13 | let coordinatorReducer: CoordinatorReducer
14 | let screenReducer: ScreenReducer
15 | let cancellationId: CoordinatorID?
16 | let toLocalState: WritableKeyPath>>
17 | let toLocalAction: CaseKeyPath>
18 |
19 | var body: some ReducerOf {
20 | CancelEffectsOnDismiss(
21 | coordinatedScreensReducer: EmptyReducer()
22 | .forEach(toLocalState, action: toLocalAction.appending(path: \.routeAction).appending(path: \.[])) {
23 | OnRoutes(wrapped: screenReducer)
24 | }
25 | .updatingRoutesOnInteraction(
26 | updateRoutes: toLocalAction.appending(path: \.updateRoutes).appending(path: \.[]),
27 | toLocalState: toLocalState
28 | ),
29 | routes: toLocalState,
30 | routeAction: toLocalAction.appending(path: \.routeAction),
31 | cancellationId: cancellationId,
32 | getIdentifier: { element, _ in element.id },
33 | coordinatorReducer: coordinatorReducer
34 | )
35 | }
36 | }
37 |
38 | public extension Reducer {
39 | /// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in
40 | /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
41 | /// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects
42 | /// will be cancelled when the screen from which they originated is dismissed.
43 | /// - Parameters:
44 | /// - routes: A writable keypath for the routes `IdentifiedArray`.
45 | /// - action: A casepath for the router action from this reducer's Action type.
46 | /// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It
47 | /// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation.
48 | /// - screenReducer: The reducer that operates on all of the individual screens.
49 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
50 | func forEachRoute(
51 | _ routes: WritableKeyPath>>,
52 | action: CaseKeyPath>,
53 | cancellationId: (some Hashable)?,
54 | @ReducerBuilder screenReducer: () -> ScreenReducer
55 | ) -> some ReducerOf
56 | where Action: CasePathable,
57 | ScreenReducer.State: Identifiable,
58 | ScreenState == ScreenReducer.State,
59 | ScreenAction == ScreenReducer.Action,
60 | ScreenAction: CasePathable
61 | {
62 | ForEachIdentifiedRoute(
63 | coordinatorReducer: self,
64 | screenReducer: screenReducer(),
65 | cancellationId: cancellationId,
66 | toLocalState: routes,
67 | toLocalAction: action
68 | )
69 | }
70 |
71 | /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
72 | /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
73 | /// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which they
74 | /// originated is dismissed.
75 | /// - Parameters:
76 | /// - routes: A writable keypath for the routes `IdentifiedArray`.
77 | /// - action: A casepath for the router action from this reducer's Action type.
78 | /// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It
79 | /// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation.
80 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
81 | func forEachRoute(
82 | _ routes: WritableKeyPath>>,
83 | action: CaseKeyPath>,
84 | cancellationId: (some Hashable)?
85 | ) -> some ReducerOf
86 | where Action: CasePathable,
87 | ScreenState: CaseReducerState,
88 | ScreenState.StateReducer.Action == ScreenAction,
89 | ScreenAction: CasePathable
90 | {
91 | forEachRoute(
92 | routes,
93 | action: action,
94 | cancellationId: cancellationId
95 | ) {
96 | ScreenState.StateReducer.body
97 | }
98 | }
99 |
100 | /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
101 | /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
102 | /// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects
103 | /// will be cancelled when the screen from which they originated is dismissed.
104 | /// - Parameters:
105 | /// - routes: A writable keypath for the routes `IdentifiedArray`.
106 | /// - action: A casepath for the router action from this reducer's Action type.
107 | /// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It
108 | /// will be combined with the screen's identifier. Defaults to the type of the parent reducer.
109 | /// - screenReducer: The reducer that operates on all of the individual screens.
110 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
111 | func forEachRoute(
112 | _ routes: WritableKeyPath>>,
113 | action: CaseKeyPath>,
114 | cancellationIdType: Any.Type = Self.self,
115 | @ReducerBuilder screenReducer: () -> ScreenReducer
116 | ) -> some ReducerOf
117 | where ScreenReducer.State: Identifiable,
118 | Action: CasePathable,
119 | ScreenState == ScreenReducer.State,
120 | ScreenAction == ScreenReducer.Action,
121 | ScreenAction: CasePathable
122 | {
123 | ForEachIdentifiedRoute(
124 | coordinatorReducer: self,
125 | screenReducer: screenReducer(),
126 | cancellationId: ObjectIdentifier(cancellationIdType),
127 | toLocalState: routes,
128 | toLocalAction: action
129 | )
130 | }
131 |
132 | /// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
133 | /// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
134 | /// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which they
135 | /// originated is dismissed.
136 | /// - Parameters:
137 | /// - routes: A writable keypath for the routes `IdentifiedArray`.
138 | /// - action: A casepath for the router action from this reducer's Action type.
139 | /// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It
140 | /// will be combined with the screen's identifier. Defaults to the type of the parent reducer.
141 | /// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
142 | func forEachRoute(
143 | _ routes: WritableKeyPath>>,
144 | action: CaseKeyPath>,
145 | cancellationIdType: Any.Type = Self.self
146 | ) -> some ReducerOf
147 | where Action: CasePathable,
148 | ScreenState: CaseReducerState,
149 | ScreenState: Identifiable,
150 | ScreenState.StateReducer.Action == ScreenAction,
151 | ScreenAction: CasePathable
152 | {
153 | forEachRoute(routes, action: action, cancellationIdType: cancellationIdType) {
154 | ScreenState.StateReducer.body
155 | }
156 | }
157 | }
158 |
159 | extension Case {
160 | subscript() -> Case> where Value == [Element] {
161 | Case>(
162 | embed: { self._embed($0.elements) },
163 | extract: {
164 | self._extract(from: $0).flatMap { IdentifiedArrayOf(uniqueElements: $0) }
165 | }
166 | )
167 | }
168 |
169 | fileprivate subscript() -> Case> where Value == (id: ID, action: Action) {
170 | Case>(
171 | embed: { action in
172 | switch action {
173 | case let .element(id, action):
174 | self._embed((id, action))
175 | }
176 | },
177 | extract: {
178 | self._extract(from: $0).flatMap {
179 | .element(id: $0, action: $1)
180 | }
181 | }
182 | )
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TCACoordinators
2 |
3 | _The coordinator pattern in the Composable Architecture_
4 |
5 | `TCACoordinators` brings a flexible approach to navigation in SwiftUI using the [Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture). It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA with [a novel approach to handling navigation in SwiftUI](https://github.com/johnpatrickmorgan/FlowStacks).
6 |
7 | You might like this library if you want to:
8 |
9 | ✅ Support deeplinks into _deeply_ nested navigation routes in your app.
10 | ✅ Easily reuse screen features within different navigation contexts.
11 | ✅ Easily go back to the root screen or a specific screen in the navigation stack.
12 | ✅ Keep all navigation logic in a single place.
13 | ✅ Break an app's navigation into multiple reusable coordinators and compose them together.
14 | ✅ Use a single system to unify push navigation and modal presentation.
15 |
16 |
17 | The library works by translating the array of screens into a hierarchy of nested `NavigationLink`s and presentation calls, so:
18 |
19 | 🚫 It does not rely on UIKit at all.
20 | 🚫 It does not use `AnyView` to type-erase screens.
21 | 🚫 It does not try to recreate `NavigationView` from scratch.
22 |
23 |
24 | ## Usage example
25 |
26 | ### Step 1 - Create a screen reducer
27 |
28 | First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Thanks to the `@Reducer macro`, this can be easily achieved with an enum reducer, e.g. the following (where `Home`, `NumbersList` and `NumberDetail` are the individual screen reducers):
29 |
30 | ```swift
31 | @Reducer(state: .hashable)
32 | enum Screen {
33 | case home(Home)
34 | case numbersList(NumbersList)
35 | case numberDetail(NumberDetail)
36 | }
37 | ```
38 |
39 | ### Step 2 - Create a coordinator reducer
40 |
41 | 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(Screen.State)`.
42 |
43 | ```swift
44 | @Reducer
45 | struct Coordinator {
46 | @ObservableState
47 | struct State: Equatable {
48 | var routes: [Route]
49 | }
50 | ...
51 | }
52 | ```
53 |
54 | The coordinator's action should include a special case, which will allow screen actions to be dispatched to the correct screen in the routes array, and allow the routes array to be updated automatically, e.g. when a user taps 'Back':
55 |
56 | ```swift
57 | @Reducer
58 | struct Coordinator {
59 | ...
60 |
61 | enum Action {
62 | case router(IndexedRouterActionOf)
63 | }
64 | ...
65 | }
66 | ```
67 |
68 | 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. `forEachRoute` takes two arguments: a keypath for the routes array and a case path for the router action case:
69 |
70 | ```swift
71 | @Reducer
72 | struct Coordinator {
73 | ...
74 | var body: some ReducerOf {
75 | Reduce { state, action in
76 | switch action {
77 | case .router(.routeAction(_, .home(.startTapped))):
78 | state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true)
79 |
80 | case .router(.routeAction(_, .numbersList(.numberSelected(let number)))):
81 | state.routes.push(.numberDetail(.init(number: number)))
82 |
83 | case .router(.routeAction(_, .numberDetail(.showDouble(let number)))):
84 | state.routes.presentSheet(.numberDetail(.init(number: number * 2)))
85 |
86 | case .router(.routeAction(_, .numberDetail(.goBackTapped))):
87 | state.routes.goBack()
88 |
89 | default:
90 | break
91 | }
92 | return .none
93 | }
94 | .forEachRoute(\.routes, action: \.router)
95 | }
96 | }
97 | ```
98 |
99 | ### Step 3 - Create a coordinator view
100 |
101 | With that in place, a `CoordinatorView` can be created. It will use a `TCARouter`, which translates the array of routes into a nested list of screen views with invisible `NavigationLinks` and presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, the `TCARouter` takes a closure that can create the view for any screen in the navigation flow. A switch statement is the natural way to achieve that, with a case for each of the possible screens:
102 |
103 | ```swift
104 | struct CoordinatorView: View {
105 | let store: StoreOf
106 |
107 | var body: some View {
108 | TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
109 | switch screen.case {
110 | case let .home(store):
111 | HomeView(store: store)
112 |
113 | case let .numbersList(store):
114 | NumbersListView(store: store)
115 |
116 | case let .numberDetail(store):
117 | NumberDetailView(store: store)
118 | }
119 | }
120 | }
121 | }
122 | ```
123 |
124 |
125 | ## Convenience methods
126 |
127 | The routes array can be managed using normal Array methods such as `append`, but a number of convenience methods are available for common transformations, such as:
128 |
129 | | Method | Effect |
130 | |--------------|---------------------------------------------------|
131 | | push | Pushes a new screen onto the stack. |
132 | | presentSheet | Presents a new screen as a sheet.† |
133 | | presentCover | Presents a new screen as a full-screen cover.† |
134 | | goBack | Goes back one screen in the stack. |
135 | | goBackToRoot | Goes back to the very first screen in the stack. |
136 | | goBackTo | Goes back to a specific screen in the stack. |
137 | | pop | Pops the current screen if it was pushed. |
138 | | dismiss | Dismisses the most recently presented screen. |
139 |
140 | † _Pass `embedInNavigationView: true` if you want to be able to push screens from the presented screen._
141 |
142 |
143 | ## Routes array automatically updated
144 |
145 | If the user taps the back button, the routes array will be automatically updated to reflect the new navigation state. Navigating back with an edge swipe gesture or via a long-press gesture on the back button will also update the routes array automatically, as will swiping to dismiss a sheet.
146 |
147 |
148 | ## Cancellation of in-flight effects on dismiss
149 |
150 | By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. To opt out of automatic cancellation, pass `cancellationId: nil` to `forEachRoute`.
151 |
152 |
153 | ## Making complex navigation updates
154 |
155 | SwiftUI does not allow more than one screen to be pushed, presented or dismissed within a single update. This makes it tricky to make large updates to the navigation state, e.g. when deeplinking straight to a view several layers deep in the navigation hierarchy, when going back multiple presentation layers to the root, or when restoring arbitrary navigation state. This library provides a workaround: it can break down large unsupported updates into a series of smaller updates that SwiftUI does support, interspersed with the necessary delays, and make that available as an Effect to be returned from a coordinator reducer. You just need to wrap route mutations in a call to `Effect.routeWithDelaysIfUnsupported`, e.g.:
156 |
157 | ```swift
158 | return Effect.routeWithDelaysIfUnsupported(state.routes, action: \.router) {
159 | $0.goBackToRoot()
160 | }
161 | ```
162 |
163 | ```swift
164 | return Effect.routeWithDelaysIfUnsupported(state.routes, action: \.router) {
165 | $0.push(...)
166 | $0.push(...)
167 | $0.presentSheet(...)
168 | }
169 | ```
170 |
171 |
172 | ## Composing child coordinators
173 |
174 | The coordinator is just like any other UI unit in the Composable Architecture - comprising a `View` and a `Reducer` with `State` and `Action` types. This means they can be composed in all the normal ways SwiftUI and TCA allow. You can present a coordinator, add it to a `TabView`, even push or present a child coordinator from a parent coordinator by adding it to the routes array. When doing so, it is best that the child coordinator is only ever the last element of the parent's routes array, as it will take over responsibility for pushing and presenting new screens until dismissed. Otherwise, the parent might attempt to push screen(s) when the child is already pushing screen(s), causing a conflict.
175 |
176 |
177 | ## Identifying screens
178 |
179 | In the example given, the `Coordinator.Action`'s router case included an associated value of `IndexedRouterActionOf`. That means that screens were identified by their index in the routes array. This is safe because the index is stable for standard navigation updates - e.g. pushing and popping do not affect the indexes of existing screens. However, if you prefer to use `Identifiable` screens, you can manage the screens as an `IdentifiedArray` instead. The `Coordinator.Action`'s router case will then have an associated value of `IdentifiedRouterActionOf` instead, and benefit from the same terse API as the example above.
180 |
181 |
182 | ## Flexible and reusable
183 |
184 | If the flow of screens needs to change, the change can be made easily in one place. The screen views and reducers (along with their state and action types) no longer need to have any knowledge of any other screens in the navigation flow - they can simply send an action and leave the coordinator to decide whether a new view should be pushed or presented - which makes it easy to re-use them in different contexts, and helps separate screen responsibilities from navigation responsibilities.
185 |
186 |
187 | ## How does it work?
188 |
189 | This library uses [FlowStacks](https://github.com/johnpatrickmorgan/FlowStacks) for hoisting navigation state out of individual screens. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture.
190 |
191 |
192 | ## Migrating from earlier versions
193 |
194 | ### From v0.8 and lower
195 |
196 | There has been an API change from v0.8 to v0.9, to bring the library's APIs more in-line with the Composable Architecture, including the use of case paths. If you're migrating to these new APIs please see the [migration docs](Docs/Migration/Migrating%20from%200.8.md).
197 |
198 | ### From v0.11 and lower
199 |
200 | v0.12 introduced a requirement that the screen reducer's state conform to `Hashable`: see the [migration docs](Docs/Migration/Migrating%20from%200.11.md).
201 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample/Game/GameView.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import ComposableArchitecture
3 | import SwiftUI
4 | import UIKit
5 |
6 | // Adapted from: https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/TicTacToe/tic-tac-toe/Sources/GameCore
7 |
8 | struct GameView: UIViewControllerRepresentable {
9 | let store: StoreOf
10 |
11 | typealias UIViewControllerType = GameViewController
12 |
13 | func makeUIViewController(context _: Context) -> GameViewController {
14 | GameViewController(store: store)
15 | }
16 |
17 | func updateUIViewController(_: GameViewController, context _: Context) {}
18 | }
19 |
20 | final class GameViewController: UIViewController {
21 | let store: StoreOf
22 | private var observationToken: ObserveToken?
23 |
24 | init(store: StoreOf) {
25 | self.store = store
26 | super.init(nibName: nil, bundle: nil)
27 | }
28 |
29 | @available(*, unavailable)
30 | required init?(coder _: NSCoder) {
31 | fatalError("init(coder:) has not been implemented")
32 | }
33 |
34 | override func viewDidLoad() {
35 | super.viewDidLoad()
36 |
37 | navigationItem.title = "Tic-Tac-Toe"
38 | view.backgroundColor = .systemBackground
39 |
40 | navigationItem.leftBarButtonItem = UIBarButtonItem(
41 | title: "Quit",
42 | style: .done,
43 | target: self,
44 | action: #selector(quitButtonTapped)
45 | )
46 |
47 | let titleLabel = UILabel()
48 | titleLabel.textAlignment = .center
49 |
50 | let logOutButton = UIButton(type: .system)
51 | logOutButton.setTitle("Log out", for: .normal)
52 | logOutButton.addTarget(self, action: #selector(logOutButtonTapped), for: .touchUpInside)
53 |
54 | let titleStackView = UIStackView(arrangedSubviews: [titleLabel, logOutButton])
55 | titleStackView.axis = .vertical
56 | titleStackView.spacing = 2
57 |
58 | let gridCell11 = UIButton()
59 | gridCell11.addTarget(self, action: #selector(gridCell11Tapped), for: .touchUpInside)
60 | let gridCell21 = UIButton()
61 | gridCell21.addTarget(self, action: #selector(gridCell21Tapped), for: .touchUpInside)
62 | let gridCell31 = UIButton()
63 | gridCell31.addTarget(self, action: #selector(gridCell31Tapped), for: .touchUpInside)
64 | let gridCell12 = UIButton()
65 | gridCell12.addTarget(self, action: #selector(gridCell12Tapped), for: .touchUpInside)
66 | let gridCell22 = UIButton()
67 | gridCell22.addTarget(self, action: #selector(gridCell22Tapped), for: .touchUpInside)
68 | let gridCell32 = UIButton()
69 | gridCell32.addTarget(self, action: #selector(gridCell32Tapped), for: .touchUpInside)
70 | let gridCell13 = UIButton()
71 | gridCell13.addTarget(self, action: #selector(gridCell13Tapped), for: .touchUpInside)
72 | let gridCell23 = UIButton()
73 | gridCell23.addTarget(self, action: #selector(gridCell23Tapped), for: .touchUpInside)
74 | let gridCell33 = UIButton()
75 | gridCell33.addTarget(self, action: #selector(gridCell33Tapped), for: .touchUpInside)
76 |
77 | let cells = [
78 | [gridCell11, gridCell12, gridCell13],
79 | [gridCell21, gridCell22, gridCell23],
80 | [gridCell31, gridCell32, gridCell33],
81 | ]
82 |
83 | let gameRow1StackView = UIStackView(arrangedSubviews: cells[0])
84 | gameRow1StackView.spacing = 6
85 | let gameRow2StackView = UIStackView(arrangedSubviews: cells[1])
86 | gameRow2StackView.spacing = 6
87 | let gameRow3StackView = UIStackView(arrangedSubviews: cells[2])
88 | gameRow3StackView.spacing = 6
89 |
90 | let gameStackView = UIStackView(arrangedSubviews: [
91 | gameRow1StackView,
92 | gameRow2StackView,
93 | gameRow3StackView,
94 | ])
95 | gameStackView.axis = .vertical
96 | gameStackView.spacing = 6
97 |
98 | let rootStackView = UIStackView(arrangedSubviews: [
99 | titleStackView,
100 | gameStackView,
101 | ])
102 | rootStackView.isLayoutMarginsRelativeArrangement = true
103 | rootStackView.layoutMargins = .init(top: 0, left: 32, bottom: 0, right: 32)
104 | rootStackView.translatesAutoresizingMaskIntoConstraints = false
105 | rootStackView.axis = .vertical
106 | rootStackView.spacing = 100
107 |
108 | view.addSubview(rootStackView)
109 |
110 | NSLayoutConstraint.activate([
111 | rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
112 | rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
113 | rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
114 | ])
115 |
116 | gameStackView.arrangedSubviews
117 | .flatMap { view in (view as? UIStackView)?.arrangedSubviews ?? [] }
118 | .enumerated()
119 | .forEach { idx, cellView in
120 | cellView.backgroundColor = idx % 2 == 0 ? .darkGray : .lightGray
121 | NSLayoutConstraint.activate([
122 | cellView.widthAnchor.constraint(equalTo: cellView.heightAnchor),
123 | ])
124 | }
125 |
126 | observationToken = observe { [weak self] in
127 | guard let self else { return }
128 |
129 | titleLabel.text = store.title
130 |
131 | for (rowIdx, row) in store.gameBoard.enumerated() {
132 | for (colIdx, label) in row.enumerated() {
133 | let button = cells[rowIdx][colIdx]
134 | button.setTitle(label, for: .normal)
135 | button.isEnabled = store.isGameEnabled
136 | }
137 | }
138 | }
139 | }
140 |
141 | @objc private func gridCell11Tapped() { store.send(.cellTapped(row: 0, column: 0)) }
142 | @objc private func gridCell12Tapped() { store.send(.cellTapped(row: 0, column: 1)) }
143 | @objc private func gridCell13Tapped() { store.send(.cellTapped(row: 0, column: 2)) }
144 | @objc private func gridCell21Tapped() { store.send(.cellTapped(row: 1, column: 0)) }
145 | @objc private func gridCell22Tapped() { store.send(.cellTapped(row: 1, column: 1)) }
146 | @objc private func gridCell23Tapped() { store.send(.cellTapped(row: 1, column: 2)) }
147 | @objc private func gridCell31Tapped() { store.send(.cellTapped(row: 2, column: 0)) }
148 | @objc private func gridCell32Tapped() { store.send(.cellTapped(row: 2, column: 1)) }
149 | @objc private func gridCell33Tapped() { store.send(.cellTapped(row: 2, column: 2)) }
150 |
151 | @objc private func quitButtonTapped() {
152 | store.send(.quitButtonTapped)
153 | }
154 |
155 | @objc private func playAgainButtonTapped() {
156 | store.send(.playAgainButtonTapped)
157 | }
158 |
159 | @objc private func logOutButtonTapped() {
160 | store.send(.logOutButtonTapped)
161 | }
162 | }
163 |
164 | @Reducer
165 | struct Game {
166 | @ObservableState
167 | struct State: Hashable {
168 | let id = UUID()
169 | var board: Three> = .empty
170 | var currentPlayer: Player = .x
171 | var oPlayerName: String
172 | var xPlayerName: String
173 |
174 | init(oPlayerName: String, xPlayerName: String) {
175 | self.oPlayerName = oPlayerName
176 | self.xPlayerName = xPlayerName
177 | }
178 |
179 | var currentPlayerName: String {
180 | switch currentPlayer {
181 | case .o: oPlayerName
182 | case .x: xPlayerName
183 | }
184 | }
185 | }
186 |
187 | enum Action: Equatable {
188 | case cellTapped(row: Int, column: Int)
189 | case playAgainButtonTapped
190 | case logOutButtonTapped
191 | case quitButtonTapped
192 | case gameCompleted(winner: Player?)
193 | }
194 |
195 | var body: some ReducerOf {
196 | Reduce { state, action in
197 | switch action {
198 | case let .cellTapped(row, column):
199 | guard
200 | state.board[row][column] == nil,
201 | !state.board.hasWinner
202 | else { return .none }
203 |
204 | state.board[row][column] = state.currentPlayer
205 |
206 | if !state.board.hasWinner {
207 | state.currentPlayer.toggle()
208 | }
209 |
210 | if state.board.hasWinner || state.board.isFilled {
211 | return .run { [winner = state.board.winner] send in
212 | await send(.gameCompleted(winner: winner))
213 | }
214 | }
215 |
216 | return .none
217 |
218 | case .playAgainButtonTapped:
219 | state = Game.State(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName)
220 | return .none
221 |
222 | case .quitButtonTapped, .logOutButtonTapped, .gameCompleted:
223 | return .none
224 | }
225 | }
226 | }
227 | }
228 |
229 | /// A collection of three elements.
230 | struct Three: CustomStringConvertible, Sendable {
231 | var first: Element
232 | var second: Element
233 | var third: Element
234 |
235 | init(_ first: Element, _ second: Element, _ third: Element) {
236 | self.first = first
237 | self.second = second
238 | self.third = third
239 | }
240 |
241 | func map(_ transform: (Element) -> T) -> Three {
242 | .init(transform(first), transform(second), transform(third))
243 | }
244 |
245 | var description: String {
246 | "[\(first),\(second),\(third)]"
247 | }
248 | }
249 |
250 | extension Three: MutableCollection {
251 | subscript(offset: Int) -> Element {
252 | _read {
253 | switch offset {
254 | case 0: yield self.first
255 | case 1: yield self.second
256 | case 2: yield self.third
257 | default: fatalError()
258 | }
259 | }
260 | _modify {
261 | switch offset {
262 | case 0: yield &self.first
263 | case 1: yield &self.second
264 | case 2: yield &self.third
265 | default: fatalError()
266 | }
267 | }
268 | }
269 |
270 | var startIndex: Int { 0 }
271 | var endIndex: Int { 3 }
272 | func index(after i: Int) -> Int { i + 1 }
273 | }
274 |
275 | extension Three: RandomAccessCollection {}
276 |
277 | extension Three: Equatable where Element: Equatable {}
278 | extension Three: Hashable where Element: Hashable {}
279 |
280 | enum Player: Equatable {
281 | case o
282 | case x
283 |
284 | mutating func toggle() {
285 | switch self {
286 | case .o: self = .x
287 | case .x: self = .o
288 | }
289 | }
290 |
291 | var label: String {
292 | switch self {
293 | case .o: "⭕️"
294 | case .x: "❌"
295 | }
296 | }
297 | }
298 |
299 | extension Three where Element == Three {
300 | static let empty = Self(
301 | .init(nil, nil, nil),
302 | .init(nil, nil, nil),
303 | .init(nil, nil, nil)
304 | )
305 |
306 | var isFilled: Bool {
307 | allSatisfy { $0.allSatisfy { $0 != nil } }
308 | }
309 |
310 | var winner: Player? {
311 | if hasWin(.o) { .o } else if hasWin(.x) { .x } else { nil }
312 | }
313 |
314 | var hasWinner: Bool {
315 | hasWin(.o) || hasWin(.x)
316 | }
317 |
318 | func hasWin(_ player: Player) -> Bool {
319 | let winConditions = [
320 | [0, 1, 2], [3, 4, 5], [6, 7, 8],
321 | [0, 3, 6], [1, 4, 7], [2, 5, 8],
322 | [0, 4, 8], [6, 4, 2],
323 | ]
324 |
325 | for condition in winConditions {
326 | let matches =
327 | condition
328 | .map { self[$0 % 3][$0 / 3] }
329 | let matchCount =
330 | matches
331 | .filter { $0 == player }
332 | .count
333 |
334 | if matchCount == 3 {
335 | return true
336 | }
337 | }
338 | return false
339 | }
340 | }
341 |
--------------------------------------------------------------------------------
/TCACoordinatorsExample/TCACoordinatorsExample.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 55;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 524087E4278E3D950048C6EE /* IndexedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524087E1278E3D950048C6EE /* IndexedCoordinator.swift */; };
11 | 524087E5278E3D950048C6EE /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524087E2278E3D950048C6EE /* Screen.swift */; };
12 | 524087E6278E3D950048C6EE /* IdentifiedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524087E3278E3D950048C6EE /* IdentifiedCoordinator.swift */; };
13 | 524087E7278E3D9F0048C6EE /* IdentifiedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524087E3278E3D950048C6EE /* IdentifiedCoordinator.swift */; };
14 | 524087E8278E3D9F0048C6EE /* IndexedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524087E1278E3D950048C6EE /* IndexedCoordinator.swift */; };
15 | 524087E9278E3D9F0048C6EE /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524087E2278E3D950048C6EE /* Screen.swift */; };
16 | 5248863026F2A26200970899 /* TCACoordinatorsExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248862F26F2A26200970899 /* TCACoordinatorsExampleApp.swift */; };
17 | 5248863426F2A26400970899 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5248863326F2A26400970899 /* Assets.xcassets */; };
18 | 5248863726F2A26400970899 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5248863626F2A26400970899 /* Preview Assets.xcassets */; };
19 | 5248864126F2A26500970899 /* TCACoordinatorsExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248864026F2A26500970899 /* TCACoordinatorsExampleTests.swift */; };
20 | 5248864B26F2A26500970899 /* TCACoordinatorsExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248864A26F2A26500970899 /* TCACoordinatorsExampleUITests.swift */; };
21 | 5248864D26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248864C26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift */; };
22 | 5248866026F2A53B00970899 /* TCACoordinators in Frameworks */ = {isa = PBXBuildFile; productRef = 5248865F26F2A53B00970899 /* TCACoordinators */; };
23 | 528AE0E92BECE20F00E143C5 /* FormScreen+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */; };
24 | 528AE0EB2BED197000E143C5 /* OutcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528AE0EA2BED197000E143C5 /* OutcomeView.swift */; };
25 | 528FEDEB2880BD94007765AD /* Step3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDEA2880BC61007765AD /* Step3.swift */; };
26 | 528FEDEC2880BD94007765AD /* Step1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE82880BC61007765AD /* Step1.swift */; };
27 | 528FEDED2880BD94007765AD /* FinalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE72880BC60007765AD /* FinalScreen.swift */; };
28 | 528FEDEE2880BD94007765AD /* FormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE42880BC60007765AD /* FormScreen.swift */; };
29 | 528FEDEF2880BD94007765AD /* FormAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */; };
30 | 528FEDF12880BD94007765AD /* Step2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE92880BC61007765AD /* Step2.swift */; };
31 | 528FEDF22880BD95007765AD /* Step3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDEA2880BC61007765AD /* Step3.swift */; };
32 | 528FEDF32880BD95007765AD /* Step1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE82880BC61007765AD /* Step1.swift */; };
33 | 528FEDF42880BD95007765AD /* FinalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE72880BC60007765AD /* FinalScreen.swift */; };
34 | 528FEDF52880BD95007765AD /* FormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE42880BC60007765AD /* FormScreen.swift */; };
35 | 528FEDF62880BD95007765AD /* FormAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */; };
36 | 528FEDF82880BD95007765AD /* Step2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528FEDE92880BC61007765AD /* Step2.swift */; };
37 | 529822D2283D76AD0011112B /* GameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822CE283D76AD0011112B /* GameView.swift */; };
38 | 529822D3283D76AD0011112B /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822CF283D76AD0011112B /* AppCoordinator.swift */; };
39 | 529822D4283D76AD0011112B /* LogInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D0283D76AD0011112B /* LogInCoordinator.swift */; };
40 | 529822D5283D76AD0011112B /* GameCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D1283D76AD0011112B /* GameCoordinator.swift */; };
41 | 529822D7283D76F60011112B /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D6283D76F60011112B /* WelcomeView.swift */; };
42 | 529822D9283D77060011112B /* LogInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529822D8283D77060011112B /* LogInView.swift */; };
43 | 912FC7232BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7222BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift */; };
44 | 912FC7252BEBAFAA0036B444 /* FormScreen+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */; };
45 | 912FC7272BEBB9080036B444 /* GameViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912FC7262BEBB9080036B444 /* GameViewState.swift */; };
46 | /* End PBXBuildFile section */
47 |
48 | /* Begin PBXContainerItemProxy section */
49 | 5248863D26F2A26500970899 /* PBXContainerItemProxy */ = {
50 | isa = PBXContainerItemProxy;
51 | containerPortal = 5248862426F2A26200970899 /* Project object */;
52 | proxyType = 1;
53 | remoteGlobalIDString = 5248862B26F2A26200970899;
54 | remoteInfo = TCACoordinatorsExample;
55 | };
56 | 5248864726F2A26500970899 /* PBXContainerItemProxy */ = {
57 | isa = PBXContainerItemProxy;
58 | containerPortal = 5248862426F2A26200970899 /* Project object */;
59 | proxyType = 1;
60 | remoteGlobalIDString = 5248862B26F2A26200970899;
61 | remoteInfo = TCACoordinatorsExample;
62 | };
63 | /* End PBXContainerItemProxy section */
64 |
65 | /* Begin PBXFileReference section */
66 | 52393DCF2A12E45F00CD207C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
67 | 524087E1278E3D950048C6EE /* IndexedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndexedCoordinator.swift; sourceTree = ""; };
68 | 524087E2278E3D950048C6EE /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; };
69 | 524087E3278E3D950048C6EE /* IdentifiedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifiedCoordinator.swift; sourceTree = ""; };
70 | 5248862C26F2A26200970899 /* TCACoordinatorsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TCACoordinatorsExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
71 | 5248862F26F2A26200970899 /* TCACoordinatorsExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCACoordinatorsExampleApp.swift; sourceTree = ""; };
72 | 5248863326F2A26400970899 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
73 | 5248863626F2A26400970899 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
74 | 5248863C26F2A26500970899 /* TCACoordinatorsExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TCACoordinatorsExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
75 | 5248864026F2A26500970899 /* TCACoordinatorsExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCACoordinatorsExampleTests.swift; sourceTree = ""; };
76 | 5248864626F2A26500970899 /* TCACoordinatorsExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TCACoordinatorsExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
77 | 5248864A26F2A26500970899 /* TCACoordinatorsExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCACoordinatorsExampleUITests.swift; sourceTree = ""; };
78 | 5248864C26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCACoordinatorsExampleUITestsLaunchTests.swift; sourceTree = ""; };
79 | 5248865A26F2A2C200970899 /* TCACoordinators */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TCACoordinators; path = ..; sourceTree = ""; };
80 | 528AE0EA2BED197000E143C5 /* OutcomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutcomeView.swift; sourceTree = ""; };
81 | 528FEDE42880BC60007765AD /* FormScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormScreen.swift; sourceTree = ""; };
82 | 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormAppCoordinator.swift; sourceTree = ""; };
83 | 528FEDE72880BC60007765AD /* FinalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalScreen.swift; sourceTree = ""; };
84 | 528FEDE82880BC61007765AD /* Step1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Step1.swift; sourceTree = ""; };
85 | 528FEDE92880BC61007765AD /* Step2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Step2.swift; sourceTree = ""; };
86 | 528FEDEA2880BC61007765AD /* Step3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Step3.swift; sourceTree = ""; };
87 | 529822CE283D76AD0011112B /* GameView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameView.swift; sourceTree = ""; };
88 | 529822CF283D76AD0011112B /* AppCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; };
89 | 529822D0283D76AD0011112B /* LogInCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogInCoordinator.swift; sourceTree = "