Is there a way to use NavigationSplitView with the new Navigation APIs? #2167
Replies: 6 comments 3 replies
-
Hi @StefanCosminR, sorry I'm not very familiar with |
Beta Was this translation helpful? Give feedback.
-
I've recently been working on this myself for a project, and here's what I've come up with so far. I'm not sure if this is the best approach, but the only one I've found that worked with both programmatically managing the detail navigation from the reducer as well as still integrating correctly with From the Apple docs, it seems like it's necessary to use the Here's a simplified example with a sidebar list of items and a simple detail screen: import ComposableArchitecture
import SwiftUI
struct Item: Equatable, Identifiable {
var id = UUID()
var title: String
}
struct AppFeature: ReducerProtocol {
struct State: Equatable {
var items: IdentifiedArrayOf<Item> = []
@BindingState var selectedItem: Item.ID?
@PresentationState var detail: ChildFeature.State?
init() {
self.items = .init(uniqueElements: (1...100).map {
Item(title: "Item \($0)")
})
}
}
enum Action: BindableAction, Equatable {
case selectItem(Item.ID?)
case detail(PresentationAction<ChildFeature.Action>)
case binding(BindingAction<State>)
}
var body: some ReducerProtocolOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .selectItem(let itemId):
if let itemId {
state.selectedItem = itemId
state.detail = .init(item: state.items[id: itemId]!)
} else {
state.selectedItem = nil
state.detail = nil
}
return .none
case .detail(.presented(.deselect)):
state.selectedItem = nil
state.detail = nil
return .none
case .detail, .binding:
return .none
}
}
.ifLet(\.$detail, action: /Action.detail) {
ChildFeature()
}
}
}
struct AppFeatureView: View {
let store: StoreOf<AppFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
NavigationSplitView {
List(selection: viewStore.binding(
get: \.selectedItem,
send: { .selectItem($0) }
)) {
ForEach(viewStore.items) { item in
NavigationLink(value: item.id) {
Text(item.title)
}
}
}
} detail: {
IfLetStore(store.scope(state: \.$detail, action: AppFeature.Action.detail)) { store in
ChildView(store: store)
} else: {
Text("No Selection")
}
}
}
}
}
struct ChildFeature: ReducerProtocol {
struct State: Equatable {
var item: Item
}
enum Action: Equatable {
case deselect
}
var body: some ReducerProtocolOf<Self> {
Reduce { state, action in
switch action {
case .deselect:
return .none
}
}
}
}
struct ChildView: View {
let store: StoreOf<ChildFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Text("Selected item: \(ViewStore(store).item.title)")
.font(.title)
Button {
viewStore.send(.deselect)
} label: {
Text("Deselect")
}
}
}
}
struct AppFeatureView_Previews: PreviewProvider {
static var previews: some View {
AppFeatureView(store: .init(initialState: .init()) {
AppFeature()
})
}
} This seems to work correctly on iPhone, iPad, and mac, but not sure if there are other more complicated situations where it doesn't work (like a NavigationStack in the detail). Would love to know if there is a better way to do this though! |
Beta Was this translation helpful? Give feedback.
-
Unfortunately I realised that NavigationSplitView doesn't work with a NavigationPath of any kind. This is what I had in mind initially: struct ContentView: View {
@State private var colorShown: Color?
var body: some View {
NavigationSplitView {
NavigationStack {
List {
NavigationLink("Mint", value: Color.mint)
NavigationLink("Pink", value: Color.pink)
NavigationLink("Teal", value: Color.teal)
}
}
.navigationDestination(for: Color.self) { color in
ColorDetail(color: color)
}
} detail: {
Text("Select a color")
}
}
}
struct ColorDetail: View {
var color: Color
var body: some View {
color
}
} This works as expected, but if I add a path to NavigationStack it will navigate in the sidebar instead of replacing the detail view. So this is why NavigationStackStore can't simply replace NavigationStack in this situation. I think the solution illustrated by @zachwaugh is the way to go right now, but it feels quite heavy to use. It would be nice to at least have a demo project for this, as it is very common on iPad and Mac to have a sidebar. |
Beta Was this translation helpful? Give feedback.
-
What's somewhat unintuitive about NavigationSplitView is that it literally vends different containers if the Split View is collapsed or not for compact size classes. This includes dynamically resizing an app window on iPad. You'll get a I think this somewhat reflects how UISplitViewController lets you pick a view controller when the Split View expands and collapses. |
Beta Was this translation helpful? Give feedback.
-
I've been looking into how to get a This 'works' but:
import ComposableArchitecture
import MapKit
import SwiftUI
struct ColorItem: Identifiable, Equatable, Hashable {
var id: Int
var name: String
var color: Color
var maps: [MapItem]
}
struct MapItem: Identifiable, Equatable, Hashable {
var id: Int
var name: String
}
@main
struct FeaturesApp: SwiftUI.App {
let store: StoreOf<SidebarListFeature> = .init(
initialState: SidebarListFeature.State(colors: [
.init(
color: .init(
id: 1, name: "Mint", color: .mint,
maps: [.init(id: 1, name: "Australia"), .init(id: 2, name: "Japan")])),
.init(
color: .init(
id: 2, name: "Pink", color: .pink,
maps: [.init(id: 3, name: "Ireland"), .init(id: 4, name: "France")])),
.init(
color: .init(
id: 3, name: "Teal", color: .teal,
maps: [.init(id: 5, name: "China"), .init(id: 6, name: "Poland")])),
]), reducer: { SidebarListFeature() })
var body: some Scene {
WindowGroup {
ComposableContentView(store: store)
}
// WindowGroup {
// ContentView()
// }
}
}
@Reducer
struct SidebarListFeature {
@ObservableState
struct State {
var selectedItemId: ColorFeature.State.ID?
var colors: IdentifiedArrayOf<ColorFeature.State>
@Presents
var destination: Destination.State?
var inspectorVisible: Bool = true
}
enum Action: BindableAction {
case selectItem(ColorFeature.State)
case select(ColorFeature.State.ID?)
case destination(PresentationAction<Destination.Action>)
case binding(BindingAction<State>)
}
@Reducer
enum Destination {
case color(ColorFeature)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case let .selectItem(colorState):
state.destination = .color(colorState)
return .none
case .destination(.presented(.color)):
guard case let .color(colorItemState) = state.destination else { return .none }
state.colors[id: colorItemState.id] = colorItemState
print("~~", state.colors[id: colorItemState.id]!)
return .none
case .destination(_):
return .none
case let .select(id):
guard let id, let colorState = state.colors[id: id] else { return .none }
state.selectedItemId = id
return .send(.selectItem(colorState))
case .binding(_):
return .none
}
}
.ifLet(\.$destination, action: \.destination)
}
}
@Reducer
struct MapFeature {
@ObservableState
struct State: Identifiable, Hashable, Equatable {
var id: Int { map.id }
var map: MapItem
}
enum Action: BindableAction {
case mapName(String)
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case let .mapName(name):
state.map.name = name
return .none
case .binding(_):
return .none
}
}
}
}
@Reducer
struct ColorFeature {
@ObservableState
struct State: Identifiable, Equatable, Hashable {
var id: Int { color.id }
var color: ColorItem
var selectedMapId: MapFeature.State.ID?
@Presents
var detailDestination: DetailDestination.State?
}
enum Action: BindableAction {
case color(String?)
case binding(BindingAction<State>)
case detailDestination(PresentationAction<DetailDestination.Action>)
case selectMap(MapFeature.State.ID?)
case selectMapItem(MapFeature.State)
}
@Reducer(state: .hashable)
enum DetailDestination {
case map(MapFeature)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case let .color(name):
guard let name else { return .none }
state.color.name = name
return .none
case let .selectMap(id):
guard let id, let mapState = state.color.maps.first(where: { $0.id == id }) else {
return .none
}
state.selectedMapId = id
return .send(.selectMapItem(MapFeature.State.init(map: mapState)))
case .binding(_):
return .none
case .detailDestination(.presented(.map)):
guard case let .map(mapState) = state.detailDestination else { return .none }
if let i = state.color.maps.firstIndex(where: { $0.id == mapState.id }) {
state.color.maps[i] = mapState.map
}
return .none
case .detailDestination(_):
return .none
case let .selectMapItem(mapState):
state.detailDestination = .map(mapState)
return .none
}
}
.ifLet(\.$detailDestination, action: \.detailDestination)
}
}
struct ComposableContentView: View {
@Bindable var store: StoreOf<SidebarListFeature>
var body: some View {
NavigationSplitView {
List(store.colors, selection: $store.selectedItemId.sending(\.select)) { color in
NavigationLink(value: color) {
Text(color.color.name)
}
}
.navigationDestination(
item: $store.scope(state: \.destination?.color, action: \.destination.color)
) { destination in
ComposableColorContentView(store: destination)
.navigationTitle(destination.color.name)
}
} content: {
Text("Content is replaced by outer navigationDestination")
} detail: {
if let colorStore = store.scope(state: \.destination?.color, action: \.destination.color) {
if let mapStore = colorStore.scope(
state: \.detailDestination?.map, action: \.detailDestination.map)
{
@Bindable var bindable = mapStore
MapItemView(store: bindable)
.inspector(isPresented: $store.inspectorVisible) {
Text("Inspector Content")
}
.toolbar {
ToolbarItem {
Toggle(isOn: $store.inspectorVisible) {
Image(systemName: "sidebar.right")
}
}
}
}
} else {
Text("Detail is replaced by scoping first the color and then the detailDestination stores")
}
}
}
}
struct MapItemView: View {
@Bindable var store: StoreOf<MapFeature>
var body: some View {
VStack {
TextField("Name", text: $store.map.name.sending(\.mapName))
Map()
}
}
}
struct ComposableColorContentView: View {
@Bindable var store: StoreOf<ColorFeature>
var body: some View {
TextField("Color Name", text: $store.color.name.sending(\.color))
Text(store.color.name)
List(store.color.maps, selection: $store.selectedMapId.sending(\.selectMap)) { map in
NavigationLink(value: MapFeature.State(map: map)) {
Text(map.name)
}
}
}
}
// Vanilla SwiftUI Setup
struct ContentView: View {
@State private var colorShown: Color?
@State private var path = NavigationPath()
@State private var inspector: Bool = true
var body: some View {
NavigationSplitView {
List(selection: $colorShown) {
NavigationLink("Mint", value: Color.mint)
NavigationLink("Pink", value: Color.pink)
NavigationLink("Teal", value: Color.teal)
}
} content: {
NavigationStack(path: $path) {
if let colorShown {
ColorDetail(color: colorShown)
.navigationDestination(for: Color.self) { color in
ColorDetail(color: color)
}
} else {
Text("Select a color")
}
}
} detail: {
Map()
.inspector(isPresented: $inspector) {
List {
Form {
Section("Properties") {
if let colorShown {
List {
Text("Some Color Properties")
ColorDetail(color: colorShown)
}
} else {
Text("No Color Selected")
}
}
}
}
}
}
}
}
struct ColorDetail: View {
var color: Color
var body: some View {
List {
Text("Some Color Showing")
color
}
}
} |
Beta Was this translation helpful? Give feedback.
-
I've figured it out. I had to use the store of the feature in question when scoping, not the state of that store, which makes sense - the List selection binding and the NavigationLink value need to be of the same type. var selectedItem: StoreOf<ColorFeature>?
enum Action {
case select(StoreOf<ColorFeature>?)
}
List(selection: $store.selectedItem.sending(\.select)) {
ForEach(store.scope(state: \.groups, action: \.groups)) { group in
DisclosureGroup {
ForEach(group.scope(state: \.colors, action: \.colors)) { color in
NavigationLink(value: color) {
Text(color.color.name)
}
}
} label: {
Text(group.name)
}
} |
Beta Was this translation helpful? Give feedback.
-
As the title says, I can't find any documentation or examples of using the new navigation APIs with NavigationSplitView.
In vanilla SwiftUI, a NavigationStack can be used in the column of a NavigationSplitView and it will automatically change the view presented in "details". Adding a NavigationStackStore inside a NavigationSplitView doesn't have the same effect though.
Is this a supported use case or more navigation tools are needed for this in TCA?
Beta Was this translation helpful? Give feedback.
All reactions