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