Data Persistence with SwiftData

Mar 19 2025 · Swift 5.10, iOS 17, ipadOS 17, macOS 15, visionOS 1.2, Xcode 15

Lesson 02: SwiftData & SwiftUI Integration

Sort Filter Demo

Episode complete

Play next episode

Next
Transcript

You can start with the app you started building in the first lesson or you can start with the app in the Starter folder for this lesson. There are a few new files that have been added. There is a new BreedModel, ParkModel and the files that you’ll build on to list, create and edit new records. Let’s take a look:

// BreedModel
import Foundation

class BreedModel {
  var name: String
  var dogs: [DogModel]?

  init(name: String) {
    self.name = name
  }
}

// ParkModel
import Foundation

class ParkModel {
  var name: String
  var dogs: [DogModel]?

  init(name: String, dogs: [DogModel]? = nil) {
    self.name = name
    self.dogs = dogs
  }
}

These models are not yet set up for SwiftData. You’ll update them soon.

Sorting the Dogs

To begin, you’ll update the mock data in DogModel by adding a color value and a breed to the dogs. Update the DogModel extension that contains the preview data.

Pro Tip: if you select a long list of properties you can press Control-M to expand and put each value pair on it’s own line, making it easier to read and reason about.

Add some breed and color preview values to the DogModel’s extension.

// in the extension DogModel
container.mainContext.insert(
  DogModel(
    name: "Mac",
    age: 11,
    weight: 90,
    color: "Yellow",
    breed: "Labrador Retriever",
    image: nil
  )
)
container.mainContext.insert(
  DogModel(
    name: "Sorcha",
    age: 1,
    weight: 40,
    color: "Yellow",
    breed: "Golden Retriever",
    image: nil
  )
)
container.mainContext.insert(
  DogModel(
    name: "Violet",
    age: 4,
    weight: 85,
    color: "Gray",
    breed: "Bouvier",
    image: nil
  )
)
container.mainContext.insert(
  DogModel(
    name: "Kirby",
    age: 10,
    weight: 95,
    color: "Fox Red",
    breed: "Labrador Retriever",
    image: nil
  )
)
container.mainContext.insert(
  DogModel(
    name: "Priscilla",
    age: 17,
    weight: 65,
    color: "White",
    breed: "Mixed",
    image: nil
  )
)

Also move the dogs list from DogListView to it’s own view. Create a new SwiftUI view file named DogList. At the top import SwiftData. Inside the struct add an @Environment(\.modelContext) as you did in the previous lesson. While you are there add the @Query to fetch the dogs. Your file will look like this,

import SwiftUI
import SwiftData

struct DogList: View {
  @Environment(\.modelContext) private var modelContext
  @Query private var dogs: [DogModel]

  var body: some View {
    Text("Hello, World!")
  }
}

#Preview {
  DogList()
}

Pro Tip: You are going to be adding the modelContext many times during the course. You might look at creating a Code Snippet. In your code, select the code @Environment(\.modelContext) private var modelContext. The Right-Click and choose Create Code Snippet. In the dialog, give it a meaningful name like modelContext and give it a Completion like mc~. Later when you start to type mc the code completion will offer your snippet.

In the DogListView, select the List, including the .onDelete call. Cut it and paste it into the view in DogList, replacing the Text view.

List {
  ForEach(dogs) { dog in
    NavigationLink {
      EditDogView(dog: dog)
    } label: {
      HStack {
        Image(systemName: "dog")
          .imageScale(.large)
          .foregroundStyle(.tint)
        Text(dog.name)
      }
    }
  }
  .onDelete(perform: dogToDelete)
}

Next cut the func dogToDelete() from DogListView and paste it into DogList. This will fix the compiler warning. Now you’ll need to add DogList() where you cut the List in DogListView. All of the warnings should be gone now.

NavigationStack {
  DogList() // add the DogList here
.navigationTitle("Good Dogs")

Head back to DogList to fix the #Preview. Add the modelContainer with DogModel.preview.

#Preview {
  DogList()
    .modelContainer(DogModel.preview)
}

The Canvas previews should update and the app should still be able to function with the refresh icon. Press Command-B to build or press the Refresh button to start the preview.

Random Access

You might recall that SwiftData does not fetch the data in any order. You might also wonder if you need the @Query in the DogListView. Of course you don’t since the list of dogs is now coming from the DogList. Check this out, if you comment out the line with @Query in the DogListView and watch the preview, the dogs seem to change position. Try commenting and uncommenting and you’ll see that the dogs are listed in a random order. You might have an application for random dogs, but sorting them makes more sense in most cases.

Sort and Order

In the DogList file add sort with a keypath for the dog’s name, like the following.

@Query(sort: \DogModel.name) private var dogs: [DogModel]

When the preview updates, you’ll see that Kirby is first and Violet is last. The sort: is a quick way to do a basic alphabetical sort. You can also add order: .reverse to sort in reverse order. Try it out.

