Search code examples
listswiftuiselectionnavigationsplitview

Selected item on Sidebar forced back to nil when returning from Detail view (on iPhone)


I have a NavigationSplitView with a Sidebar and Detail view. When selecting an item from the sidebar, the Detail view is updated to show what was selected. This all works fine so far on both an iPhone and an iPad.

However, on an iPhone, when returning back to the sidebar view to select something else, the previously selected item is being set to nil.

On an iPad it is not setting the previously selected item to nil, even if you hide and show the sidebar, it still remembers the previously selected entry.

I have tried with the iPhone simulator and on a physical device too.

To reproduce the issue, see below small (complete) code. Run it on a simulator or physical device. Select an entry from the sidebar, the detail is shown/updated showing what was selected. Return back to the sidebar view and you can see for about half a second the previously selected item still selected, then it gets set to nil.

Why is the selection being reset back to nil on the iPhone when returning back to the Sidebar view?

import SwiftUI

struct ContentView: View {
    @State private var selection: String?
    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selection)
        } detail: {
            DetailView(selection: $selection)
        }
    }
}

struct SidebarView: View {
    @Binding var selection: String?
    let people = ["Finn", "Leia", "Luke", "Rey"]
    var body: some View {
        List(people, id: \.self, selection: $selection) { person in
            Text(person)
        }
        Text("selection = \(String(describing: selection))")
    }
}

struct DetailView: View {
    @Binding var selection: String?
    var body: some View {
        Text("selectedItem = \(String(describing: selection))")
    }
}

#Preview {
    ContentView()
}

See below GIF. Notice down the bottom of the screen of the sidebar. When returning to the sidebar you will see it still shows the previously selected item for a very short half a second or so. Then it gets cleared to nil.

Example showing the issue


Solution

  • Just make the @State a non-optional. Then convert it to Binding<String?> using one of its initialisers, before passing it to selection:

    struct ContentView: View {
        @State private var selection: String = "Finn"
        var body: some View {
            NavigationSplitView {
                SidebarView(selection: $selection)
            } detail: {
                DetailView(selection: $selection)
            }
        }
    }
    
    struct SidebarView: View {
        @Binding var selection: String
        let people = ["Finn", "Leia", "Luke", "Rey"]
        var body: some View {
            // note the selection parameter
            List(people, id: \.self, selection: Binding($selection)) { person in
                Text(person)
                    // if you want to indicate the previously selected person more clearly
                    .listRowBackground(selection == person ? Color.gray : nil)
            }
            Text("selection = \(String(describing: selection))")
        }
    }
    
    struct DetailView: View {
        @Binding var selection: String
        var body: some View {
            Text("selectedItem = \(String(describing: selection))")
        }
    }
    

    This forces you to give an initial selection. If you don't want that, you can add a new @State, in SidebarView or ContentView.

    struct SidebarView: View {
        @Binding var selection: String?
        @State private var visitedPerson: String?
        let people = ["Finn", "Leia", "Luke", "Rey"]
        var body: some View {
            Group {
                List(people, id: \.self, selection: $selection) { person in
                    Text(person)
                        // if you want to indicate the previously selected person more clearly
                        .listRowBackground(visitedPerson == person ? Color.gray : nil)
                }
                Text("selection = \(String(describing: visitedPerson))")
            }
            .onChange(of: selection) { oldValue, newValue in
                if let newValue {
                    visitedPerson = newValue
                }
            }
                
        }
    }
    

    That said, the "forced deselection" behaviour is consistent with how iOS behaves everywhere else. When a view is pushed on top of a List and then subsequently popped, the List will not retain its selection.

    See for example:

    struct ContentView: View {
        @State private var items = ["One", "Two", "Three"]
        @State private var selected: String?
        
        var body: some View {
            NavigationStack {
                List(items, id: \.self, selection: $selected) { item in
                    Text(item)
                }
                .toolbar {
                    NavigationLink("Navigate") {
                        Text("Destination")
                    }
                }
            }
            
        }
    }
    

    You can also compare the Settings app on iPhones and iPads, which also has a NavigationSplitView-like design.