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.
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.
people
array, but people
is a let
constant. people
should be a @State var
instead.$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]
.\.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
.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?
}