I've run into a problem related to navigation links in child Views containing a SwiftData @Query and a predicate.
When tapping on a NavigationLinks, the containing View is invalidated pausing the UI. When tapping back, the View is invalidated a second time during which time the View ignores any new taps for navigation leading to a poor user experience.
A complete example:
import SwiftUI
import SwiftData
@Model
final class Item {
var num: Int
init(num: Int) {
self.num = num
}
}
@main
struct TestSwiftDataApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([Item.self])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
let container: ModelContainer
do {
container = try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
// Add some sample data
Task { @MainActor in
for i in 0...1000 {
container.mainContext.insert(Item(num: i))
}
}
return container
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
extension Color {
static func random() -> Color {
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
}
}
struct ContentView: View {
var body: some View {
NavigationStack {
SubView()
.navigationDestination(for: Item.self) { item in
Text("Item at \(item.num)")
}
}
}
}
struct SubView: View {
@Environment(\.modelContext) private var modelContext
@Query(filter: #Predicate<Item> { item in
item.num < 20
}, sort: \.num) private var items: [Item]
var body: some View {
let _ = Self._printChanges()
List {
ForEach(items) { item in
NavigationLink(value: item) {
Text("Item \(item.num)")
}.background(Color.random())
}
}
}
}
The background colors of cells will shift every invalidation. In addition there's some debugging in there to show what's happening. When running it, I get
SubView: @self, @identity, _modelContext, @128, @144 changed.
SubView: @self changed.
SubView: @dependencies changed.
Then I tap on an item and it invalidates:
SubView: @self changed.
Tapping back invalidates it again during which time the UI ignores new taps:
SubView: @self changed.
The odd thing is, this behavior doesn't happen if the NavigationStack is moved to the child View with the NavigationLinks like this:
struct ContentView2: View {
var body: some View {
SubView2()
}
}
struct SubView2: View {
@Environment(\.modelContext) private var modelContext
@Query(filter: #Predicate<Item> { item in
item.num < 20
}, sort: \.num) private var items: [Item]
var body: some View {
let _ = Self._printChanges()
NavigationStack {
List {
ForEach(items) { item in
NavigationLink(value: item) {
Text("Item \(item.num)")
}.background(Color.random())
}
}
.navigationDestination(for: Item.self) { item in
Text("Item at \(item.num)")
}
}
}
}
When running this, there's one less change as well and no invalidations on tap or back:
SubView: @self, @identity, _modelContext, @128, @144 changed.
SubView: @dependencies changed.
The problem also doesn't happen if the @Query does not have a filter #Predicate.
Unfortunately, the application in question has a deeper hierarchy where views with a @Query with a predicate can navigation to other views with a @Query and predicate, so neither solution seems ideal.
Is there some other way to stop the invalidations from happening?
I seem to have come up with a workaround. Placing a View between the View containing the NavigationStack and the one containing the @Query with predicate filter appears to solve the problem. The view graph no longer gets invalidated when clicking to navigate away or back.
The resulting code looks like this:
struct ContentView: View {
var body: some View {
NavigationStack {
let _ = Self._printChanges()
MiddleView()
.navigationDestination(for: Item.self) { item in
Text("Item at \(item.num)")
}
}
}
}
struct MiddleView: View {
var body: some View {
let _ = Self._printChanges()
SubView()
}
}
struct SubView: View {
@Environment(\.modelContext) private var modelContext
@Query(filter: #Predicate<Item> { item in
item.num < 20
}, sort: \.num) private var items: [Item]
var body: some View {
let _ = Self._printChanges()
List {
ForEach(items) { item in
NavigationLink(value: item) {
Text("Item \(item.num)")
}.background(Color.random())
}
}
}
}
Not only that, but it appears that using NavigationLink(destination:, label: ) in the SubView seems to work now as well whereas before it would sometimes cause an infinite loop when navigating from a view with a Query predicate to another view with one.