iosswiftmacosswiftuiswiftui-list

SwiftUI app using NavigationSplitView crashes when sidebar list's selection is cleared


I have a simple SwiftUI app with a sidebar and detail pane implement using NavigationSplitView. It works OK, except when (programmatically) deselecting the item selected in the list, the app crashes with an unhelpful stack trace (mostly ___lldb_unamed_symbol). Specifically, when selectedItem = nil, the app crashes.

The issue can be reproduced with the program below:

import SwiftUI

struct Person: Hashable {
    var firstName: String
    var lastName: String
}

struct PersonDetailView: View {

    var body: some View {
        if let selectedPersonBinding = Binding($selection) {
            VStack {
                TextField("First Name", text: selectedPersonBinding.firstName)
                TextField("Last Name", text: selectedPersonBinding.lastName)
            }
            .padding()
        }
    }

    @Binding var selection: Person?
}

struct ContentView: View {
    var body: some View {
        NavigationSplitView {
            List(people, id: \.self, selection: $selectedPerson) {
                Text($0.firstName)
            }
            .toolbar {
                Button("Deselect") {
                    selectedPerson = nil
                }
            }
        } detail: {
            PersonDetailView(selection: $selectedPerson)
        }
        .onAppear() {
            selectedPerson = people[0]
        }
    }

    let people = [
        Person(firstName: "Steve", lastName: "Jobs"),
        Person(firstName: "Steve", lastName: "Wozniak"),
        Person(firstName: "Ronald", lastName: "Wayne")
    ]
    @State var selectedPerson: Person?
}

Run that on Mac or iPad, click the "Deselect" button, and the app will crash.

I've deduced that it has something to do with the detail view's binding to selectedPerson, as the problem goes away if I remove that binding, but I can't figure out why it should crash with that binding there.


Solution

  • This seems to be the same problem as the one in this post, which has been largely seen as a SwiftUI bug. This happens whenever you use the Binding initialiser to convert a Binding<T?> to Binding<T>?, then pass the resulting Binding to some other view in a if statement. A minimal reproducible example is:

    struct ContentView: View {
        @State private var s: String? = "Foo"
        
        var body: some View {
            if let binding = Binding($s) {
                TextField("Foo", text: binding)
            }
            Button("Foo") {
                s = nil
            }
        }
    }
    

    That said, in your specific case, your code has other mistakes, and if you do it correctly, you will avoid this bug.

    1. I assume you want the text fields to modify the names of the people in the people array, but people is a let constant. people should be a @State var instead.
    2. You pass $selectedPerson to the detail view, so the text fields will be changing selectedPerson, not the people in people. You should pass a binding like $people[selectedIndex].
    3. You are using \.self as the id, so when any of the people changes, the ids of the list rows change, and everything is destroyed and recreated. This is undesirable. Person should conform to Identifiable.
    4. You are using Person as the selection type. This means every change to the selected person changes the selection value. Again, this is undesirable. The selection type should be Person.ID.

    After these changes, you get:

    struct Person: Hashable, Identifiable {
        var firstName: String
        var lastName: String
        let id = UUID()
    }
    
    struct PersonDetailView: View {
    
        var body: some View {
            VStack {
                TextField("First Name", text: $selection.firstName)
                TextField("Last Name", text: $selection.lastName)
            }
            .padding()
        }
    
        @Binding var selection: Person
    }
    
    struct ContentView: View {
        var body: some View {
            NavigationSplitView {
                List(people, selection: $selectedPerson) {
                    Text($0.firstName)
                }
                .toolbar {
                    Button("Deselect") {
                        selectedPerson = nil
                    }
                }
            } detail: {
                if let selectedPerson, let index = people.firstIndex(where: { $0.id == selectedPerson }) {
                    PersonDetailView(selection: $people[index])
                }
            }
        }
    
        @State var people = [
            Person(firstName: "Steve", lastName: "Jobs"),
            Person(firstName: "Steve", lastName: "Wozniak"),
            Person(firstName: "Ronald", lastName: "Wayne")
        ]
        @State var selectedPerson: UUID?
    }