@Query(sort: \DogModel.name, order: .reverse) private var dogs: [DogModel]

The important thing to notice is that we add DogModel type in the keypath along with the property to sort on. You might recall that you can add the model type in angle brackets on the @Query. Like this for example.

// for example - defining model type in Query
@Query<DogModel>(sort: [
  SortDescriptor(\.name, order: .reverse)
  ])
  private var dogs: [DogModel]

SortDescriptors For Multi-Sort

Suppose you have a lot of dogs and you want to sort them with a few factors. For more complicated sorting you make use of an array of SortDescriptors.

Replace the simple sort: by adding a SortDescriptor in an array like this.

@Query(sort: [
  SortDescriptor(\DogModel.age, order: .reverse)
]) private var dogs: [DogModel]

Here you are sorting the dogs by age, oldest first. Update the UI to show the age in the list. In the DogList, select the Text(dog.name), open the contextual menu and Embed it in a VStack. You can Control-click to open the contextual menu. Below the dog.name line add the age like so.

Text("age: \(String(describing: dog.age ?? 0))")
  .font(.footnote)

Add a font modifier with .title2 and add leading alignment to the VStack. The VStack should look like this.

VStack(alignment: .leading) {
  Text(dog.name)
    .font(.title2)
  Text("age: \(String(describing: dog.age ?? 0))")
    .font(.footnote)
}

Add a second SortDescriptor for name.

@Query(sort: [
  SortDescriptor(\DogModel.age, order: .reverse),
  SortDescriptor(\DogModel.name)
]) private var dogs: [DogModel]

Now you are sorting by age and then by name. If you like, you can change the dog’s age in the mock data to test this. By the way, if you go back to DogListView you might notice that the sorting no longer uses plain @Query, if you left it there. The data is now sorted in the DogList’s modelContext where SwiftData is doing the work.

Sort Menu

It would be convenient to add a menu item to change the sorting. Create an enum for sort order. In the Project Navigator create a new Group named Enumerations. Add a new Swift file called SortOrder in the group. Add a String enum adopting Identifiable and CaseIterable, with age and name cases.

enum SortOrder: String, Identifiable, CaseIterable {
  case name, age
  var id: Self {
    self
  }
}

Initialize a sortOrder in DogList and move the SortDescriptors to it. The @Query will be simple again.

@Query private var dogs: [DogModel]

init(sortOrder: SortOrder) {
  let sortDescriptors: [SortDescriptor<DogModel>] = switch sortOrder {
  case .name:
    [SortDescriptor(\DogModel.name)]
  case .age:
    [SortDescriptor(\DogModel.age)]
  }
  _dogs = Query(sort: sortDescriptors)
}

Now the SortDescriptor can be changed based on the sortOrder case. But first you’ll need to fix the preview by adding a sort order. Use the name case.

#Preview {
  DogList(sortOrder: .name)
    .modelContainer(DogModel.preview)
}

The DogList() at the call site also needs a sort order value. In DogListView add a @State property for sort order defaulting with .name.

@State private var sortOrder = SortOrder.name

Add the value where DogList() is added.

DogList(sortOrder: sortOrder)

Now you can add a menu style picker inside the toolBar in DogListView. With this menu, you can switch between the sort order cases. Add this inside the current toolbar below the first ToolbarItem

ToolbarItem {
  Menu("Sort", systemImage: "arrow.up.arrow.down") {
    Picker("Sort Dogs", selection: $sortOrder) {
      ForEach(SortOrder.allCases) { sortOrder in
        Text("Sort By: \(String(describing: sortOrder))").tag(sortOrder)
      }
    }
    .buttonStyle(.bordered)
    .pickerStyle(.inline)
  }
}

When the preview updates, try out the menu by tapping the arrows and choosing a case. To reproduce the multi-sort you used earlier, update the .age then .name case in the initializer in DogList.swift inside the sortDescriptors code block, where you have sortOrder.

case .age:
  [SortDescriptor(\DogModel.age),
    SortDescriptor(\DogModel.name)]

Now when you choose age the dogs sort alphabetically. When you choose age, they sort by age and then by name. The sortOrder initializer will now look like this.

init(sortOrder: SortOrder) {
  let sortDescriptors: [SortDescriptor<DogModel>] = switch sortOrder {
  case .name:
    [SortDescriptor(\DogModel.name)]
  case .age:
    [SortDescriptor(\DogModel.age),
      SortDescriptor(\DogModel.name)]
  }
  _dogs = Query(sort: sortDescriptors)
}

Finding the Good Dog With Filter

Sorting organizes data into familiar patterns, but narrowing down large sets of data or finding specific records adds power to you apps. For clarity, start by commenting out the line were the query is assigned in the sort order initializer. It will interfere with the filter until we fully incorporate it.

//_dogs = Query(sort: sortDescriptors)

