Exploring SwiftUI Property Wrappers: @FetchRequest, @SectionedFetchRequest, @Query, @Namespace, @Bindable

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In this article, we will explore property wrappers such as @FetchRequest, @SectionedFetchRequest, @Query, @Namespace, and @Bindable. These property wrappers encompass functionalities such as retrieving Core Data and SwiftData within views and creating namespaces in views.

The aim of this article is to provide an overview of the main functionalities and usage considerations of these property wrappers, rather than an exhaustive guide.

1. @FetchRequest

In SwiftUI, @FetchRequest is used for retrieving Core Data entity data within views. It simplifies the process of fetching data from persistent storage and automatically updates the view when the data changes.

1.1 Basic Usage

Swift
@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
    predicate: nil,
    animation: .default)
private var items: FetchedResults<Item>

List {
    ForEach(items) { item in
        if let timestamp = item.timestamp {
            NavigationLink {
                Text("Item at \(timestamp, format: .dateTime)")
            } label: {
                Text(timestamp, format: .dateTime)
            }
        }
    }
}

1.2 Main Functions

  • @FetchRequest simplifies the process of retrieving data from Core Data, allowing developers to define entity types, sort descriptors, and predicates to filter results.
  • @FetchRequest automatically updates its fetched results upon any changes in Core Data, ensuring real-time synchronization between the view and the data.
  • Closely integrated with SwiftUI’s declarative programming model, @FetchRequest offers a more natural and smooth way to integrate data-driven interfaces into applications.

1.3 Considerations and Tips

  • Before using @FetchRequest, ensure that the managed object context has been injected into the current view environment.
  • The following example demonstrates how to set filtering and sorting conditions in the constructor based on specific parameters:
