Featured image of post The Composable Architecture - Part 2

The Composable Architecture - Part 2

Learn about the two main forms of state-driven navigation, tree-based and stack-based navigation, as well as their tradeoffs.

Introduction

  • State-driven navigation broadly falls into 2 main categories: tree-based, where you use optionals and enums to model navigation, and stack-based, where you use flat collections to model navigation. In this section, we will focus on stack-based navigation and their pros and cons.

Tree-based navigation pros

  • Tree-based navigation is a very concise way of modeling navigation. You get to statically describe all of the various navigation paths that are valid for your application, and that makes it impossible to restore a navigation that is invalid for your application. For example, if it only makes sense to navigate to an “edit” screen after a “detail” screen, then your detail feature needs only to hold onto a piece of optional edit state:
1
2
3
4
5
@ObservableState
struct State {
  @Presents var editItem: EditItemFeature.State?
  // ...
}
  • Tree-based navigation unifies all forms of navigation into a single, concise style of API, including drill-downs, sheets, popovers, covers, alerts, dialogs and a lot more. See API Unification for more information.

  • More pros here

Tree-based navigation cons

  • For example, in a movie application you can navigate to a movie, then a list of actors in the movies, then to a particular actor, and then to the same movie you started at. This creates a recursive dependency between features that can be difficult to model in Swift data types.

  • More cons here

Stack-based navigation pros

  • Stack-based navigation can easily handle complex and recursive navigation paths.
  • Each feature held in the stack can typically be fully decoupled from all other screens on the stack. This means the features can be put into their own modules with no dependencies on each other, and can be compiled without compiling any other features.
  • The NavigationStack API in SwiftUI typically has fewer bugs than NavigationLink(isActive:) and navigationDestination(isPresented:), which are used in tree-based navigation. There are still a few bugs in NavigationStack, but on average it is a lot more stable.

Stack-based navigation cons

  • Stack-based navigation is not a concise tool. It makes it possible to express navigation paths that are completely non-sensical. For example, even though it only makes sense to navigate to an edit screen from a detail screen, in a stack it would be possible to present the features in the reverse order:
1
2
3
4
let path: [Path] = [
  .edit(/* ... */),
  .detail(/* ... */)
]
  • And finally, stack-based navigation and NavigationStack only applies to drill-downs and does not address at all other forms of navigation, such as sheets, popovers, alerts, etc. It’s still on you to do the work to decouple those kinds of navigations.

Implementing Tab View in TCA

Preqrequisites

  • The Composable Architecture 1.7.2.
  • iOS 17.0+

Create a new project

  • Open Xcode and create a new project.
  • Add The Composable Architecture package to the project. Go to File > Swift Packages > Add Package Dependency... and enter the following URL: https://github.com/pointfreeco/swift-composable-architecture.

Create the First Tab View

  • Create a new SwiftUI View file named FirstTab.swift and add the following code:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Reducer
struct FirstTabReducer {
  @ObservableState
  struct State: Equatable {
  }
  
  enum Action {
  }
  
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      default:
        return .none
      }
    }
  }
}

struct FirstTab: View {
  @Bindable var store: StoreOf<FirstTabReducer>
  
  var body: some View {
    Text("First Tab")
  }
}

#Preview {
  FirstTab(
    store: Store(initialState: FirstTabReducer.State()) {
      FirstTabReducer()
    }
  )
}
  • Create similar files for the second tab named SecondTab.swift and SecondTabReducer.swift.

Create RootReducer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import ComposableArchitecture

@Reducer
struct RootReducer {
  @ObservableState
  struct State: Equatable {
    var firstTab = FirstTabReducer.State()
    var secondTab = SecondTabReducer.State()
    
    var currentTab: Tab = .firstTab
  }
  
  enum Action {
    case firstTabSelected(FirstTabReducer.Action)
    case secondTabSelected(SecondTabReducer.Action)
    
    case tabChanged(Tab)
  }
  
  enum Tab: Int, CaseIterable, Identifiable {
    case firstTab
    case secondTab
    
    var id: Int { self.rawValue }
    
    var title: String {
      switch self {
      case .firstTab:
        return "Tree-based"
      case .secondTab:
        return "Stack-based"
      }
    }
    
    var systemImage: String {
      switch self {
      case .firstTab:
        return "tree.fill"
      case .secondTab:
        return "square.stack.3d.up.fill"
      }
    }
  }
  
  var body: some ReducerOf<Self> {
    Scope(state: \.firstTab, action: \.firstTabSelected) {
      FirstTabReducer()
    }
    Scope(state: \.secondTab, action: \.secondTabSelected) {
      SecondTabReducer()
    }
    Reduce { state, action in
      switch action {
      case .firstTabSelected:
        return .none
      case .secondTabSelected:
        return .none
      case .tabChanged(let selectedTab):
        state.currentTab = selectedTab
        return .none
      }
    }
  }
}

Create RootView

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct RootView: View {
  @Bindable var store: StoreOf<RootReducer>
  
  var body: some View {
    TabView(selection: $store.currentTab.sending(\.tabChanged)) {
      NavigationStack {
        FirstTab(
          store: store.scope(
            state: \.firstTab,
            action: \.firstTabSelected
          )
        )
      }
      .tag(RootReducer.Tab.firstTab)
      .tabItem {
        Label(
          RootReducer.Tab.firstTab.title,
          systemImage: RootReducer.Tab.firstTab.systemImage
        )
      }
      
      SecondTab(
        store: store.scope(
          state: \.secondTab,
          action: \.secondTabSelected
        )
      )
      .tag(RootReducer.Tab.secondTab)
      .tabItem {
        Label(
          RootReducer.Tab.secondTab.title,
          systemImage: RootReducer.Tab.secondTab.systemImage
        )
      }
    }
  }
}

#Preview {
  RootView(
    store: Store(initialState: RootReducer.State()) {
      RootReducer()
    }
  )
}

Implementing Tree-based navigation in TCA

Implementing Stack-based navigation in TCA

Conclusion

  • In this section, we learned about the two main forms of state-driven navigation, tree-based and stack-based navigation, as well as their tradeoffs. We also learned how to implement both of these styles of navigation in the Composable Architecture.

  • You can find the source code for this section here

References

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy