swiftswiftuiswiftdatabindableswiftui-navigationsplitview

Why is state variable not updating when passed through view hierarchy in NavigationSplitVIew?


I have a simple NavigationSplitView where the user can select from a list of people, and then a disclouse group of their favorite books appears in the content column and they then can select the book they're currently reading. However, when I pass a @State property called selectedBook as a binding to the disclosure group view struct, it doesn't update when I select a new reader. Here is a video of it: https://youtube.com/shorts/J96uPqx9_rU?feature=share and the code:

import SwiftUI
import SwiftData

@Model
class Person {
    var name: String = ""
    @Relationship(deleteRule: .cascade, inverse: \Book.customer) var books: [Book]?
    
    init(name: String) {
        self.name = name
    }
}

@Model
class Book {
    var name: String = ""
    var customer: Person?
    
    init(name: String) {
        self.name = name
    }
}

struct MainView: View {
    @Environment(\.modelContext) private var modelContext
    
    @Query private var queriedPersons: [Person]
    
    @State private var columnVisibility: NavigationSplitViewVisibility = .automatic
    @State private var selectedPerson: Person?
    
    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            List(selection: $selectedPerson) {
                ForEach(queriedPersons) { customer in
                    NavigationLink(customer.name, value: customer)
                }
            }
            .listStyle(.insetGrouped)
        }
        content: {
            if let selectedPerson = selectedPerson {
                PersonView(selectedPerson: selectedPerson)
            }
        }
        detail: {
            EmptyView()
        }
        .task {
            do {
                try modelContext.delete(model: Person.self)
                
                let billPerson = Person(name: "Bill")
                modelContext.insert(billPerson)
                billPerson.books = [
                    Book(name: "Catcher in the Rye"),
                    Book(name: "Liar's Poker"),
                    Book(name: "The Odyssey")
                ]
                
                let mikePerson = Person(name: "Mike")
                modelContext.insert(mikePerson)
                mikePerson.books = [
                    Book(name: "Red Dragon"),
                    Book(name: "Steve Jobs"),
                    Book(name: "The Great Gatsby")
                ]
            } catch {
                fatalError(error.localizedDescription)
            }
        }
        .onFirstAppear {
            columnVisibility = .all
        }
    }
}

struct PersonView: View {
    private(set) var selectedPerson: Person
    
    @State private var selectedBook: Book?
    
    var body: some View {
        List {
            BooksDisclosure(books: selectedPerson.books, selectedBook: $selectedBook)
        }
        .listStyle(.insetGrouped)
    }
}

struct BooksDisclosure: View {
    private(set) var books: [Book]?
    @Binding var selectedBook: Book?
    
    @Query private var queriedBooks: [Book]
    
    @State var disclosureIsExpanded: Bool = false
    
    private var filteredAndSortedBooks: [Book] {
        queriedBooks.filter { book in
            books?.contains(book) == true
        }
        .sorted { lhs, rhs in
            lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
        }
    }
    
    var body: some View {
        DisclosureGroup(isExpanded: $disclosureIsExpanded) {
            ForEach(filteredAndSortedBooks) { book in
                HStack {
                    Button(action: {
                        if selectedBook != book {
                            selectedBook = book
                        }
                        
                        if disclosureIsExpanded != true {
                            disclosureIsExpanded = false
                        }
                    }) {
                        Text(book.name)
                    }
                    .buttonStyle(PlainButtonStyle())
                    
                    if selectedBook == book {
                        Image(systemName: "checkmark")
                    }
                    
                    Spacer()
                }
            }
        } label: {
            Text("Currently Reading: ")
            if let selectedBook = selectedBook {
                Text(selectedBook.name)
            } else {
                Text("None")
            }
        }
        .onAppear {
            selectedBook = filteredAndSortedBooks.first
        }
    }
}

My first thought was that it was the thing from Apple's release notes:

Conditional views in columns of NavigationSplitView fail to update on some state changes. (91311311) Workaround: Wrap the contents of the column in a ZStack.

I've tried this though at numerous points in the code and it's made no difference. From what I've read, I think it's possible that I may need to use @Bindable instead of @Binding for selectedBook in BooksDisclosure (apparently post iOS 17 non-primitive types need to be passed as @Bindable instead of a @Binding), however when I change it, all hell breaks loose with the compiler. Apparently it can't be an optional anymore and don't understand the proper syntax to be able to convert it from an optional to a non optional and it's throwing errors when I try to assign to it (yet wrappedValue doesn't work). Not even sure I'm heading into right direction with this.

So was just wondering in general if anyone has any insight into why this isn't working now after I switch to NavigationSplitView when it was working fine on a NavigationStack.


Solution

  • I think the confusion occurred for me because I switched from a NavigationStack to a NavigationSplitView, and now PersonView would become omnipresent as opposed to being reloaded everytime it was pushed on the stack, so I had to make sure that I was updating selectedBook when selectedPerson changed. So now I'm passing selectedPerson to BooksDisclosure, and adding this to the DisclosureGroup:

            .onChange(of: self.selectedPerson){
                selectedBook = filteredAndSortedBooks.first
            }
    

    Maybe should've included this in my MRE, but in the actual code Book has a property called lastSelectedDate that gets updated whenever a Book gets selected, and this is how I'm determining what Book to set as selected when a Person is selected (as opposed to selecting the first in the array which is weak from a UI perspective).