TL;DR: If I have a view containing a NavigationSplitView(sidebar:detail:)
, with a property (such as a State
or StateObject
) tracking user selection, how should I make it so that the sidebar
and detail
views observe the user selection, but the parent view does not?
Using SwiftUI's new NavigationSplitView
(or the deprecated NavigationView
), a common paradigm is to have a list of selectable items in the sidebar view, with details of the selected item in the detail view. The selection, of course, needs to be observed, usually from within an ObservedObject
.
struct ExampleView: View {
@StateObject private var viewModel = ExampleViewModel()
var body: some View {
NavigationSplitView {
SidebarView(selection: $viewModel.selection)
} detail: {
DetailView(item: viewModel.selection)
}
}
}
struct SidebarView: View {
let selectableItems: [Item] = []
@Binding var selection: Item?
var body: some View {
List(selectableItems, selection: $viewModel.selected) { item in
NavigationLink(value: item) { Text(item.name) }
}
}
}
struct DetailView: View {
let item: Item?
var body: some View {
// Details of the selected item
}
}
@MainActor
final class ExampleViewModel: ObservableObject {
@Published var selection: Item? = nil
}
This, however, poses a problem: the ExampleView
owns the ExampleViewModel
used for tracking the selection, which means that it gets recalculated whenever the selection changes. This, in turn, causes its children SidebarView
and DetailView
to be redrawn.
Since we want those children to be recalculated, one might be forgiven for thinking that everything is as intended. However, the ExampleView
itself should not be recalculated in my opinion, because doing so will not only update the child views (intended), but also everything in the parent view (not intended). This is especially true if its body is composed of other views, modifiers, or setup work. Case in point: in this example, the NavigationSplitView
itself will also be recalculated, which I don't think is what we want.
Almost all tutorials, guides and examples I see online use a version of the above example - sometimes the viewModel is passed as an ObservedObject
, or as an EnvironmentObject
, but they all share the same trait in that the parent view containing the NavigationSplitView
is observing the property that should only be observed by the children of NavigationSplitView
.
My current solution is to initiate the viewmodel in the parent view, but not observe it:
struct ExampleView: View {
let viewModel = ExampleViewModel()
...
}
@MainActor
final class ExampleViewModel: ObservableObject {
@Published var selection: Item? = nil
nonisolated init() { }
}
This way, the parent view will remain intact (at least in regards to user selection); however, this will cause the ExampleViewModel
to be recreated if anything else would cause the ExampleView
to be redrawn - effectively resetting our user selection. Additionally, we are unable to pass any of the viewModel's properties as bindings. So while it works for my current use-case, I don't consider this an effective solution.