WWDC Quick Look 💓 By SwiftGGTeam
What's new in SwiftData

What's new in SwiftData

Watch original video

Highlight

SwiftData adds three capabilities: @Query can group by property with sectionBy, @Attribute(.codable) can persist third-party framework types, and ModelResultsObserver plus HistoryObserver let non-SwiftUI code observe data changes.

Core Content

Grouped Lists No Longer Need Hand-Written Dictionaries

Anyone who has built a grouped list has gone through this flow: fetch all data from SwiftData, manually group it into a Dictionary by some property, then convert the dictionary into Section values inside a List. The code is not short, and every data change forces you to recompute the grouping.

At 00:36, Apple adds a sectionBy parameter to @Query. Pass in a KeyPath, and SwiftData groups the results at the query layer. The view only needs two ForEach loops: the outer loop iterates over sections, and the inner loop iterates over the data inside each section.

Third-Party Types Finally Get an Escape Hatch

Want to store an MKMapItem.Identifier in your model? Previously SwiftData would throw a fatal error: “Class property within Persisted Struct/Enum is not supported.” Because MKMapItem.Identifier is a MapKit class, you cannot add @Model to its source, and SwiftData cannot inspect it.

At 04:52, the new @Attribute(.codable) lets SwiftData delegate serialization to the type itself. As long as the type conforms to Codable, SwiftData stores its encoded result instead of trying to generate a schema.

There is a cost: Codable properties are opaque to SwiftData. They cannot be used in Predicate filters or sorting. Structural changes to the type also do not trigger migration. Apple explicitly calls this an “escape hatch” for third-party types you do not control. For models you own, you should still use @Model or value types.

Data Observation Leaves SwiftUI

@Query can only be used in SwiftUI views. If your app has an independent state object that needs to fetch data from SwiftData and respond to changes, you previously needed a workaround: hide an invisible SwiftUI view, or manually listen through NotificationCenter.

At 07:21, ModelResultsObserver extracts the capability behind @Query. It supports filtering, sorting, and grouping. Combined with withContinuousObservation from the Swift Observation framework, any Swift code can receive notifications when data changes.

At 09:30, HistoryObserver goes further. Every SwiftData save records a history transaction, including what changed, who changed it, and a unique token. HistoryObserver observes these transactions and is useful for server sync or responding to app extension changes. You can filter by author to avoid uploading changes that were just pulled down from the server.

Details

Sectioned Fetching

At 00:01, the sectionBy parameter of @Query accepts a KeyPath and groups query results by that property’s value. The wrapped value is still [Trip], but you can access the underlying Query object with the underscore-prefixed property and retrieve its sections collection.

struct TripListView: View {
    @Query(sort: \Trip.startDate, sectionBy: \.destination)
    var trips: [Trip]

    var body: some View {
        List(selection: $selection) {
            ForEach(_trips.sections) { section in
                Section(section.id) {
                    ForEach(section) { trip in
                        TripListItem(trip: trip)
                    }
                }
            }
        }
    }
}

Key points:

  • sectionBy: \.destination tells SwiftData to group by the destination property
  • _trips is the underlying property-wrapper instance; use it to access sections
  • Each section’s id is the grouping-key value, used here as the Section header
  • The inner ForEach iterates over section itself, which is a RandomAccessCollection

Codable Attributes

At 04:59, @Attribute(.codable) hands persistence to the type’s Codable implementation. SwiftData no longer generates a schema for this kind of property; it stores the encoded data instead.

import SwiftData

@Model class Trip {
    struct Location: Codable {
        var latitude: Double
        var longitude: Double
    }

    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    var location: Location?

    @Attribute(.codable) var mapItemIdentifier: MKMapItem.Identifier?
}

Key points:

  • MKMapItem.Identifier is a MapKit class, and SwiftData cannot inspect it automatically
  • It conforms to Codable, so it can be marked with the .codable attribute
  • SwiftData stores encoded binary or JSON data, and the internal structure is opaque to it
  • It cannot be used in #Predicate filters or SortDescriptor sorting
  • Structural changes to the type do not trigger automatic migration, so your encoding logic must remain backward compatible

ModelResultsObserver

