Using NavigationSplitView with The Composable Architecture

695 views Asked by At

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?

1

There are 1 answers

5
CouchDeveloper On BEST ANSWER

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()
// }