Next add a predicate to filter the data. In the @Query, add filter followed by the #Predicate and DogModel type. In a trailing closure add search on a breed.

@Query(filter: #Predicate<DogModel> { dog in
  dog.breed == "Labrador Retriever"
}) private var dogs: [DogModel]

In the sample mock data there are only two Labrador Retrievers. Next you add the predicate to the initializer. Update the initializer by adding in the predicate. Pass in a filterString value. Then add the logic to apply the filterString and add this to the Query assignment.

The initializer will look like the following.

init(sortOrder: SortOrder, filterString: String) {
  let sortDescriptors: [SortDescriptor<DogModel>] = switch sortOrder {
  case .name:
    [SortDescriptor(\DogModel.name)]
  case .age:
    [SortDescriptor(\DogModel.age),
      SortDescriptor(\DogModel.name)]
  }
  let predicate = #Predicate<DogModel> { dog in
    dog.breed?.localizedStandardContains(filterString) ?? false
    || dog.name.localizedStandardContains(filterString)
    || filterString.isEmpty
  }
  _dogs = Query(filter: predicate, sort: sortDescriptors)
}

The preview will also want an empty filterString value. Also in the DogListView, add a State variable filter with an empty string default value. Now to access and enter a filter string add a .searchable modifier to the DogList() in DogListView. Update it like this.

DogList(sortOrder: sortOrder, filterString: filter)
  .searchable(text: $filter, prompt: Text("Filter on name or breed"))

Empty Dog Park?

You’ve added mock data to help with testing your views, and now you can sort and filter the records. However, what happens when there are no results? Apple added ContentUnavailableView to handle this case, where you can populate an empty view with helpful information. In fact, this is also a good thing to add when your app is new and no records have been created.

Start by creating a message to show the users when there are no dogs. Add two State properties at the top of the DogList struct. Add an empty string called message and a dogCount with default value zero.

@State private var message = ""
@State private var dogCount = 0

You’re going to use .onAppear() to check the dogCount amount, but before you do select the List and embed it in a Group. Your code should look like the following.

Group {
  List {
    ForEach(dogs) { dog in
      NavigationLink {
        EditDogView(dog: dog)
      } label: {
        HStack {
          Image(systemName: "dog")
            .imageScale(.large)
            .foregroundStyle(.tint)
          VStack(alignment: .leading) {
            Text(dog.name)
              .font(.title2)
            Text("age: \(String(describing: dog.age ?? 0))")
              .font(.footnote)
          }
        }
      }
    }
    .onDelete(perform: dogToDelete)
  }
}

You’ll need to have something like this Group to attach the .onAppear() and you’re about the wrap the List in some logic, so it may not be created is there are no dogs.

At the bottom of the Group, after the closing curly brace, add an .onAppear() to check the dogCount. There are two conditions where either there are no dogs found by the filter, or the app is new without any dogs created. Add the following check for dogs.count.

.onAppear() {
  dogCount = dogs.count
  if dogCount == 0 {
    message = "Enter a dog."
  } else {
    message = "No dogs found."
  }
}

Note: You can use .onAppear and .onDisappear to handle states when the view loads and unloads. You can also use the asynchronous .task instead. You’ll learn how to use .task soon, but for clarity you’ll use .onAppear() for now.

Next you’ll move the List inside the logic to check the dogCount. Start by adding an if statement at the top of the Group

if !dogs.isEmpty {
  // the List will go here
} else {
  // empty view will go here
}

Next select the List by double-clicking the opening brace. This highlights the code block. Also Shift-click the List name to include it in your selection. Now cut the List and paste it into the top half of the if statement.

Pro Tip: With one or more lines of code selected you can move the code up with Command-Option-[ keys and move the lines down with Command-Option-], instead of cut & paste.

In the bottom half of the if !dogs.isEmpty check, add a ContentUnavailableView() with your message and a systemImage: "dog".

if !dogs.isEmpty {
  // the List code is here
} else {
  ContentUnavailableView(
    message,
    systemImage: "dog"
  )
}

Pro Tip: It is often preferable to put the ContentUnavailableView() in the bottom half of the if logic. If there is an error in your code, the compiler may complain that ContentUnavailableView() is at fault if it comes first. To find the actual root cause, you would need to comment out the if and ContentUnavailableView(). The compiler warning can be confusing. Within the else case the compiler is less likely to blame the empty view.

Now that you have the ContentUnavailableView() in place, switch over to DogListView and start the Canvas preview. Enter some text that doesn’t match the mock data, or your stored data if your testing in the Simulator or on a device. You should see No Dogs Found. If you try installing the app with no dogs added, you should see Enter a dog.

That’s all for sorting and filtering with SwiftData. You can take a break and move on to learning about One to Many Relationships in the next demo.

See forum comments
Cinema mode Download course materials from Github
Previous: Managing Fetched Data Next: One to Many Relationships Demo