One. Course Details
This is the fourteenth lecture of Stanford University's CS193p iOS Application Development course for Spring 2025, taught by Paul Hegarty. The entire lecture is a hands-on demo building on the SwiftData model conversion completed in Lecture Thirteen.
The lecture covers fixing SwiftData preview crashes, replacing local state with database-backed queries, performing insert and delete operations through ModelContext, resolving the unordered relationship array problem, and implementing dynamic sorting and real-time search functionality. By the end of the lecture, the CodeBreaker application fully supports persistent storage, with all game data and moves saved between app launches.
Two. Key Learning Takeaways
SwiftData preview configuration: Create a custom PreviewModifier to provide an in-memory ModelContainer for all previews, eliminating crashes when working with @Model objects in previews.@Query automatic synchronization: Use @Query to replace local @State arrays, making the database the single source of truth. Results automatically update when the database changes and trigger SwiftUI view refreshes.
ModelContext operations: Access the database context through the environment to perform insert and delete operations. SwiftData's default autosave mechanism persists changes when the app enters the background.
Relationship array ordering: Add timestamp properties to related models and use computed properties to automatically sort arrays, solving the inherent unordered nature of SwiftData relationship arrays.
Dynamic query construction: Construct @Query instances in a view's initializer to support user-driven sorting and filtering. This is the only way to create dynamic queries with variable parameters.
Real-time search: Use the .searchable() modifier to add standard search UI, combined with #Predicate to implement case-insensitive filtering of database results.
Efficient counting: Use fetchCount() instead of fetch() to count matching objects, avoiding unnecessary data transfer from the database.
Three. Course Gold Quotes
"The database is now going to be the source of truth. No longer am I, this View, going to own the source of truth." "That one line of code is probably one of the biggest lines of code in this whole thing in terms of the amount of work it's doing." "Arrays of @Model objects are unordered. That is absolutely no good for us." "fetchCount() avoids even loading the data because it's doing a SQL operation that returns an Int." "The schema is the description of the types of all the variables in the database and the structure of the tables." "Convenience initializers let you add UI-specific initialization without polluting your core Model." "Your Model should be UI-independent. It should know nothing about Colors or SwiftUI."Four. Layered Learning Notes
Module 1: SwiftData Preview Configuration
-
Problem: Xcode previews cannot access the
ModelContainerconfigured in the main app, causing crashes when creating or using@Modelobjects. -
Solution: Create a reusable
PreviewModifierthat provides an in-memory database for previews:-
Implement the
PreviewModifierprotocol with amakeSharedContext()method that creates aModelConfiguration(isStoredInMemoryOnly: true) -
In the
body(content:)method, apply.modelContainer(context)to the preview content -
Extend
PreviewTraitto add a static.swiftDataproperty for easy use
-
-
Usage: Add
#Preview(traits: .swiftData)to any preview that needs SwiftData access. This approach works for all previews in the application.
Module 2: Database as the Single Source of Truth
-
Replace local state: Convert
@State var games: [CodeBreaker]to@Query var games: [CodeBreaker] -
@Querycapabilities:-
Automatically executes the query when the view appears
-
Continuously monitors the database for changes and updates the results array
-
Triggers SwiftUI view updates whenever the results change
-
Results are read-only; all modifications must be performed through the
ModelContext
-
-
Basic query configuration:
swift@Query(sort: \CodeBreaker.name, order: .forward) private var games: [CodeBreaker] -
Access the database context:
swift@Environment(\.modelContext) private var modelContext
Module 3: Converting Data Operations to SwiftData
-
Delete operation:
swift// Before: games.remove(at: offset) modelContext.delete(games[offset]) -
Insert operation:
swift// Before: games.append(newGame) modelContext.insert(newGame) -
Edit operation: Delete the original object and insert the edited copy. This works correctly with SwiftData's reference semantics.
-
Autosave behavior:
-
SwiftData automatically saves changes when the app enters the background
-
Switching to the app switcher triggers an immediate save
-
Explicit saves are rarely needed but can be performed with
try modelContext.save()
-
-
Development tip: Delete the app from the simulator or device to clear the database and reset all data, especially after schema changes.
Module 4: Fixing Unordered Relationship Arrays
-
Problem: Arrays of
@Modelobjects (likeattempts: [Code]) represent database joins and return results in undefined order. -
Solution:
-
Add a timestamp property to the related model:
swiftvar timestamp = Date.now -
Rename the original array to
_attempts -
Add a computed property that automatically sorts the array:
swiftvar attempts: [Code] { get { _attempts.sorted { $0.timestamp > $1.timestamp } } set { _attempts = newValue } }
-
-
Important note: Adding a new property to an
@Modelchanges the database schema. Existing data will be lost unless you implement schema migration.
Module 5: Implementing Dynamic Sorting
-
Step 1: Define a sorting options enum:
swiftenum SortOption: String, CaseIterable { case name, recent var title: String { switch self { case .name: return "Sort by Name" case .recent: return "Recent" } } } -
Step 2: Add sorting UI using a segmented picker:
swift@State private var sortOption: SortOption = .name Picker("Sort By", selection: $sortOption.animation()) { ForEach(SortOption.allCases, id: \.self) { option in Text(option.title) } } .pickerStyle(.segmented) .padding(.horizontal) -
Step 3: Construct dynamic queries in the view's initializer:
swiftinit(sortBy: SortOption) { switch sortBy { case .name: _games = Query(sort: \CodeBreaker.name, order: .forward) case .recent: _games = Query(sort: \CodeBreaker.lastAttemptDate, order: .reverse) } } -
Step 4: Add a
lastAttemptDateproperty to theCodeBreakermodel and update it whenever a new attempt is made.
Module 6: Implementing Real-Time Search
-
Step 1: Add search state and UI:
swift@State private var searchString = "" .searchable(text: $searchString) -
Step 2: Pass the search string to the view containing the
@Query -
Step 3: Construct a dynamic predicate in the initializer:
swiftinit(sortBy: SortOption, search: String) { let predicate = #Predicate<CodeBreaker> { game in search.isEmpty || game.name.contains(search.lowercased()) || game.name.contains(search.capitalized) } switch sortBy { case .name: _games = Query(filter: predicate, sort: \CodeBreaker.name, order: .forward) case .recent: _games = Query(filter: predicate, sort: \CodeBreaker.lastAttemptDate, order: .reverse) } } -
Note:
#Predicatestring comparisons are case-sensitive by default. The example above handles both lowercase and capitalized search terms.
Module 7: Manual Queries and Error Handling
-
Manual fetch:
swiftlet descriptor = FetchDescriptor<CodeBreaker>() if let results = try? modelContext.fetch(descriptor) { // Use results } -
Count query (most efficient for checking if the database is empty):
swiftlet count = try? modelContext.fetchCount(FetchDescriptor()) -
Error handling options:
-
try!: Crashes on failure, use only during development when failure is impossible -
try?: Returns an optional, returns nil on failure, suitable for most cases -
do-catch: Full error handling, use when you need to recover from errors or show them to the user
-
Need me to prepare a complete SwiftData implementation template with preview setup, dynamic sorting, and search functionality that you can drop into your project?