Swift
@FetchRequest
private var items: FetchedResults<Item>
init(startDate:Date,endDate:Date) {
    let predicate = NSPredicate(format: "timestamp >= %@ AND timestamp <= %@", startDate as CVarArg,endDate as CVarArg)
    _items = FetchRequest(
        entity: Item.entity(),
        sortDescriptors: [.init(key: #keyPath(Item.timestamp), ascending: true)],
        predicate: predicate
    )
}
  • Starting from iOS 15, developers can dynamically adjust the filtering and sorting conditions of @FetchRequest within a view. However, it is important to note that this method is not applicable within .onAppear and .task. It is typically used to dynamically update the view based on user actions.
Swift
Button("abc") {
    $items.nsPredicate.wrappedValue = .init(value: false)
    $items.sortDescriptors.wrappedValue = [.init(\Item.timestamp, order: .reverse)]
}
  • For more complex configurations, it is recommended to create @FetchRequest using an instance of NSFetchRequest:
Swift
extension Item {
    static let noFaultRequest: NSFetchRequest = {
        let request = NSFetchRequest<Item>(entityName: "Item")
        request.predicate = nil
        request.sortDescriptors = [.init(keyPath: \Item.timestamp, ascending: true)]
        request.returnsObjectsAsFaults = false
        return request
    }()
}

@FetchRequest(fetchRequest: Item.noFaultRequest)
private var items: FetchedResults<Item>
  • The animation parameter in @FetchRequest determines the animation effect for the interface update when data changes. Both .none and nil indicate no animation.
  • When displaying data in a List, the List ignores the animation effect set in @FetchRequest and uses the default animation of the component.
  • Developers can override the animation effect set in @FetchRequest with the withAnimation function, as shown below:
Swift
withAnimation(.none) {
    let newItem = Item(context: viewContext)
    newItem.timestamp = Date()

    do {
        try viewContext.save()
    } catch {
    }
}
  • @FetchRequest is SwiftUI’s encapsulation of NSFetchedResultsController, with its main functions and performance largely consistent with the latter.
  • Similar to NSFetchedResultsController, @FetchRequest only supports returning collections of NSManagedObject type and does not support other NSFetchRequestResult types (such as numbers, NSManagedObjectID, dictionaries).
  • The lifecycle of @FetchRequest is closely tied to the lifespan of the view. It begins fetching data when the view is loaded into the view hierarchy and stops when the view is removed.
  • Compared to fetching Core Data entity data in a ViewModel, @FetchRequest is more closely bound to the view’s lifecycle, with virtually no delay in initial loading.
  • After the initial data fetch, @FetchRequest (NSFetchedResultsController) updates the data set based on the merge information in the managed object context. Therefore, settings based on fetchLimit may not always be effective in subsequent data changes.
  • For more information on the working principle of @FetchRequest, see SwiftUI and Core Data — Data Fetching.
  • FetchedResults is an encapsulation of NSFetchRequestResult, following the RandomAccessCollection protocol, allowing data access through subscripts.
  • FetchedResults provides a publisher property, which sends the entire data set once the result set data changes. Therefore, it is not recommended to subscribe to this property to monitor dataset changes, in order to avoid redundant operations.
  • In SwiftUI development, it is recommended to encapsulate interfaces displaying to-Many data into separate views and fetch data through @FetchRequest. This approach not only ensures the stability of the data fetching order but also responds promptly to data changes and makes view updates more efficient. For more details, please refer to Mastering Relationships in Core Data: Practical Application.

2. @SectionedFetchRequest

In SwiftUI, the @SectionedFetchRequest property wrapper offers a convenient way to handle and display Core Data query results that are grouped according to specific criteria. This enables developers to present complex data structures in a grouped format within the user interface.

2.1 Basic Usage

Below is a basic usage example of @SectionedFetchRequest:

image-20240122162612483

Swift
@SectionedFetchRequest(
    entity: Item.entity(),
    sectionIdentifier: \Item.categoryID,
    sortDescriptors: [
        .init(keyPath: \Item.categoryID, ascending: true),
        .init(keyPath: \Item.timestamp, ascending: true),
    ]
)
var sectionItems: SectionedFetchResults<Int16, Item>

ForEach(sectionItems) { section in
    Section(header: Text("\(section.id)")) {
        ForEach(section) { item in
            Row(item: item)
        }
    }
}

This code demonstrates how to create a grouped SectionedFetchRequest based on the categoryID attribute of the Item entity, and sorts it according to predefined rules.

SectionedFetchResults is a structure with two generic parameters. The first parameter defines the type of attribute used for grouping identification (section identifier), while the second parameter specifies the type of the managed object entity.

2.2 Main Functions

  • @SectionedFetchRequest allows developers to group query results based on specific attributes, enabling the presentation of these results in a grouped format within SwiftUI views.
  • Besides supporting grouping, its other characteristics are essentially similar to @FetchRequest.

2.3 Considerations and Tips

  • Most considerations and tips applicable to @FetchRequest also apply to @SectionedFetchRequest.
  • When using @SectionedFetchRequest, it is necessary to specify an attribute for grouping. This attribute type should be clearly suitable for segmentation, such as String or Int type.
  • To ensure the accuracy of the group order, when constructing sortDescriptors, the attribute used for sectionIdentifier should be the primary sorting option:
Swift
sortDescriptors: [
    .init(keyPath: \Item.categoryID, ascending: true),
    .init(keyPath: \Item.timestamp, ascending: true),
]
  • For attribute types that are difficult to group precisely, it is recommended to create a specific attribute for the entity for categorization. For example, you can add an Int16 type year attribute to timestamp to facilitate grouping by year.
  • In SwiftUI, nested ForEach can affect the performance optimization of lazy containers. Adding Section can prevent the recursive expansion of ForEach and improve performance. For more details, refer to Demystify SwiftUI performance. For example, in the following code, if Section is removed, SwiftUI will build child views for each data item in the result set all at once:
Swift
ForEach(sectionItems) { section in
    // Section(header: Text("\(section.id)")) {
        ForEach(section) { item in
            Row(item: item)
        }
    // }
}

3. @Query

In SwiftUI, @Query is used to retrieve SwiftData entity data within views. It simplifies the process of fetching data from persistent storage and automatically updates the view when data changes.

3.1 Basic Usage

Swift
@Query(sort: \Item.timestamp, animation: .default)
private var items: [Item]

3.2 Considerations and Tips

  • @Query can be seen as the SwiftData environment’s equivalent to @FetchRequest. However, unlike @FetchRequest, @Query does not support dynamically modifying query predicates and sorting conditions within the view.
  • The comparison between @FetchRequest and @Query is as follows:
    • NSFetchRequest in Core Data corresponds to FetchDescriptor in SwiftData.
    • NSSortDescriptor corresponds to SortDescriptor (which can also be used with @FetchRequest).
    • NSPredicate corresponds to Predicate in the Foundation framework.
    • In terms of parameters, the predicate in @FetchRequest corresponds to the filter in @Query.
  • In the @Predicate macro, direct calls to external methods, functions, or computed properties are not possible. The values should be computed outside the macro and then used as predicate conditions:
Swift
// Example 1: Incorrect approach
@Query
private var items: [Item]

init() {
    let predicate = #Predicate<Item>{
        $0.timestamp < Date.now // Cannot compile
    }
    _items = Query(
        filter: predicate,
        sort:\Item.timestamp,
        order: .forward,
        animation: .default
    )
}

// Example 1: Correct approach
let now = Date.now
let predicate = #Predicate<Item>{
    $0.timestamp < now
}

