SwiftData: Dynamic Query with @State properties


Greetings, traveler!

Even though SwiftData is based on CoreData, it is relatively young and does not offer some convenient tools that we can expect from it. This article will cover a case where we must dynamically change the predicate based on a state property value.

FetchDescriptor

Consider this example. We have an item list that we display on the screen. Every element is stored in SwiftData and looks like this.

@Model
final class Item {
    
    @Attribute(.unique)
    let id: UUID
    let title: String
    let date: Date
    
    init(
        id: UUID,
        title: String,
        date: Date
    ) {
        self.id = id
        self.title = title
        self.date = date
    }
    
}

Our Item model has a date variable. We want to limit the number of displayed items via FetchDescriptor.  Let’s create an example, then.

struct ContentView: View {
    
    @Environment(\.modelContext) var modelContext
    
    @Query(
        FetchDescriptor<Item>(
            predicate: #Predicate<Item> { $0.date >= .now },
            sortBy: [SortDescriptor(\.date)]
        )
    ) var items: [Item]
    
    var body: some View {
        List {
            ForEach(items) {
                Text($0.title)
            }
        }
    }
    
}

Everything seems to be okay. But what if we want to make our Query dynamic and base it on specific @State properties? We cannot do it for now.

struct ContentView: View {
    
    @Environment(\.modelContext) var modelContext
    
    @State private var startDate: Date = .distantPast
    @State private var endDate: Date = .distantFuture
    
    @Query(
        FetchDescriptor<Item>(
            predicate: #Predicate<Item> { $0.date >= startDate && $0.date <= endDate },
            sortBy: [SortDescriptor(\Item.title)]
        )
    ) var items: [Item]
    
    var body: some View {        
        List {
            ForEach(items) {
                Text($0.title)
            }
        }
    }
    
}

❌ Instance member ‘endDate’ cannot be used on type ‘ContentView’; did you mean to use a value of this type instead?


❌ Instance member ‘startDate’ cannot be used on type ‘ContentView’; did you mean to use a value of this type instead?

DynamicQueryView

Since SwiftData does not offer a tool to solve this problem automatically, we should figure it out ourselves. To make it work, we must inject the date values into the View initializer. We can do this right in the parent View.

struct ContentView: View {
    
    @Environment(\.modelContext) var modelContext
    
    @Query var items: [Item]
    
    init(startDate: Date, endDate: Date) {
        let predicate = #Predicate<Item> {
            $0.date >= startDate && $0.date <= endDate
        }
        
        _items = Query(filter: predicate, sort: \.date)
    }
    
    var body: some View {
        List {
            ForEach(items) {
                Text($0.title)
            }
        }
    }
    
}

If we want to apply changes dynamically and not worry about such injection, we can create a generic wrapper View. This view will allow us to use any Element that is a PersistentModel and produce any View as its content. We will inject the descriptor via its constructor.

struct DynamicQueryView<Element: PersistentModel, Content: View>: View {
    
    private let descriptor: FetchDescriptor<Element>
    private let content: ([Element]) -> Content
    
    @Query private var items: [Element]
    
    init(
        _ descriptor: FetchDescriptor<Element>,
        @ViewBuilder content: @escaping ([Element]) -> Content
    ) {
        self.descriptor = descriptor
        self.content = content
        _items = Query(descriptor)
    }
    
    var body: some View {
        content(items)
    }
    
}

In our parent View, we should create a computed property to produce a descriptor. We can use our state properties as well.

private var descriptor: FetchDescriptor<Item> {
    let predicate = #Predicate<Item> {
        $0.date >= startDate && $0.date <= endDate
    }
    return FetchDescriptor<Item>(
        predicate: predicate,
        sortBy: [SortDescriptor(\.date)]
    )
}

Now, we can use our DynamicQueryView to wrap the ForEach View.

struct ContentView: View {
    
    @Environment(\.modelContext) var modelContext
    
    @State private var startDate: Date = .distantPast
    @State private var endDate: Date = .distantFuture
    
    private var descriptor: FetchDescriptor<Item> {
        let predicate = #Predicate<Item> {
            $0.date >= startDate && $0.date <= endDate
        }
        return FetchDescriptor<Item>(
            predicate: predicate,
            sortBy: [SortDescriptor(\.date)]
        )
    }
    
    var body: some View {
        List {
            DynamicQueryView(descriptor) { items in
                ForEach(items) {
                    Text($0.title)
                }
            }
        }
    }
    
}

And that’s it! Everything is working as expected now.

Conclusion

I hope Apple will continue developing SwiftData, but for now, we need some workarounds to make it work in specific cases.