swiftswiftuiswift-composable-architecture

Using NavigationSplitView with The Composable Architecture


I'm trying to learn navigation using TCA, and want to create a macOS app with a sidebar. This is what I want to achieve:

enter image description here

Except with the text replaced with ProjectView() with the corresponding Blob Jr project.

NavigationView is deprecated and Apple recommends using NavigationSplitView for this it looks like.

Here's the code I have so far:

struct ProjectsView: View {
  let store: StoreOf<ProjectsFeature>
  
  var body: some View {
    NavigationStackStore(self.store.scope(state: \.path, action: { .path($0) })) {
      WithViewStore(self.store, observe: \.projects) { viewStore in
        NavigationSplitView {
          List {
            ForEach(viewStore.state) { project in
              NavigationLink(state: ProjectFeature.State(project: project)) {
                Text(project.name)
              }
            }
          }
        } detail: {
          Text("How do I get ProjectView() with Blob Jr to show here?")
        }
      }
    } destination: { store in
      ProjectView(store: store)
    }
  }
}

ProjectFeature is just like this: (I wan't to be able to mutate the project from this view in the future.)

struct ProjectFeature: Reducer {
  struct State: Equatable {
    var project: Project
  }
  
  enum Action {
    case didUpdateNameTextField
  }
  
  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch(action) {
    case .didUpdateNameTextField:
      return .none
    }
  }
}

struct ProjectView: View {
  let store: StoreOf<ProjectFeature>
  
  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        Text("Project").font(.largeTitle)
        Text(viewStore.state.project.name)
      }
    }
  }
}

If I remove the NavigationSplitView, the navigation works, but the display is incorrect.

How can I use this NavigationSplitView with TCA?


Solution

  • I came up with a solution. It seems though, TCA is not (yet) directly supporting a NavigationSplitView, with having a custom view like NavigationSplitViewStore. Thus some "manual" coding was necessary. I'm no expert with TCA, though - so bear with me if I have missed something in TCA. ;)

    TCA denotes this kind of navigation as "Tree based navigation". It's recommended to read the documentation, which is excellent by the way.

    First, as already mentioned, we need a way to keep the selection. For this type of navigation TCA provides a property wrapper @PresentationState:

        struct Master: Reducer {
    
            struct State: Equatable {
                let items: [Item]
                @PresentationState var detail: Detail.State?  
            }
    
            ...
    
    

    Note that Master and Detail are reducers.

    Note also, we have an array of "items" in the Master State whose titles will be drawn in the sidebar.

        struct Item: Identifiable, Equatable {
            var id: String { title }
            var title: String
            var detail: Int
        }
    

    This struct is for demoing purpose only. Its "detail" property represents some "detail". Its type is arbitrary for the sake of the demo.

    In a SwiftUI NavigationSplitView setup, Master View and Detail View communicate through a @State selection variable defined in the Master View. TCA would probably define a Custom NavigationSplitView in order to hide the details and use a Store for this.

    Now, in order to let a Store communicate with a selection, we need to add the code for the selection state and call an appropriate send(action:) when the selection has been changed.

    The below snippet shows a working example. Please keep mind, that this is a starting point, and could probably improved. It's also not very "TCA" like (it lacks ergonomics), but I'm pretty sure this can be achieved with some custom views.

    import ComposableArchitecture
    
    enum MyFeature {}
    
    extension MyFeature {
        
        struct Item: Identifiable, Equatable {
            var id: String { title }
            var title: String
            var detail: Int  // count
        }
    
        struct Master: Reducer {
    
            struct State: Equatable {
                let items: [Item]
                @PresentationState var detail: Detail.State?  // The "Detail" for a NavigationSplitView.
            }
    
            enum Action {
                case didSelectItem(Item.ID?)
                case detail(PresentationAction<Detail.Action>)
            }
            
            var body: some ReducerOf<Self> {
                Reduce<State, Action> { state, action in
                    print("Master: action \(action) @state: \(state)")
    
                    switch action {
                    // Core logic for master feature:
                    case .didSelectItem(let id):
                        if let id = id, let count = state.items.first(where: { $0.id == id })?.detail {
                            state.detail = Detail.State(count: count)
                        } else {
                            state.detail = nil
                        }
                        
                        return .none
                                            
                        // Intercept "Detail/dismiss" intent (this happens _before_ the "Detail" handles it!
                    case .detail(.dismiss):
                        // can't happen, since this is a split view where the "Detail View" cannot be dismissed.
                        return .none
    
                    // Optionally handle "Detail" actions _after_ they have been handled by the "Detail" reducer:
                    case .detail(.presented(.decrementIntent)):
                        return .none
                    case .detail(.presented(.incrementIntent)):
                        return .none
                        
                        
                    default:
                        return .none
                    }
                }
                // embed the "Detail" reducer:
                .ifLet(\.$detail, action: /Action.detail) {
                    Detail() // this is the reducer to combine with the "Master" reducer (iff not nil).
                }
            }
        }
        
        // This is the Reducer for the "Detail View" of the NavigationSplitView:
        struct Detail: Reducer {
            
            struct State: Equatable {
                var count: Int
            }
            
            enum Action {
                case incrementIntent
                case decrementIntent
            }
            
            func reduce(into state: inout State, action: Action) -> Effect<Action> {
                switch (state, action) {
                case (_, .decrementIntent):
                    state.count -= 1
                    return .none
                case (_, .incrementIntent):
                    state.count += 1
                    return .none
                }
            }
        }
        
    }
    
    import SwiftUI
    
    extension MyFeature {
        
        struct MasterView: View {
            let store: StoreOf<Master>
                    
            @State private var selection: Item.ID?  // initially no selection
    
    
            var body: some View {
                WithViewStore(self.store, observe: { $0 }) { viewStore in
                    // A NavigationSplitView has two or three colums: a "Sidebar" view, an optional "Content" view and a "Detail" view.
                    NavigationSplitView {
                        // Sidebar view
                        List(viewStore.items, selection: $selection) { item in
                            Text(item.title)
                        }
                    } detail: {
                        // Since the selection and thus the "Detail" can be nil, the
                        // store can be nil as well. So, we need a `IfLetStore` view:
                        
                        IfLetStore(
                            store.scope(
                                state: \.$detail,
                                action: Master.Action.detail
                            )
                        ) {
                            DetailView(store: $0)
                        } else: {
                            // render a "no data available" view:
                            Text("Empty. Please select an item in the sidebar.")
                        }
                    }
                    .onChange(of: selection, perform: { selection in
                        self.store.send(.didSelectItem(selection))
                    })
                }
            }
        }
        
        
        struct DetailView: View {
            let store: StoreOf<Detail>
            
            var body: some View {
                WithViewStore(self.store, observe: { $0 }) { viewStore in
                    VStack {
                        Text("Count: \(viewStore.count)")
                            .padding()
                        
                        Button("+", action: { store.send(.incrementIntent) })
                            .padding()
                        
                        Button("-", action: { store.send(.decrementIntent) })
                            .padding()
                    }
                }
            }
            
        }
        
    
    }
    
    // Xcode Beta
    // #Preview {
    //     ContentView()
    // }