Implementing a SwiftData Query View as the most convenient way to fetch data in SwiftUI

Ihor Malovanyi
4 min readJan 28, 2024

Many will find my opinion controversial, but I consider that SwiftUI views cannot be perceived as real UI views. SwiftUI views are models that describe the real UI still based on UIKit.

Based on that opinion, we have all rights to design services based on the View protocol to integrate them smoothly into the UI flow.

Today, I’ll show you how to create SwiftUI SwiftData Query service integrated SwiftUI flow and expanded a lot compared to just a Query macro.

If you like my content and want to support me, please buy me a coffee and subscribe!

Also, I have the Telegram channel Swift in UA, where you can find more exciting things.

Creating a QueryView

The first steps are so close to a general Query usage:

  • Create a View
  • Add a query
  • Add type marker
  • Add content ViewBuilder that accepts a Model array and returns some View
struct QueryView<Model: PersistentModel, Content: View>: View {

@Query private var query: [Model]
var type: Model.Type
@ViewBuilder var content: ([Model]) -> (Content)

var body: some View {
content(query)
}

}

The code is straightforward, and so is its usage:

But what benefits does this code provide compared to the standard Query macro way? Radical! But at the moment, it is not so obvious.

The Query’s biggest problem

It’s impossible to use stored properties in the Query initializer. So, it means that Qurey can’t be updated dynamically. No filter changes, no sorting changes.

And, it’s impossible to re-assign the query on the example search word update.

No way. Query macro can be initialized once and that’s all… Query is not a typical view state… Wait!

Initializing the QueryView

SwiftUI View updates when the related state changes! It’s possible to move Query initialization to the QueryView initialization with all properties that need to set up the Query!

struct QueryView<Model: PersistentModel, Content: View>: View {

@Query private var query: [Model]
private var content: ([Model]) -> (Content)

init(for type: Model.Type,
sort: [SortDescriptor<Model>] = [],
@ViewBuilder content: @escaping ([Model]) -> Content,
filter: (() -> (Predicate<Model>))? = nil) {
_query = Query(filter: filter?(), sort: sort)
self.content = content
}

var body: some View {
content(query)
}

}

Now, the filter and sort are initializer parameters of QueryView, which means they can be set up externally. And if the external parameters are View state parameters (State, Binding, AppStorage, etc…), they will trigger the QueryView update on change! Let’s see:

That’s it! Now, the QueryView is ready and works well! It is fully integrated with SwiftUI flow and is much more understandable and flexible.

But there’s one more thing to prepare the QueryView for most cases.

Sectioning the Query View

If there’s a lot of data in the list, separating the data by some key attribute is convenient. For example, using a sorted list of keys and transforming the array into a dictionary:

let people = [p1, p2, p3, ...., pn]
let grouped = Dictionary(grouping: people, by: \.city)

To add logic to the QueryView, one more type is needed — Hashable Key. Then add the keyExtractor property. The property has the same signature as in the Dictionary:

struct SectionedQueryView<Content: View, Model: PersistentModel, Key: Hashable>: View {

@Query private var query: [Model]
private var content: ([QueryViewDataSection<Key, Model>]) -> Content
private var keyExtractor: ((Model) -> Key)

init(for type: Model.Type,
sectionedBy keyExtractor: @escaping ((Model) -> Key),
sort: [SortDescriptor<Model>] = [],
@ViewBuilder content: @escaping ([QueryViewDataSection<Key, Model>]) -> Content,
filter: (() -> (Predicate<Model>))? = nil) {
_query = Query(filter: filter?(), sort: sort)
self.content = content
self.keyExtractor = keyExtractor
}

var body: some View {
let data = Dictionary(grouping: query, by: keyExtractor)
let result = keys.reduce([QueryViewDataSection]()) { partialResult, key in
partialResult + [.init(key: key, models: data[key] ?? [])]
}
content(result)
}

private var keys: [Key] {
var seen: Set<Key> = []
var result: [Key] = []

for model in query {
let key = keyExtractor(model)
if !seen.contains(key) {
seen.insert(key)
result.append(key)
}
}

return result
}

}

struct QueryViewDataSection<Key: Hashable, Model: PersistentModel>: Identifiable {

let key: Key
let models: [Model]
let id = UUID()

}

Now, this is a SectionedQueryView! The main differences from QueryView are:

  • Hashable Key
  • keyExtractor property
  • Updated content closure — now it accepts an array of sections instead of an array of models
  • keys computed property. The keys sort in the same order that the Query sorts
  • QueryViewDataSection to handle the key and related models

Let’s see how it looks!

--

--