// Example 2: Incorrect approach
init(item: Item) {
    let predicate = #Predicate<Item>{
        $0.timestamp < item.timestamp // Cannot compile
    }
    _items = Query(
        filter: predicate,
        sort:\Item.timestamp,
        order: .forward,
        animation: .default
    )
}

// Example 2: Correct approach
init(item: Item) {
    let startDate = item.timestamp
    let predicate = #Predicate<Item>{
        $0.timestamp < startDate
    }
}

The three property wrappers introduced above perform data filtering, sorting, and grouping operations at the SQLite end. This approach is more efficient and uses fewer system resources compared to using high-order functions in Swift for the same operations in memory.

4. @Namespace

The @Namespace property wrapper is used to create a unique identifier, allowing for effective grouping and differentiation of views or elements.

4.1 Basic Usage

Swift
@Namespace private var namespace

4.2 Main Functions

  • Each @Namespace property wrapper creates a unique identifier, which remains constant throughout its lifecycle after creation.
  • @Namespace is often combined with other id information to annotate views. This method allows for adding more identifiable information to views without changing their id.

4.3 Considerations and Tips

  • After creating a @Namespace identifier, you can pass it to other views for use:
Swift
struct ParentView: View {
    @Namespace var namespace
    let id = "1"
    var body: some View {
        VStack {
            Rectangle().frame(width: 200, height: 200)
                .matchedGeometryEffect(id: id, in: namespace)
            SubView3(namespace: namespace, id: id)
        }
    }
}

struct SubView: View {
    let namespace: Namespace.ID
    let id: String
    var body: some View {
        Rectangle()
            .matchedGeometryEffect(id: id, in: namespace, properties: .size, isSource: false)
    }
}
  • Although developers often use @Namespace in conjunction with matchedGeometryEffect, it is important to understand that @Namespace solely plays the role of an identifier and does not directly participate in the actual implementation of geometric information processing or animation transitions.

  • @Namespace is not limited to use with matchedGeometryEffect but can also be used with other elements or view modifiers, such as accessibilityRotorEntry, AccessibilityRotorEntry, accessibilityLinkedGroup, prefersDefaultFocus, and defaultFocus.

  • In scenarios using @Namespace, a pattern often emerges where views are marked and view information is read in pairs:

Swift
// Example 1:
Rectangle().frame(width: 200, height: 200)
     .matchedGeometryEffect(id: id, in: namespace) // Marking the view
Rectangle()
     .matchedGeometryEffect(id: id, in: namespace, properties: .size, isSource: false) // Reading the geometric information of the view with specific namespace + id


// Example 2:
VStack {
    TextField("email", text: $email)
        .prefersDefaultFocus(true, in: namespace) // Marking the view that gets default focus in the namespace

    SecureField("password", text: $password)

    Button("login") {
      ...
    }
}
.focusScope(namespace) // Reading the information of the view with default focus in the namespace and setting the focus on it
  • It is permissible to apply multiple different namespace + id combinations to the same view. For example, in the code below, we used the same id but different namespaces to annotate the same TrendView. This approach provides independent identifiers for two different accessibility rotors:
Swift
struct TrendsView: View {
    let trends: [Trend]

    @Namespace private var customRotorNamespace
    @Namespace private var countSpace

    var body: some View {
        VStack {
            ScrollViewReader { scrollView in
                List {
                    ForEach(trends, id: \.id) { trend in
                        TrendView(trend: trend)
                            .accessibilityRotorEntry(id: trend.id, in: customRotorNamespace) // Identifier 1
                            .accessibilityRotorEntry(id: trend.id, in: countSpace) // Identifier 2
                            .id(trend.id)
                    }
                }
                .accessibilityRotor("Negative trends") {
                    ForEach(trends, id: \.id) { trend in
                        if !trend.isPositive {
                            AccessibilityRotorEntry(trend.message, trend.id, in: customRotorNamespace) {
                                scrollView.scrollTo(trend.id)
                            }
                        }
                    }
                }
                .accessibilityRotor("Count"){
                    ForEach(trends, id: \.id) { trend in
                        if trend.count % 2 == 0 {
                            AccessibilityRotorEntry("\(trend.count)", trend.id, in: countSpace) {
                                scrollView.scrollTo(trend.id)
                            }
                        }
                    }
                }
            }
        }

    }
}

