skip to Main Content

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 Locations, 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:
A video showing the NavigationSplitView bug, tapping on a main item, and then a group, which shows the lack of a navigation push animation

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


  1. Chosen as BEST ANSWER

    After a lot of trial and error, and gaining a deeper understanding of how NavigationSplitView, NavigationStack, and NavigationPath can work together, I came up with the following solution, that allows me to push a list onto an existing one, and use NavigationSplitView's detail: 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 a NavigationStack in my NavigationSplitView sidebar, in order to push another list onto the detail.

    In addition, the trigger of detail: in a NavigationSplitView is from the List(selection:) within the side-bar.

    Because I am using the same Binding within both my List and its sub-list, the NavigationSplitView pushes the detail onto the highest stack.

    Here's the final result. working on iPhone and iPad:

    A video of the Navigation Split View properly animating on iPad into a list, and a detail view from there

    A video of the Navigation Split View properly animating into a list, and a detail view from there

    In order to push a LocationListView onto an existing one, I incorporated NavigationPath:

    ContentView

    struct ContentView: View {
        @State private var navigationPath = NavigationPath()
        @State private var selectedItem: Location?
        @State private var columnVisibility = NavigationSplitViewVisibility.doubleColumn
        
        var body: some View {
            NavigationSplitView(columnVisibility: $columnVisibility) {
                NavigationStack(path: $navigationPath) {
                    LocationListView(
                        locationData: LocationSampleData(
                            locations: LocationSampleData.sampleLocations,
                            locationGroups: LocationSampleData.sampleLocationGroups
                        ), selectedItem: $selectedItem,
                        navigationPath: $navigationPath)
                    .navigationDestination(for: LocationGroup.self) { locationGroup in
                        LocationListView(
                            locationData: LocationSampleData(locations: locationGroup.locations), selectedItem: $selectedItem,
                            navigationPath: $navigationPath
                        )
                    }
                }
            } detail: {
                if let selectedItem = selectedItem {
                    LocationDetailView(selectedLocation: selectedItem)
                }
            }
        }
    }
    

    LocationListView

    struct LocationListView: View {
        var locationData: LocationSampleData
        @Binding var selectedItem: Location?
        @Binding var navigationPath: NavigationPath
        
        var body: some View {
            List(selection: $selectedItem) {
                if let locations = locationData.locations {
                    ForEach(locations) { location in
                        NavigationLink(value: location) {
                            Text(location.name)
                                .bold()
                        }
                    }
                }
                
                if let locationGroups = locationData.locationGroups {
                    ForEach(locationGroups) { locationGroup in
                        Button(action: {
                            navigationPath.append(locationGroup)
                        }) {
                            Text(locationGroup.name)
                                .bold()
                                .foregroundStyle(.red)
                        }
                    }
                }
            }
            .navigationTitle("Saturday Spots")
            .navigationBarTitleDisplayMode(.large)
        }
    }
    

  2. 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.

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

    When you tap a Location from the main list view, selectedNavigationItem is no longer nil and goes into your switch. The .location case is hit, causing the view to navigate to LocationDetailView(selectedLocation:).

    What happens when you tap on a LocationGroup? It does similar to above, expect it goes into the .locationGroup case and displays another LocationListView.

    However, what happens next when you tap on a Location? Your logic in your detail closure doesn’t provide for multiple layers of navigation. Instead, it checks for the state of the selectedNavigationItem. After tapping on a Location, the value of selectedNavigationItem has changed. It’s not pushing a new view but, instead, is replacing the existing detail with the details of the Location. 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 a NavigationStack and its own navigation paths. This would change your detail into something like the following:

    NavigationStack(path: $yourNavigationPaths) {
      Text("Select a location")
      .navigationDestination(LocationSplitViewNavigationItem.self) { navItem in
        switch navItem {
          case .location(let location):
            LocationDetailView(selectedLocation: location)
          case .locationGroup(let locationGroup):
            LocationListView(selectedItem: /* not sure how you want to handle this as you're now nesting another list view */, locationData: LocationSplitViewNavigationItem)
        }
      }
    

    This pattern should hopefully allow for your detail view to navigate down the hierarchy.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search