├── .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 = ""; }; 90 | 529822D1283D76AD0011112B /* GameCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameCoordinator.swift; sourceTree = ""; }; 91 | 529822D6283D76F60011112B /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 92 | 529822D8283D77060011112B /* LogInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogInView.swift; sourceTree = ""; }; 93 | 912FC7222BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LogInScreen+StateIdentifiable.swift"; sourceTree = ""; }; 94 | 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormScreen+Identifiable.swift"; sourceTree = ""; }; 95 | 912FC7262BEBB9080036B444 /* GameViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewState.swift; sourceTree = ""; }; 96 | /* End PBXFileReference section */ 97 | 98 | /* Begin PBXFrameworksBuildPhase section */ 99 | 5248862926F2A26200970899 /* Frameworks */ = { 100 | isa = PBXFrameworksBuildPhase; 101 | buildActionMask = 2147483647; 102 | files = ( 103 | 5248866026F2A53B00970899 /* TCACoordinators in Frameworks */, 104 | ); 105 | runOnlyForDeploymentPostprocessing = 0; 106 | }; 107 | 5248863926F2A26500970899 /* Frameworks */ = { 108 | isa = PBXFrameworksBuildPhase; 109 | buildActionMask = 2147483647; 110 | files = ( 111 | ); 112 | runOnlyForDeploymentPostprocessing = 0; 113 | }; 114 | 5248864326F2A26500970899 /* Frameworks */ = { 115 | isa = PBXFrameworksBuildPhase; 116 | buildActionMask = 2147483647; 117 | files = ( 118 | ); 119 | runOnlyForDeploymentPostprocessing = 0; 120 | }; 121 | /* End PBXFrameworksBuildPhase section */ 122 | 123 | /* Begin PBXGroup section */ 124 | 5248862326F2A26200970899 = { 125 | isa = PBXGroup; 126 | children = ( 127 | 5248865926F2A2C200970899 /* Packages */, 128 | 5248862E26F2A26200970899 /* TCACoordinatorsExample */, 129 | 5248863F26F2A26500970899 /* TCACoordinatorsExampleTests */, 130 | 5248864926F2A26500970899 /* TCACoordinatorsExampleUITests */, 131 | 5248862D26F2A26200970899 /* Products */, 132 | 5248865E26F2A53B00970899 /* Frameworks */, 133 | ); 134 | sourceTree = ""; 135 | }; 136 | 5248862D26F2A26200970899 /* Products */ = { 137 | isa = PBXGroup; 138 | children = ( 139 | 5248862C26F2A26200970899 /* TCACoordinatorsExample.app */, 140 | 5248863C26F2A26500970899 /* TCACoordinatorsExampleTests.xctest */, 141 | 5248864626F2A26500970899 /* TCACoordinatorsExampleUITests.xctest */, 142 | ); 143 | name = Products; 144 | sourceTree = ""; 145 | }; 146 | 5248862E26F2A26200970899 /* TCACoordinatorsExample */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | 52393DCF2A12E45F00CD207C /* Info.plist */, 150 | 5248862F26F2A26200970899 /* TCACoordinatorsExampleApp.swift */, 151 | 528FEDE32880BC58007765AD /* Form */, 152 | 529822CD283D76AD0011112B /* Game */, 153 | 524087E3278E3D950048C6EE /* IdentifiedCoordinator.swift */, 154 | 524087E1278E3D950048C6EE /* IndexedCoordinator.swift */, 155 | 524087E2278E3D950048C6EE /* Screen.swift */, 156 | 5248863326F2A26400970899 /* Assets.xcassets */, 157 | 5248863526F2A26400970899 /* Preview Content */, 158 | ); 159 | path = TCACoordinatorsExample; 160 | sourceTree = ""; 161 | }; 162 | 5248863526F2A26400970899 /* Preview Content */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | 5248863626F2A26400970899 /* Preview Assets.xcassets */, 166 | ); 167 | path = "Preview Content"; 168 | sourceTree = ""; 169 | }; 170 | 5248863F26F2A26500970899 /* TCACoordinatorsExampleTests */ = { 171 | isa = PBXGroup; 172 | children = ( 173 | 5248864026F2A26500970899 /* TCACoordinatorsExampleTests.swift */, 174 | ); 175 | path = TCACoordinatorsExampleTests; 176 | sourceTree = ""; 177 | }; 178 | 5248864926F2A26500970899 /* TCACoordinatorsExampleUITests */ = { 179 | isa = PBXGroup; 180 | children = ( 181 | 5248864A26F2A26500970899 /* TCACoordinatorsExampleUITests.swift */, 182 | 5248864C26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift */, 183 | ); 184 | path = TCACoordinatorsExampleUITests; 185 | sourceTree = ""; 186 | }; 187 | 5248865926F2A2C200970899 /* Packages */ = { 188 | isa = PBXGroup; 189 | children = ( 190 | 5248865A26F2A2C200970899 /* TCACoordinators */, 191 | ); 192 | name = Packages; 193 | sourceTree = ""; 194 | }; 195 | 5248865E26F2A53B00970899 /* Frameworks */ = { 196 | isa = PBXGroup; 197 | children = ( 198 | ); 199 | name = Frameworks; 200 | sourceTree = ""; 201 | }; 202 | 528FEDE32880BC58007765AD /* Form */ = { 203 | isa = PBXGroup; 204 | children = ( 205 | 528FEDE62880BC60007765AD /* FormAppCoordinator.swift */, 206 | 528FEDE42880BC60007765AD /* FormScreen.swift */, 207 | 912FC7242BEBAFAA0036B444 /* FormScreen+Identifiable.swift */, 208 | 528FEDE72880BC60007765AD /* FinalScreen.swift */, 209 | 528FEDE82880BC61007765AD /* Step1.swift */, 210 | 528FEDE92880BC61007765AD /* Step2.swift */, 211 | 528FEDEA2880BC61007765AD /* Step3.swift */, 212 | ); 213 | path = Form; 214 | sourceTree = ""; 215 | }; 216 | 529822CD283D76AD0011112B /* Game */ = { 217 | isa = PBXGroup; 218 | children = ( 219 | 529822CE283D76AD0011112B /* GameView.swift */, 220 | 912FC7262BEBB9080036B444 /* GameViewState.swift */, 221 | 529822CF283D76AD0011112B /* AppCoordinator.swift */, 222 | 529822D0283D76AD0011112B /* LogInCoordinator.swift */, 223 | 912FC7222BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift */, 224 | 529822D1283D76AD0011112B /* GameCoordinator.swift */, 225 | 529822D6283D76F60011112B /* WelcomeView.swift */, 226 | 528AE0EA2BED197000E143C5 /* OutcomeView.swift */, 227 | 529822D8283D77060011112B /* LogInView.swift */, 228 | ); 229 | path = Game; 230 | sourceTree = ""; 231 | }; 232 | /* End PBXGroup section */ 233 | 234 | /* Begin PBXNativeTarget section */ 235 | 5248862B26F2A26200970899 /* TCACoordinatorsExample */ = { 236 | isa = PBXNativeTarget; 237 | buildConfigurationList = 5248865026F2A26500970899 /* Build configuration list for PBXNativeTarget "TCACoordinatorsExample" */; 238 | buildPhases = ( 239 | 5248862826F2A26200970899 /* Sources */, 240 | 5248862926F2A26200970899 /* Frameworks */, 241 | 5248862A26F2A26200970899 /* Resources */, 242 | ); 243 | buildRules = ( 244 | ); 245 | dependencies = ( 246 | ); 247 | name = TCACoordinatorsExample; 248 | packageProductDependencies = ( 249 | 5248865F26F2A53B00970899 /* TCACoordinators */, 250 | ); 251 | productName = TCACoordinatorsExample; 252 | productReference = 5248862C26F2A26200970899 /* TCACoordinatorsExample.app */; 253 | productType = "com.apple.product-type.application"; 254 | }; 255 | 5248863B26F2A26500970899 /* TCACoordinatorsExampleTests */ = { 256 | isa = PBXNativeTarget; 257 | buildConfigurationList = 5248865326F2A26500970899 /* Build configuration list for PBXNativeTarget "TCACoordinatorsExampleTests" */; 258 | buildPhases = ( 259 | 5248863826F2A26500970899 /* Sources */, 260 | 5248863926F2A26500970899 /* Frameworks */, 261 | 5248863A26F2A26500970899 /* Resources */, 262 | ); 263 | buildRules = ( 264 | ); 265 | dependencies = ( 266 | 5248863E26F2A26500970899 /* PBXTargetDependency */, 267 | ); 268 | name = TCACoordinatorsExampleTests; 269 | productName = TCACoordinatorsExampleTests; 270 | productReference = 5248863C26F2A26500970899 /* TCACoordinatorsExampleTests.xctest */; 271 | productType = "com.apple.product-type.bundle.unit-test"; 272 | }; 273 | 5248864526F2A26500970899 /* TCACoordinatorsExampleUITests */ = { 274 | isa = PBXNativeTarget; 275 | buildConfigurationList = 5248865626F2A26500970899 /* Build configuration list for PBXNativeTarget "TCACoordinatorsExampleUITests" */; 276 | buildPhases = ( 277 | 5248864226F2A26500970899 /* Sources */, 278 | 5248864326F2A26500970899 /* Frameworks */, 279 | 5248864426F2A26500970899 /* Resources */, 280 | ); 281 | buildRules = ( 282 | ); 283 | dependencies = ( 284 | 5248864826F2A26500970899 /* PBXTargetDependency */, 285 | ); 286 | name = TCACoordinatorsExampleUITests; 287 | productName = TCACoordinatorsExampleUITests; 288 | productReference = 5248864626F2A26500970899 /* TCACoordinatorsExampleUITests.xctest */; 289 | productType = "com.apple.product-type.bundle.ui-testing"; 290 | }; 291 | /* End PBXNativeTarget section */ 292 | 293 | /* Begin PBXProject section */ 294 | 5248862426F2A26200970899 /* Project object */ = { 295 | isa = PBXProject; 296 | attributes = { 297 | BuildIndependentTargetsInParallel = 1; 298 | LastSwiftUpdateCheck = 1300; 299 | LastUpgradeCheck = 1300; 300 | TargetAttributes = { 301 | 5248862B26F2A26200970899 = { 302 | CreatedOnToolsVersion = 13.0; 303 | }; 304 | 5248863B26F2A26500970899 = { 305 | CreatedOnToolsVersion = 13.0; 306 | TestTargetID = 5248862B26F2A26200970899; 307 | }; 308 | 5248864526F2A26500970899 = { 309 | CreatedOnToolsVersion = 13.0; 310 | TestTargetID = 5248862B26F2A26200970899; 311 | }; 312 | }; 313 | }; 314 | buildConfigurationList = 5248862726F2A26200970899 /* Build configuration list for PBXProject "TCACoordinatorsExample" */; 315 | compatibilityVersion = "Xcode 13.0"; 316 | developmentRegion = en; 317 | hasScannedForEncodings = 0; 318 | knownRegions = ( 319 | en, 320 | Base, 321 | ); 322 | mainGroup = 5248862326F2A26200970899; 323 | productRefGroup = 5248862D26F2A26200970899 /* Products */; 324 | projectDirPath = ""; 325 | projectRoot = ""; 326 | targets = ( 327 | 5248862B26F2A26200970899 /* TCACoordinatorsExample */, 328 | 5248863B26F2A26500970899 /* TCACoordinatorsExampleTests */, 329 | 5248864526F2A26500970899 /* TCACoordinatorsExampleUITests */, 330 | ); 331 | }; 332 | /* End PBXProject section */ 333 | 334 | /* Begin PBXResourcesBuildPhase section */ 335 | 5248862A26F2A26200970899 /* Resources */ = { 336 | isa = PBXResourcesBuildPhase; 337 | buildActionMask = 2147483647; 338 | files = ( 339 | 5248863726F2A26400970899 /* Preview Assets.xcassets in Resources */, 340 | 5248863426F2A26400970899 /* Assets.xcassets in Resources */, 341 | ); 342 | runOnlyForDeploymentPostprocessing = 0; 343 | }; 344 | 5248863A26F2A26500970899 /* Resources */ = { 345 | isa = PBXResourcesBuildPhase; 346 | buildActionMask = 2147483647; 347 | files = ( 348 | ); 349 | runOnlyForDeploymentPostprocessing = 0; 350 | }; 351 | 5248864426F2A26500970899 /* Resources */ = { 352 | isa = PBXResourcesBuildPhase; 353 | buildActionMask = 2147483647; 354 | files = ( 355 | ); 356 | runOnlyForDeploymentPostprocessing = 0; 357 | }; 358 | /* End PBXResourcesBuildPhase section */ 359 | 360 | /* Begin PBXSourcesBuildPhase section */ 361 | 5248862826F2A26200970899 /* Sources */ = { 362 | isa = PBXSourcesBuildPhase; 363 | buildActionMask = 2147483647; 364 | files = ( 365 | 529822D9283D77060011112B /* LogInView.swift in Sources */, 366 | 529822D5283D76AD0011112B /* GameCoordinator.swift in Sources */, 367 | 524087E4278E3D950048C6EE /* IndexedCoordinator.swift in Sources */, 368 | 529822D4283D76AD0011112B /* LogInCoordinator.swift in Sources */, 369 | 5248863026F2A26200970899 /* TCACoordinatorsExampleApp.swift in Sources */, 370 | 528FEDF82880BD95007765AD /* Step2.swift in Sources */, 371 | 528FEDF42880BD95007765AD /* FinalScreen.swift in Sources */, 372 | 529822D7283D76F60011112B /* WelcomeView.swift in Sources */, 373 | 528FEDF52880BD95007765AD /* FormScreen.swift in Sources */, 374 | 912FC7252BEBAFAA0036B444 /* FormScreen+Identifiable.swift in Sources */, 375 | 912FC7232BEBAB7A0036B444 /* LogInScreen+StateIdentifiable.swift in Sources */, 376 | 524087E5278E3D950048C6EE /* Screen.swift in Sources */, 377 | 528FEDF22880BD95007765AD /* Step3.swift in Sources */, 378 | 529822D3283D76AD0011112B /* AppCoordinator.swift in Sources */, 379 | 524087E6278E3D950048C6EE /* IdentifiedCoordinator.swift in Sources */, 380 | 528AE0EB2BED197000E143C5 /* OutcomeView.swift in Sources */, 381 | 528FEDF62880BD95007765AD /* FormAppCoordinator.swift in Sources */, 382 | 529822D2283D76AD0011112B /* GameView.swift in Sources */, 383 | 528FEDF32880BD95007765AD /* Step1.swift in Sources */, 384 | 912FC7272BEBB9080036B444 /* GameViewState.swift in Sources */, 385 | ); 386 | runOnlyForDeploymentPostprocessing = 0; 387 | }; 388 | 5248863826F2A26500970899 /* Sources */ = { 389 | isa = PBXSourcesBuildPhase; 390 | buildActionMask = 2147483647; 391 | files = ( 392 | 524087E8278E3D9F0048C6EE /* IndexedCoordinator.swift in Sources */, 393 | 528FEDEE2880BD94007765AD /* FormScreen.swift in Sources */, 394 | 5248864126F2A26500970899 /* TCACoordinatorsExampleTests.swift in Sources */, 395 | 528FEDED2880BD94007765AD /* FinalScreen.swift in Sources */, 396 | 524087E7278E3D9F0048C6EE /* IdentifiedCoordinator.swift in Sources */, 397 | 528AE0E92BECE20F00E143C5 /* FormScreen+Identifiable.swift in Sources */, 398 | 528FEDF12880BD94007765AD /* Step2.swift in Sources */, 399 | 524087E9278E3D9F0048C6EE /* Screen.swift in Sources */, 400 | 528FEDEF2880BD94007765AD /* FormAppCoordinator.swift in Sources */, 401 | 528FEDEB2880BD94007765AD /* Step3.swift in Sources */, 402 | 528FEDEC2880BD94007765AD /* Step1.swift in Sources */, 403 | ); 404 | runOnlyForDeploymentPostprocessing = 0; 405 | }; 406 | 5248864226F2A26500970899 /* Sources */ = { 407 | isa = PBXSourcesBuildPhase; 408 | buildActionMask = 2147483647; 409 | files = ( 410 | 5248864B26F2A26500970899 /* TCACoordinatorsExampleUITests.swift in Sources */, 411 | 5248864D26F2A26500970899 /* TCACoordinatorsExampleUITestsLaunchTests.swift in Sources */, 412 | ); 413 | runOnlyForDeploymentPostprocessing = 0; 414 | }; 415 | /* End PBXSourcesBuildPhase section */ 416 | 417 | /* Begin PBXTargetDependency section */ 418 | 5248863E26F2A26500970899 /* PBXTargetDependency */ = { 419 | isa = PBXTargetDependency; 420 | target = 5248862B26F2A26200970899 /* TCACoordinatorsExample */; 421 | targetProxy = 5248863D26F2A26500970899 /* PBXContainerItemProxy */; 422 | }; 423 | 5248864826F2A26500970899 /* PBXTargetDependency */ = { 424 | isa = PBXTargetDependency; 425 | target = 5248862B26F2A26200970899 /* TCACoordinatorsExample */; 426 | targetProxy = 5248864726F2A26500970899 /* PBXContainerItemProxy */; 427 | }; 428 | /* End PBXTargetDependency section */ 429 | 430 | /* Begin XCBuildConfiguration section */ 431 | 5248864E26F2A26500970899 /* Debug */ = { 432 | isa = XCBuildConfiguration; 433 | buildSettings = { 434 | ALWAYS_SEARCH_USER_PATHS = NO; 435 | CLANG_ANALYZER_NONNULL = YES; 436 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 437 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 438 | CLANG_CXX_LIBRARY = "libc++"; 439 | CLANG_ENABLE_MODULES = YES; 440 | CLANG_ENABLE_OBJC_ARC = YES; 441 | CLANG_ENABLE_OBJC_WEAK = YES; 442 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 443 | CLANG_WARN_BOOL_CONVERSION = YES; 444 | CLANG_WARN_COMMA = YES; 445 | CLANG_WARN_CONSTANT_CONVERSION = YES; 446 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 447 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 448 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 449 | CLANG_WARN_EMPTY_BODY = YES; 450 | CLANG_WARN_ENUM_CONVERSION = YES; 451 | CLANG_WARN_INFINITE_RECURSION = YES; 452 | CLANG_WARN_INT_CONVERSION = YES; 453 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 454 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 455 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 456 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 457 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 458 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 459 | CLANG_WARN_STRICT_PROTOTYPES = YES; 460 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 461 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 462 | CLANG_WARN_UNREACHABLE_CODE = YES; 463 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 464 | COPY_PHASE_STRIP = NO; 465 | DEBUG_INFORMATION_FORMAT = dwarf; 466 | ENABLE_STRICT_OBJC_MSGSEND = YES; 467 | ENABLE_TESTABILITY = YES; 468 | GCC_C_LANGUAGE_STANDARD = gnu11; 469 | GCC_DYNAMIC_NO_PIC = NO; 470 | GCC_NO_COMMON_BLOCKS = YES; 471 | GCC_OPTIMIZATION_LEVEL = 0; 472 | GCC_PREPROCESSOR_DEFINITIONS = ( 473 | "DEBUG=1", 474 | "$(inherited)", 475 | ); 476 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 477 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 478 | GCC_WARN_UNDECLARED_SELECTOR = YES; 479 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 480 | GCC_WARN_UNUSED_FUNCTION = YES; 481 | GCC_WARN_UNUSED_VARIABLE = YES; 482 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 483 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 484 | MTL_FAST_MATH = YES; 485 | ONLY_ACTIVE_ARCH = YES; 486 | SDKROOT = iphoneos; 487 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 488 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 489 | SWIFT_VERSION = 6.0; 490 | }; 491 | name = Debug; 492 | }; 493 | 5248864F26F2A26500970899 /* Release */ = { 494 | isa = XCBuildConfiguration; 495 | buildSettings = { 496 | ALWAYS_SEARCH_USER_PATHS = NO; 497 | CLANG_ANALYZER_NONNULL = YES; 498 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 499 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 500 | CLANG_CXX_LIBRARY = "libc++"; 501 | CLANG_ENABLE_MODULES = YES; 502 | CLANG_ENABLE_OBJC_ARC = YES; 503 | CLANG_ENABLE_OBJC_WEAK = YES; 504 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 505 | CLANG_WARN_BOOL_CONVERSION = YES; 506 | CLANG_WARN_COMMA = YES; 507 | CLANG_WARN_CONSTANT_CONVERSION = YES; 508 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 509 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 510 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 511 | CLANG_WARN_EMPTY_BODY = YES; 512 | CLANG_WARN_ENUM_CONVERSION = YES; 513 | CLANG_WARN_INFINITE_RECURSION = YES; 514 | CLANG_WARN_INT_CONVERSION = YES; 515 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 516 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 517 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 518 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 519 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 520 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 521 | CLANG_WARN_STRICT_PROTOTYPES = YES; 522 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 523 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 524 | CLANG_WARN_UNREACHABLE_CODE = YES; 525 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 526 | COPY_PHASE_STRIP = NO; 527 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 528 | ENABLE_NS_ASSERTIONS = NO; 529 | ENABLE_STRICT_OBJC_MSGSEND = YES; 530 | GCC_C_LANGUAGE_STANDARD = gnu11; 531 | GCC_NO_COMMON_BLOCKS = YES; 532 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 533 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 534 | GCC_WARN_UNDECLARED_SELECTOR = YES; 535 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 536 | GCC_WARN_UNUSED_FUNCTION = YES; 537 | GCC_WARN_UNUSED_VARIABLE = YES; 538 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 539 | MTL_ENABLE_DEBUG_INFO = NO; 540 | MTL_FAST_MATH = YES; 541 | SDKROOT = iphoneos; 542 | SWIFT_COMPILATION_MODE = wholemodule; 543 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 544 | SWIFT_VERSION = 6.0; 545 | VALIDATE_PRODUCT = YES; 546 | }; 547 | name = Release; 548 | }; 549 | 5248865126F2A26500970899 /* Debug */ = { 550 | isa = XCBuildConfiguration; 551 | buildSettings = { 552 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 553 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 554 | CODE_SIGN_STYLE = Automatic; 555 | CURRENT_PROJECT_VERSION = 1; 556 | DEVELOPMENT_ASSET_PATHS = "\"TCACoordinatorsExample/Preview Content\""; 557 | DEVELOPMENT_TEAM = QJAU94J65Z; 558 | ENABLE_PREVIEWS = YES; 559 | GENERATE_INFOPLIST_FILE = YES; 560 | INFOPLIST_FILE = TCACoordinatorsExample/Info.plist; 561 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 562 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 563 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 564 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 565 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 566 | LD_RUNPATH_SEARCH_PATHS = ( 567 | "$(inherited)", 568 | "@executable_path/Frameworks", 569 | ); 570 | MARKETING_VERSION = 1.0; 571 | PRODUCT_BUNDLE_IDENTIFIER = uk.johnpatrickmorgan.TCACoordinatorsExample; 572 | PRODUCT_NAME = "$(TARGET_NAME)"; 573 | SWIFT_EMIT_LOC_STRINGS = YES; 574 | TARGETED_DEVICE_FAMILY = "1,2"; 575 | }; 576 | name = Debug; 577 | }; 578 | 5248865226F2A26500970899 /* Release */ = { 579 | isa = XCBuildConfiguration; 580 | buildSettings = { 581 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 582 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 583 | CODE_SIGN_STYLE = Automatic; 584 | CURRENT_PROJECT_VERSION = 1; 585 | DEVELOPMENT_ASSET_PATHS = "\"TCACoordinatorsExample/Preview Content\""; 586 | DEVELOPMENT_TEAM = QJAU94J65Z; 587 | ENABLE_PREVIEWS = YES; 588 | GENERATE_INFOPLIST_FILE = YES; 589 | INFOPLIST_FILE = TCACoordinatorsExample/Info.plist; 590 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 591 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 592 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 593 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 594 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 595 | LD_RUNPATH_SEARCH_PATHS = ( 596 | "$(inherited)", 597 | "@executable_path/Frameworks", 598 | ); 599 | MARKETING_VERSION = 1.0; 600 | PRODUCT_BUNDLE_IDENTIFIER = uk.johnpatrickmorgan.TCACoordinatorsExample; 601 | PRODUCT_NAME = "$(TARGET_NAME)"; 602 | SWIFT_EMIT_LOC_STRINGS = YES; 603 | TARGETED_DEVICE_FAMILY = "1,2"; 604 | }; 605 | name = Release; 606 | }; 607 | 5248865426F2A26500970899 /* Debug */ = { 608 | isa = XCBuildConfiguration; 609 | buildSettings = { 610 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 611 | BUNDLE_LOADER = "$(TEST_HOST)"; 612 | CODE_SIGN_STYLE = Automatic; 613 | CURRENT_PROJECT_VERSION = 1; 614 | DEVELOPMENT_TEAM = QJAU94J65Z; 615 | GENERATE_INFOPLIST_FILE = YES; 616 | LD_RUNPATH_SEARCH_PATHS = ( 617 | "$(inherited)", 618 | "@executable_path/Frameworks", 619 | "@loader_path/Frameworks", 620 | ); 621 | MARKETING_VERSION = 1.0; 622 | PRODUCT_BUNDLE_IDENTIFIER = uk.johnpatrickmorgan.TCACoordinatorsExampleTests; 623 | PRODUCT_NAME = "$(TARGET_NAME)"; 624 | SWIFT_EMIT_LOC_STRINGS = NO; 625 | TARGETED_DEVICE_FAMILY = "1,2"; 626 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TCACoordinatorsExample.app/TCACoordinatorsExample"; 627 | }; 628 | name = Debug; 629 | }; 630 | 5248865526F2A26500970899 /* Release */ = { 631 | isa = XCBuildConfiguration; 632 | buildSettings = { 633 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 634 | BUNDLE_LOADER = "$(TEST_HOST)"; 635 | CODE_SIGN_STYLE = Automatic; 636 | CURRENT_PROJECT_VERSION = 1; 637 | DEVELOPMENT_TEAM = QJAU94J65Z; 638 | GENERATE_INFOPLIST_FILE = YES; 639 | LD_RUNPATH_SEARCH_PATHS = ( 640 | "$(inherited)", 641 | "@executable_path/Frameworks", 642 | "@loader_path/Frameworks", 643 | ); 644 | MARKETING_VERSION = 1.0; 645 | PRODUCT_BUNDLE_IDENTIFIER = uk.johnpatrickmorgan.TCACoordinatorsExampleTests; 646 | PRODUCT_NAME = "$(TARGET_NAME)"; 647 | SWIFT_EMIT_LOC_STRINGS = NO; 648 | TARGETED_DEVICE_FAMILY = "1,2"; 649 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TCACoordinatorsExample.app/TCACoordinatorsExample"; 650 | }; 651 | name = Release; 652 | }; 653 | 5248865726F2A26500970899 /* Debug */ = { 654 | isa = XCBuildConfiguration; 655 | buildSettings = { 656 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 657 | CODE_SIGN_STYLE = Automatic; 658 | CURRENT_PROJECT_VERSION = 1; 659 | DEVELOPMENT_TEAM = QJAU94J65Z; 660 | GENERATE_INFOPLIST_FILE = YES; 661 | LD_RUNPATH_SEARCH_PATHS = ( 662 | "$(inherited)", 663 | "@executable_path/Frameworks", 664 | "@loader_path/Frameworks", 665 | ); 666 | MARKETING_VERSION = 1.0; 667 | PRODUCT_BUNDLE_IDENTIFIER = uk.johnpatrickmorgan.TCACoordinatorsExampleUITests; 668 | PRODUCT_NAME = "$(TARGET_NAME)"; 669 | SWIFT_EMIT_LOC_STRINGS = NO; 670 | TARGETED_DEVICE_FAMILY = "1,2"; 671 | TEST_TARGET_NAME = TCACoordinatorsExample; 672 | }; 673 | name = Debug; 674 | }; 675 | 5248865826F2A26500970899 /* Release */ = { 676 | isa = XCBuildConfiguration; 677 | buildSettings = { 678 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 679 | CODE_SIGN_STYLE = Automatic; 680 | CURRENT_PROJECT_VERSION = 1; 681 | DEVELOPMENT_TEAM = QJAU94J65Z; 682 | GENERATE_INFOPLIST_FILE = YES; 683 | LD_RUNPATH_SEARCH_PATHS = ( 684 | "$(inherited)", 685 | "@executable_path/Frameworks", 686 | "@loader_path/Frameworks", 687 | ); 688 | MARKETING_VERSION = 1.0; 689 | PRODUCT_BUNDLE_IDENTIFIER = uk.johnpatrickmorgan.TCACoordinatorsExampleUITests; 690 | PRODUCT_NAME = "$(TARGET_NAME)"; 691 | SWIFT_EMIT_LOC_STRINGS = NO; 692 | TARGETED_DEVICE_FAMILY = "1,2"; 693 | TEST_TARGET_NAME = TCACoordinatorsExample; 694 | }; 695 | name = Release; 696 | }; 697 | /* End XCBuildConfiguration section */ 698 | 699 | /* Begin XCConfigurationList section */ 700 | 5248862726F2A26200970899 /* Build configuration list for PBXProject "TCACoordinatorsExample" */ = { 701 | isa = XCConfigurationList; 702 | buildConfigurations = ( 703 | 5248864E26F2A26500970899 /* Debug */, 704 | 5248864F26F2A26500970899 /* Release */, 705 | ); 706 | defaultConfigurationIsVisible = 0; 707 | defaultConfigurationName = Release; 708 | }; 709 | 5248865026F2A26500970899 /* Build configuration list for PBXNativeTarget "TCACoordinatorsExample" */ = { 710 | isa = XCConfigurationList; 711 | buildConfigurations = ( 712 | 5248865126F2A26500970899 /* Debug */, 713 | 5248865226F2A26500970899 /* Release */, 714 | ); 715 | defaultConfigurationIsVisible = 0; 716 | defaultConfigurationName = Release; 717 | }; 718 | 5248865326F2A26500970899 /* Build configuration list for PBXNativeTarget "TCACoordinatorsExampleTests" */ = { 719 | isa = XCConfigurationList; 720 | buildConfigurations = ( 721 | 5248865426F2A26500970899 /* Debug */, 722 | 5248865526F2A26500970899 /* Release */, 723 | ); 724 | defaultConfigurationIsVisible = 0; 725 | defaultConfigurationName = Release; 726 | }; 727 | 5248865626F2A26500970899 /* Build configuration list for PBXNativeTarget "TCACoordinatorsExampleUITests" */ = { 728 | isa = XCConfigurationList; 729 | buildConfigurations = ( 730 | 5248865726F2A26500970899 /* Debug */, 731 | 5248865826F2A26500970899 /* Release */, 732 | ); 733 | defaultConfigurationIsVisible = 0; 734 | defaultConfigurationName = Release; 735 | }; 736 | /* End XCConfigurationList section */ 737 | 738 | /* Begin XCSwiftPackageProductDependency section */ 739 | 5248865F26F2A53B00970899 /* TCACoordinators */ = { 740 | isa = XCSwiftPackageProductDependency; 741 | productName = TCACoordinators; 742 | }; 743 | /* End XCSwiftPackageProductDependency section */ 744 | }; 745 | rootObject = 5248862426F2A26200970899 /* Project object */; 746 | } 747 | --------------------------------------------------------------------------------