Reputation: 151
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
Reputation: 36119
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