Skip to content

Commit

Permalink
Merge pull request #65 from rhysm94/observation
Browse files Browse the repository at this point in the history
Observation
  • Loading branch information
johnpatrickmorgan authored May 10, 2024
2 parents 26ff32a + 389c437 commit 17a4ab7
Show file tree
Hide file tree
Showing 34 changed files with 635 additions and 522 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/johnpatrickmorgan/FlowStacks", from: "0.3.6"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.5.0"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.8.0"),
],
targets: [
.target(
Expand Down
76 changes: 22 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

_The coordinator pattern in the Composable Architecture_

`TCACoordinators` brings a flexible approach to navigation in SwiftUI using the [Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture). It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA such as `.forEach`, `ifCaseLet` and `SwitchStore` with [a novel approach to handling navigation in SwiftUI](https://github.com/johnpatrickmorgan/FlowStacks).
`TCACoordinators` brings a flexible approach to navigation in SwiftUI using the [Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture). It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA with [a novel approach to handling navigation in SwiftUI](https://github.com/johnpatrickmorgan/FlowStacks).

You might like this library if you want to:

Expand All @@ -25,33 +25,14 @@ The library works by translating the array of screens into a hierarchy of nested

### Step 1 - Create a screen reducer

First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Both the state and action types will be the sum of the individual screens' state and action types, and the reducer will combine each individual screens' reducers into one:
First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Thanks to the `@Reducer macro`, this can be easily achieved with an enum reducer, e.g. the following (where `Home`, `NumbersList` and `NumberDetail` are the individual screen reducers):

```swift
@Reducer
struct Screen {
enum State: Equatable {
case home(Home.State)
case numbersList(NumbersList.State)
case numberDetail(NumberDetail.State)
}
enum Action {
case home(Home.Action)
case numbersList(NumbersList.Action)
case numberDetail(NumberDetail.Action)
}

var body: some ReducerOf<Self> {
Scope(state: /State.home, action: /Action.home) {
Home()
}
Scope(state: /State.numbersList, action: /Action.numbersList) {
NumbersList()
}
Scope(state: /State.numberDetail, action: /Action.numberDetail) {
NumberDetail()
}
}
@Reducer(state: .equatable)
enum Screen {
case home(Home)
case numbersList(NumbersList)
case numberDetail(NumberDetail)
}
```

Expand All @@ -62,6 +43,7 @@ The coordinator will manage multiple screens in a navigation flow. Its state sho
```swift
@Reducer
struct Coordinator {
@ObservableState
struct State: Equatable {
var routes: [Route<Screen.State>]
}
Expand Down Expand Up @@ -108,46 +90,31 @@ struct Coordinator {
break
}
return .none
}.forEachRoute(\.routes, action: \.router) {
Screen()
}
.forEachRoute(\.routes, action: \.router)
}
}
```

### Step 3 - Create a coordinator view

With that in place, a `CoordinatorView` can be created. It will use a `TCARouter`, which translates the array of routes into a nested list of screen views with invisible `NavigationLinks` and presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, the `TCARouter` takes a closure that can create the view for any screen in the navigation flow. A `SwitchStore` is the natural way to achieve that, with a `CaseLet` for each of the possible screens:
With that in place, a `CoordinatorView` can be created. It will use a `TCARouter`, which translates the array of routes into a nested list of screen views with invisible `NavigationLinks` and presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, the `TCARouter` takes a closure that can create the view for any screen in the navigation flow. A switch statement is the natural way to achieve that, with a case for each of the possible screens:

```swift
struct CoordinatorView: View {
let store: StoreOf<Coordinator>

var body: some View {
TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
SwitchStore(screen) { screen in
switch screen {
case .home:
CaseLet(
/Screen.State.home,
action: Screen.Action.home,
then: HomeView.init
)

case .numbersList:
CaseLet(
/Screen.State.numbersList,
action: Screen.Action.numbersList,
then: NumbersListView.init
)

case .numberDetail:
CaseLet(
/Screen.State.numberDetail,
action: Screen.Action.numberDetail,
then: NumberDetailView.init
)
}
switch screen.case {
case let .home(store):
HomeView(store: store)

case let .numbersList(store):
NumbersListView(store: store)

case let .numberDetail(store):
NumberDetailView(store: store)
}
}
}
Expand Down Expand Up @@ -180,7 +147,7 @@ If the user taps the back button, the routes array will be automatically updated

## Cancellation of in-flight effects on dismiss

By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. This would normally require a lot of boilerplate, but can be entirely handled by this library without additional work. To opt out of automatic cancellation, pass `cancellationId: nil` to `forEachRoute`.
By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. To opt out of automatic cancellation, pass `cancellationId: nil` to `forEachRoute`.


## Making complex navigation updates
Expand Down Expand Up @@ -219,7 +186,8 @@ If the flow of screens needs to change, the change can be made easily in one pla

## How does it work?

This library uses [FlowStacks](https://github.com/johnpatrickmorgan/FlowStacks) for hoisting navigation state out of individual screens. This [blog post](https://johnpatrickmorgan.github.io/2021/07/03/NStack/) explains how that is achieved. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture.
This library uses [FlowStacks](https://github.com/johnpatrickmorgan/FlowStacks) for hoisting navigation state out of individual screens. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture.


## Migrating from v0.8 and lower

Expand Down
6 changes: 6 additions & 0 deletions Sources/TCACoordinators/Collection+safeSubscript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
extension Collection {
/// Returns the element at the specified index if it is within bounds, otherwise nil.
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
57 changes: 56 additions & 1 deletion Sources/TCACoordinators/Reducers/ForEachIdentifiedRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,36 @@ public extension Reducer {
)
}

/// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in
/// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
/// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
/// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which they
/// originated is dismissed.
/// - Parameters:
/// - routes: A writable keypath for the routes `IdentifiedArray`.
/// - action: A casepath for the router action from this reducer's Action type.
/// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It
/// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation.
/// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
func forEachRoute<ScreenState, ScreenAction>(
_ routes: WritableKeyPath<Self.State, IdentifiedArrayOf<Route<ScreenState>>>,
action: CaseKeyPath<Self.Action, IdentifiedRouterAction<ScreenState, ScreenAction>>,
cancellationId: (some Hashable)?
) -> some ReducerOf<Self>
where Action: CasePathable,
ScreenState: CaseReducerState,
ScreenState.StateReducer.Action == ScreenAction,
ScreenAction: CasePathable
{
self.forEachRoute(
routes,
action: action,
cancellationId: cancellationId
) {
ScreenState.StateReducer.body
}
}

/// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
/// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
/// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects
/// will be cancelled when the screen from which they originated is dismissed.
Expand Down Expand Up @@ -99,6 +128,32 @@ public extension Reducer {
toLocalAction: action
)
}

/// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
/// the coordinator's routes IdentifiedArray will have its actions and state propagated. When screens are
/// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which they
/// originated is dismissed.
/// - Parameters:
/// - routes: A writable keypath for the routes `IdentifiedArray`.
/// - action: A casepath for the router action from this reducer's Action type.
/// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It
/// will be combined with the screen's identifier. Defaults to the type of the parent reducer.
/// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
func forEachRoute<ScreenState, ScreenAction>(
_ routes: WritableKeyPath<State, IdentifiedArrayOf<Route<ScreenState>>>,
action: CaseKeyPath<Action, IdentifiedRouterAction<ScreenState, ScreenAction>>,
cancellationIdType: Any.Type = Self.self
) -> some ReducerOf<Self>
where Action: CasePathable,
ScreenState: CaseReducerState,
ScreenState: Identifiable,
ScreenState.StateReducer.Action == ScreenAction,
ScreenAction: CasePathable
{
self.forEachRoute(routes, action: action, cancellationIdType: cancellationIdType) {
ScreenState.StateReducer.body
}
}
}

extension Case {
Expand Down
54 changes: 54 additions & 0 deletions Sources/TCACoordinators/Reducers/ForEachIndexedRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ public extension Reducer {
)
}

/// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
/// the coordinator's routes array will have its actions and state propagated. When screens are
/// dismissed, the routes will be updated. If a cancellation identifier is passed, in-flight effects
/// will be cancelled when the screen from which they originated is dismissed.
/// - Parameters:
/// - routes: A writable keypath for the routes array.
/// - action: A casepath for the router action from this reducer's Action type.
/// - cancellationId: An identifier to use for cancelling in-flight effects when a view is dismissed. It
/// will be combined with the screen's identifier. If `nil`, there will be no automatic cancellation.
/// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
func forEachRoute<ScreenState, ScreenAction>(
_ routes: WritableKeyPath<Self.State, [Route<ScreenState>]>,
action: CaseKeyPath<Self.Action, IndexedRouterAction<ScreenState, ScreenAction>>,
cancellationId: (some Hashable)?
) -> some ReducerOf<Self>
where Action: CasePathable,
ScreenState: CaseReducerState,
ScreenState.StateReducer.Action == ScreenAction,
ScreenAction: CasePathable
{
self.forEachRoute(
routes,
action: action,
cancellationId: cancellationId
) {
ScreenState.StateReducer.body
}
}

/// Allows a screen reducer to be incorporated into a coordinator reducer, such that each screen in
/// the coordinator's routes Array will have its actions and state propagated. When screens are
/// dismissed, the routes will be updated. In-flight effects
Expand Down Expand Up @@ -96,4 +125,29 @@ public extension Reducer {
toLocalAction: action
)
}

/// Allows a screen case reducer to be incorporated into a coordinator reducer, such that each screen in
/// the coordinator's routes Array will have its actions and state propagated. When screens are
/// dismissed, the routes will be updated. In-flight effects will be cancelled when the screen from which
/// they originated is dismissed.
/// - Parameters:
/// - routes: A writable keypath for the routes array.
/// - action: A casepath for the router action from this reducer's Action type.
/// - cancellationIdType: A type to use for cancelling in-flight effects when a view is dismissed. It
/// will be combined with the screen's identifier. Defaults to the type of the parent reducer.
/// - Returns: A new reducer combining the coordinator-level and screen-level reducers.
func forEachRoute<ScreenState, ScreenAction>(
_ routes: WritableKeyPath<State, [Route<ScreenState>]>,
action: CaseKeyPath<Action, IndexedRouterAction<ScreenState, ScreenAction>>,
cancellationIdType: Any.Type = Self.self
) -> some ReducerOf<Self>
where Action: CasePathable,
ScreenState: CaseReducerState,
ScreenState.StateReducer.Action == ScreenAction,
ScreenAction: CasePathable
{
self.forEachRoute(routes, action: action, cancellationIdType: cancellationIdType) {
ScreenState.StateReducer.body
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public extension TCARouter where Screen: Identifiable {
/// Convenience initializer for managing screens in an `IdentifiedArray`.
init(
_ store: Store<IdentifiedArrayOf<Route<Screen>>, IdentifiedRouterAction<Screen, ScreenAction>>,
screenContent: @escaping (Store<Screen, ScreenAction>) -> ScreenContent
@ViewBuilder screenContent: @escaping (Store<Screen, ScreenAction>) -> ScreenContent
) where Screen.ID == ID {
self.init(
store: store.scope(state: \.elements, action: \.self),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public extension TCARouter where ID == Int {
/// Convenience initializer for managing screens in an `Array`, identified by index.
init(
_ store: Store<[Route<Screen>], IndexedRouterAction<Screen, ScreenAction>>,
screenContent: @escaping (Store<Screen, ScreenAction>) -> ScreenContent
@ViewBuilder screenContent: @escaping (Store<Screen, ScreenAction>) -> ScreenContent
) {
self.init(
store: store,
Expand Down
42 changes: 24 additions & 18 deletions Sources/TCACoordinators/TCARouter/TCARouter.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
@_spi(Internals) import ComposableArchitecture
import FlowStacks
import Foundation
import SwiftUI

/// TCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented. The TCARouter translates that collection into a hierarchy of SwiftUI views, and ensures that `updateScreens`.
/// TCARouter manages a collection of Routes, i.e., a series of screens, each of which is either pushed or presented.
/// The TCARouter translates that collection into a hierarchy of SwiftUI views, and updates it when the user navigates back.
public struct TCARouter<
Screen: Equatable,
ScreenAction,
ID: Hashable,
ScreenContent: View
>: View {
let store: Store<[Route<Screen>], RouterAction<ID, Screen, ScreenAction>>
@Perception.Bindable private var store: Store<[Route<Screen>], RouterAction<ID, Screen, ScreenAction>>
let identifier: (Screen, Int) -> ID
let screenContent: (Store<Screen, ScreenAction>) -> ScreenContent

Expand Down Expand Up @@ -41,24 +41,30 @@ public struct TCARouter<
}

public var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Router(
viewStore
.binding(
get: { $0 },
send: RouterAction.updateRoutes
),
buildView: { screen, index in
screenContent(scopedStore(index: index, screen: screen))
}
)
if Screen.self is ObservableState.Type {
WithPerceptionTracking {
Router(
$store[],
buildView: { screen, index in
WithPerceptionTracking {
screenContent(scopedStore(index: index, screen: screen))
}
}
)
}
} else {
UnobservedTCARouter(store: store, identifier: identifier, screenContent: screenContent)
}
}
}

extension Collection {
/// Returns the element at the specified index if it is within bounds, otherwise nil.
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
private extension Store {
subscript<ID: Hashable, Screen, ScreenAction>() -> [Route<Screen>]
where State == [Route<Screen>], Action == RouterAction<ID, Screen, ScreenAction>
{
get { currentState }
set {
send(.updateRoutes(newValue))
}
}
}
Loading

0 comments on commit 17a4ab7

Please sign in to comment.