For more information about AccessibilityRotorEntry, refer to Accessibility rotors in SwiftUI.

  • When using ForEach, we typically use the identifier provided by ForEach as the id, and by combining different namespaces, we provide multiple distinct identifiers for the same view or element. As shown in the example above.

  • At any given time, there should only be one unique namespace + id combination. For instance, in the code below, if run, a warning will be generated because there are multiple views using the same namespace + id combination:

Swift
struct AView: View {
    @Namespace var namespace
    var body: some View {
        VStack {
            Rectangle()
                .matchedGeometryEffect(id: "111", in: namespace)
            Rectangle()
                .matchedGeometryEffect(id: "111", in: namespace)
        }
    }
}

// Warning: Multiple inserted views in matched geometry group Pair<String, ID>(first: "111", second: SwiftUI.Namespace.ID(id: 88)) have `isSource: true`, results are undefined.
  • When used in conjunction with matchedGeometryEffect, multiple views can share the geometric information of a marked view. In the following code, the two lower Rectangles will overlap with the position of the first Rectangle, as they share the position information of the view marked with namespace + "111":
Swift
struct AView: View {
    @Namespace var namespace
    var body: some View {
        VStack {
            Rectangle()
                .matchedGeometryEffect(id: "111", in: namespace)
            Rectangle().fill(.red)
                .matchedGeometryEffect(id: "111", in: namespace, properties: .position, isSource: false)
            Rectangle().fill(.blue)
                .matchedGeometryEffect(id: "111", in: namespace, properties: .position, isSource: false)
        }
    }
}
  • When using matchedGeometryEffect in modal views, if the correct geometric information cannot be obtained, this is usually due to a known issue with SwiftUI, not a problem with @Namespace. A solution is to place the view inside a navigation container. This ensures correct geometric information retrieval in modal views (such as sheet or fullscreenCover).
Swift
// Issue example: Unable to obtain geometric information
struct NaviTest: View {
    @Namespace var namespace

    @State var show = false
    var body: some View {
        VStack {
            Button("Show") {
                show.toggle()
            }
            .sheet(isPresented: $show) {
                VStack {
                    Rectangle()
                        .fill(.cyan)
                        .matchedGeometryEffect(id: "1", in: namespace, properties: .size, isSource: false)
                }
                .frame(width: 300, height: 300)
            }
            Rectangle().fill(.orange).frame(width: 100, height: 200).matchedGeometryEffect(id: "1", in: namespace)
        }
    }
}

// Solution: Correct geometric information retrieval within a navigation container
NavigationStack {
    VStack {
        Button("Show") {
            show.toggle()
        }
        .sheet(isPresented: $show) {
            VStack {
                Rectangle()
                    .fill(.cyan)
                    .matchedGeometryEffect(id: "1", in: namespace, properties: .size, isSource: false)
            }
            .frame(width: 300, height: 300)
        }
        Rectangle().fill(.orange).frame(width: 100, height: 200).matchedGeometryEffect(id: "1", in: namespace)
    }
}

To learn more about the details of matchedGeometryEffect, refer to MatchedGeometryEffect – Part 1 (Hero Animations).

5. @Bindable

The @Bindable property wrapper provides a convenient and efficient way to create binding (Binding) for mutable properties of observable (Observable) object instances.

5.1 Basic Usage

Swift
@Observable
class People {
    var name: String
    var age: Int
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

struct PeopleView: View {
    @State var people = People(name: "fat", age: 18)
    var body: some View {
        VStack {
            Text("\(people.name) \(people.age)")
            PeopleName(people: people)
            PeopleAge(people: people)
        }
    }
}

struct PeopleName: View {
    @Bindable var people: People // Usage 1
    var body: some View {
        TextField("Name", text: $people.name)
    }
}

struct PeopleAge: View {
    let people: People
    var body: some View {
        @Bindable var people = people // Usage 2
        TextField("Age:", value: $people.age, format: .number)
    }
}

5.2 Considerations and Tips

  • @Bindable is specifically used for types conforming to the Observation.Observable protocol, applicable to those declared via the @Observable or @Model macro.
  • Currently, special care is needed when applying @Bindable to instances of SwiftData’s PersistentModel, especially when the autoSave feature is activated, as it may lead to stability issues. It is expected that this issue will be resolved and improved in future updates.

Conclusion

To this point, we have introduced 16 different property wrappers in SwiftUI. Another article will explore the functionalities of the remaining property wrappers, so stay tuned.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed