0

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?

eivindml
  • 2,197
  • 7
  • 36
  • 68
  • Not sure if this is a TCA specific problem, and I'm not that into TCA. But, it's clear that you need some value representing the selection. You might have this in your Project**s**Feature.State. This also makes totally sense, since it's the "data" which tells your view what to render, including the currently selected item. So, the selection will likely be used as a Binding within the view, or uses actions to forward the selection change to the store. When the store has the current item, it should be easy to create your Detail view with that item. – CouchDeveloper Aug 29 '23 at 10:24
  • Thank you. But TCA should have feature's that automatically creates this value, so that you don't have to manage the selection state your self. – eivindml Aug 30 '23 at 08:35
  • TCA does not do this automatic. It has its own artefacts, like property wrappers, custom views, etc., which support state driven navigation and which you need to use properly in concert. TCA has excellent documentation. Unfortunately, your code is not complete. For example, in TCA, a detail view state will be modelled with a `@PresentationState` in the _master_ reducer. You may want your code to follow more closely the documentation. – CouchDeveloper Aug 30 '23 at 11:12
  • One question: what's the purpose of `NavigationStackStore`? Given a model which is basically a list of master/detail items, just using a `WithViewStore` to get the ViewStore should be enough. Also, `destination`, should possibly be `navigationDestination(store:)` . And, as mentioned, the master state should have a `@PresentationState` which declares the detail relation. It would be great if you could get so far to show a working example. :) – CouchDeveloper Aug 30 '23 at 11:55

1 Answers1

0

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



CouchDeveloper
  • 18,174
  • 3
  • 45
  • 67