Amar Sagoo
Amar Sagoo

Reputation: 151

Right way to select newly added SwiftData object in NavigationSplitView?

I'm using a two-column NavigationSplitView to display SwiftData objects in a macOS app. After you add a new object, I want it to be selected in the sidebar. Although I've found a way to do this, it feels awkward and I'm wondering if there's a simpler solution I'm missing.

The issue is that you can't simply set the selection directly after adding an object, because you're only inserting it into the SwiftData model context, not directly into the array, which is a @Query that needs to update separately. So the array won't yet contain the new object at the point where you would try to set the selection.

To get around this, my code remembers the new object in an addedItem variable, waits for the @Query array to get updated using onChange(of:) and sets the selection from there.

Here's essentially what I have:

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var selectedItem: Item?
    @State private var addedItem: Item? // PART OF MY SOLUTION
    
    var body: some View {
        NavigationSplitView {
            List(selection: $selectedItem) {
                ForEach(items) { item in
                    NavigationLink(value: item,
                                   label: { Text(verbatim: item.name) })
                }
            }
            .navigationSplitViewColumnWidth(min: 180, ideal: 200)
            .toolbar {
                Button("Add", systemImage: "plus", action: addItem)
            }
        } detail: {
            DetailView(item: selectedItem)
        }
        .task {
            selectedItem = items.first
        }
        // PART OF MY SOLUTION:
        .onChange(of: items) { oldValue, newValue in
            if let addedItem, items.contains(addedItem) {
                selectedItem = addedItem
            }
            addedItem = nil
        }
    }
    
    private func addItem() {
        let newItem = Item()
        withAnimation {
            modelContext.insert(newItem)
            //selectedItem = newItem // THIS WOULDN'T WORK
        }
        addedItem = newItem // PART OF MY SOLUTION
    }
}

Is there a more elegant solution?

Upvotes: 1

Views: 927

Answers (1)

You could try this approach using only selectedItem and DispatchQueue.main.async as shown in this example code:

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var selectedItem: Item?
    
    var body: some View {
        NavigationSplitView {
            List(selection: $selectedItem) {
                ForEach(items) { item in
                    NavigationLink(value: item,
                                   label: { Text(verbatim: item.name) })
                }
            }
            .navigationSplitViewColumnWidth(min: 180, ideal: 200)
            .toolbar {
                Button("Add", systemImage: "plus", action: addItem)
            }
        } detail: {
            DetailView(item: selectedItem)
        }
        .task {
            selectedItem = items.first
        }
    }
    
    private func addItem() {
        let rndm = String(UUID().uuidString.prefix(5))
        let newItem = Item(name: "Mickey Mouse \(rndm)")
        withAnimation {
            modelContext.insert(newItem)
        }
        DispatchQueue.main.async {
            selectedItem = newItem  // <--- here
        }
    }
}

struct DetailView: View {
    var item: Item?
    
    var body: some View {
        Text(item?.name ?? "no name")
    }
}

@Model
final class Item {
    var timestamp: Date
    var name: String
    
    init(timestamp: Date = Date(), name: String) {
        self.timestamp = timestamp
        self.name = name
    }
}

As you mentioned the issue is to do with timing (or maybe threading). If async does not work for you, you could try

 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
      selectedItem = newItem  // <--- here
}

Another approach less workaround but still using only selectedItem, is to use the fact that the @Query private var items: [Item] conforms to DynamicProperty protocol, such that:

 .onChange(of: items) {
     selectedItem = items.last
 }
 
 private func addItem() {
     let rndm = String(UUID().uuidString.prefix(5))
     let newItem = Item(name: "Mickey Mouse \(rndm)")
     withAnimation {
         modelContext.insert(newItem)
     }
 }

Upvotes: 0

Related Questions