I’ve encountered a SwiftUI NavigationSplitView
bug in my app that I’d appreciate help with, I’ve spent quite some time trying to work out a solution to this problem. I have a main list, which has either a Location
or LocationGroup
. When selecting a Location
, the detail view is presented. When selecting a LocationGroup
, a subsequent list of Locations
are presented (from the group). When tapping on any of these Location
s, the NavigationStack does not animate (push the view), and when tapping back from it, it jumps to the main selection.
I’d greatly appreciate help with this bug in my code, thanks!
Here’s a GIF of the bug occurring:
I created the following sample code that reproduces the issue:
ContentView.swift
public enum LocationSplitViewNavigationItem: Identifiable, Hashable {
case location(Location)
case locationGroup(LocationGroup)
public var id: UUID? {
switch self {
case .location(let location):
return location.id
case .locationGroup(let locationGroup):
return locationGroup.id
}
}
}
struct ContentView: View {
@State private var columnVisibility = NavigationSplitViewVisibility.doubleColumn
@State private var selectedNavigationItem: LocationSplitViewNavigationItem?
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
LocationListView(selectedItem: $selectedNavigationItem, locationData: LocationSampleData(locations: LocationSampleData.sampleLocations, locationGroups: LocationSampleData.sampleLocationGroups))
} detail: {
if let selectedLocation = selectedNavigationItem {
switch selectedLocation {
case .location(let location):
LocationDetailView(selectedLocation: location)
case .locationGroup(let locationGroup):
LocationListView(selectedItem: $selectedNavigationItem, locationData: LocationSampleData(locations: locationGroup.locations))
}
}
}
.navigationSplitViewStyle(.balanced)
}
}
LocationListView.swift
struct LocationListView: View {
@Binding public var selectedItem: LocationSplitViewNavigationItem?
var locationData: LocationSampleData
var body: some View {
List(selection: $selectedItem) {
if let locations = locationData.locations {
ForEach(locations) { location in
NavigationLink(value: LocationSplitViewNavigationItem.location(location)) {
Text(location.name)
.bold()
}
}
}
if let locationGroups = locationData.locationGroups {
ForEach(locationGroups) { locationGroup in
NavigationLink(value: LocationSplitViewNavigationItem.locationGroup(locationGroup)) {
Text(locationGroup.name)
.bold()
.foregroundStyle(.red)
}
}
}
}
.navigationTitle("Saturday Spots")
.navigationBarTitleDisplayMode(.large)
}
}
LocationDetailView.swift
struct LocationDetailView: View {
var selectedLocation: Location
var body: some View {
Text(selectedLocation.name)
.font(.largeTitle)
.bold()
.foregroundStyle(LinearGradient(
colors: [.teal, .indigo],
startPoint: .top,
endPoint: .bottom
))
.toolbarBackground(
Color.orange,
for: .navigationBar
)
.toolbarBackground(.visible, for: .navigationBar)
}
}
Location.swift
import CoreLocation
struct LocationSampleData {
var locations: [Location]?
var locationGroups: [LocationGroup]?
static let sampleLocations: [Location]? = Location.sample
static let sampleLocationGroups: [LocationGroup]? = [LocationGroup.sample]
}
public struct Location: Hashable, Identifiable {
var name: String
var coordinates: CLLocationCoordinate2D
var photo: String
public var id = UUID()
static public let sample: [Location] = [
Location(name: "Best Bagel & Coffee", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf"),
Location(name: "Absolute Bagels", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf"),
Location(name: "Tompkins Square Bagels", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf"),
Location(name: "Zabar's", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf"),
]
static public let oneSample = Location(name: "Absolute Bagels", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf")
}
public struct LocationGroup: Identifiable, Hashable {
var name: String
var locations: [Location]
public var id = UUID()
static public let sample: LocationGroup = LocationGroup(name: "Bowling", locations: [
Location(name: "Frames Bowling Lounge", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf"),
Location(name: "Bowlero Chelsea Piers", coordinates: CLLocationCoordinate2D(latitude: 123, longitude: 121), photo: "asdf")
])
}
extension CLLocationCoordinate2D: Hashable {
public static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
}
public func hash(into hasher: inout Hasher) {
hasher.combine(latitude)
hasher.combine(longitude)
}
}
2
Answers
After a lot of trial and error, and gaining a deeper understanding of how
NavigationSplitView
,NavigationStack
, andNavigationPath
can work together, I came up with the following solution, that allows me to push a list onto an existing one, and useNavigationSplitView
'sdetail:
to push a detail. While this doesn't support recursive-lists (more than two levels deep), that's not a requirement for my usage.The "trick" that I learned that allowed this to work, was to use
NavigationPath
within aNavigationStack
in myNavigationSplitView
sidebar, in order to push another list onto thedetail
.In addition, the trigger of
detail:
in aNavigationSplitView
is from theList(selection:)
within theside-bar
.Because I am using the same
Binding
within both myList
and its sub-list, theNavigationSplitView
pushes the detail onto the highest stack.Here's the final result. working on iPhone and iPad:
In order to push a
LocationListView
onto an existing one, I incorporatedNavigationPath
:ContentView
LocationListView
I don’t see this as a bug so much as it’s doing what you’re telling it to do.
Consider your stack view.
When you tap a
Location
from the main list view,selectedNavigationItem
is no longernil
and goes into your switch. The.location
case is hit, causing the view to navigate toLocationDetailView(selectedLocation:)
.What happens when you tap on a
LocationGroup
? It does similar to above, expect it goes into the.locationGroup
case and displays anotherLocationListView
.However, what happens next when you tap on a
Location
? Your logic in yourdetail
closure doesn’t provide for multiple layers of navigation. Instead, it checks for the state of theselectedNavigationItem
. After tapping on aLocation
, the value ofselectedNavigationItem
has changed. It’s not pushing a new view but, instead, is replacing the existing detail with the details of theLocation
. This is also why a tap on the back button takes you back to the root.You need to account for this type of navigation with your
detail
column, such as with aNavigationStack
and its own navigation paths. This would change your detail into something like the following:This pattern should hopefully allow for your detail view to navigate down the hierarchy.