At 08:20, ModelResultsObserver provides @Query-equivalent capabilities in non-SwiftUI code: query data and continuously observe changes.

@Observable @MainActor final class MapCameraController {
    private let observer: ModelResultsObserver<Trip>
    var bounds: MapCameraBounds?
    private var token: ObservationTracking.Token?

    init(modelContext: ModelContext) throws {
        observer = try ModelResultsObserver<Trip>(modelContext: modelContext)

        token = withContinuousObservation(options: [.didSet]) { [weak self] _ in
            self?.bounds = self?.calculateBounds(trips: observer.results)
        }
    }

    private func calculateBounds(trips: [Trip]) -> MapCameraBounds? { /* ... */ }
}

Key points:

  • ModelResultsObserver needs a ModelContext at initialization
  • It supports predicate, sort, and sectionBy, with an API consistent with @Query
  • The token returned by withContinuousObservation must be strongly referenced, or observation stops immediately
  • The closure needs to read observer.results; the Observation framework tracks dependencies through property access
  • [weak self] avoids retain cycles

HistoryObserver

At 08:21, HistoryObserver observes SwiftData persistent history transactions, which is useful for server sync scenarios.

@SyncActor final class ServerSync {
    private let observer: HistoryObserver
    private var token: ObservationTracking.Token?

    func start() throws {
        observer = try HistoryObserver(authors: ["App"], modelContainer: modelContainer)
        token = withContinuousObservation(options: .didSet) { [weak self] _ in
            _ = self?.observer.eventCounter
            self?.processChanges()
        }
    }

    private func processChanges() {
        // Use ModelContext.fetchHistory to get transactions and upload them to the server.
    }
}

Key points:

  • The authors parameter filters transaction sources, preventing server-synced changes from being uploaded again
  • eventCounter is the only observable property and increments when a new transaction arrives
  • The closure must access eventCounter so the Observation framework can establish the dependency
  • During actual processing, call ModelContext.fetchHistory to get transaction details
  • Each transaction has a unique token, which can be used to track processed changes

Key Takeaways

1. Fully separate the data layer from the view layer

In MVVM architectures, ViewModels previously had to work around SwiftData if they wanted to observe changes. Now ModelResultsObserver lets a ViewModel own its own data observation, while the View only renders. What to do: refactor existing projects and move @Query out of the ViewModel. Why it is worth doing: the coupling between View and data is reduced, and tests become easier. How to start: replace @Query with ModelResultsObserver, then use withContinuousObservation to receive changes and notify the View through @Observable.

2. Build a real-time collaborative notes app

HistoryObserver’s authors filtering and transaction tokens are naturally suited to multi-device sync. What to do: mark locally edited transactions as "App", mark server-pushed transactions as "Server", and have HistoryObserver observe only "App" changes for upload. Why it is worth doing: you do not need to implement change tracking yourself; SwiftData already records it. How to start: pass authors: ["App"] when initializing HistoryObserver, then call fetchHistory inside processChanges to get the transaction list.

3. Use SwiftData for progress storage in SpriteKit or SceneKit games

Games often do not use SwiftUI, so SwiftData’s real-time observation capability was previously hard to use. What to do: use ModelResultsObserver to observe player data changes and dynamically adjust game state. Why it is worth doing: the game architecture does not need to force in SwiftUI just to use SwiftData. How to start: hold a ModelResultsObserver<PlayerData> inside the game’s GameManager and update in-game variables when data changes.

4. Build schedule or todo lists grouped by date

sectionBy makes grouping by date trivial. What to do: show events in a schedule app grouped by day. Why it is worth doing: what used to require manual Calendar grouping is now a single sectionBy line. How to start: @Query(sort: \.startDate, sectionBy: \.dateString), where dateString is a computed property that returns a formatted date string.

5. Store complex configuration objects directly in your model

Some third-party SDK configuration types conform to Codable but have complex structure. What to do: store them directly in SwiftData with @Attribute(.codable), without manually splitting them into primitive fields. Why it is worth doing: it removes conversion code and keeps the model cleaner. How to start: confirm the type conforms to Codable, then mark it with @Attribute(.codable).

Comments

GitHub Issues · utterances