Data Persistence with SwiftData

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

Lesson 03: SwiftData Techniques

SwiftData Techniques Demo

Episode complete

Play next episode

Next
Transcript

You can start with the app you started building in the previous lessons, or you can start with the app in the Starter folder for this lesson. In Xcode, select DogListView from the Project Navigator. In the Canvas preview, switch the Preview Device to an iPad, and wait for the preview to update. Tap Zoom to Fit on the right. Tap the Device Settings on the left toggle on the Orientation, and select Landscape Left.

Notice that the List view stretches to fill the width. This would be the same if you ran the app on macOS or chose to run on a Vision Pro preview or Simulator.

Note: The current app configuration won’t build on macOS because of the use of UIKit in the mock data. You’ll fix that in a later lesson by adding NSImage support.

Now you’ll refactor the DogListView with the NavigationSplitView. Still in the DogListView, add a state variable selectedDog of a DogModel type at the top of the main struct.

@State private var selectedDog: DogModel?

Change NavigationStack to NavigationSplitView. After a moment, you will see an error. Missing argument for parameter 'detail' in call at the closing curly brace of the NavigationSplitView. Enter the } detail: { closure.

NavigationSplitView {
  DogList(sortOrder: sortOrder, filterString: filter)
  // ...
  // end of ToolbarItem "Sort"
} detail: {
  // add NavigationLink here
}
  // ...

Recall that EditDogView requires a dog. In the detail closure, add a NavigationLink with the value: selectedDog. Then add EditDodView(dog: selectedDog) in the NavigationLink’s closure. Since the selectedDog is Optional you need unwrap it, so first embed the NavigationLink in an if let. Control-click on the NavigationLink and choose Embed… from the contextual menu. Then change the Container to if let selectedDog which will unwrap the selectedDog contained. Add an elseand enter Text("Select a dog!").

} detail: {
  if let selectedDog {
    NavigationLink(value: selectedDog) {
      EditDogView(dog: selectedDog)
    }
  } else {
    Text("Select a dog!")
  }
}

The whole refactored view will look like this:

var body: some View {
  NavigationSplitView {
    DogList(sortOrder: sortOrder, filterString: filter)
      .searchable(text: $filter, prompt: Text("Filter on name or breed"))
      .navigationTitle("Good Dogs")
      .toolbar {
        ToolbarItem(placement: .primaryAction) {
          Button("Add New Dog", systemImage: "plus") {
            showingNewDogScreen = true
          }
        }
      }
      .sheet(isPresented: $showingNewDogScreen) {
        NewDogView()
          .presentationDetents([.medium, .large])
      }
      .toolbar {
        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)
          }
        }
      }
  } detail: {
    if selectedDog != nil {
      NavigationLink(value: selectedDog) {
        EditDogView(dog: selectedDog!)
      }
    } else {
      Text("Select a dog!")
    }
  }
}

However, there is a slight problem. The first dog you select loads into the EditDogView. When you select a second dog, the contents don’t change to the second dog, except for the Parks. You need to update the EditDogView when you change the dog. Add an .onChange(of: ) to the EditDogView. Add this to the bottom of the Group. Remember, double-click the opening curly brace to find the end curly brace. Also, assign the properties to the dog object’s properties.

.onChange(of: dog) {
  name = dog.name
  age = dog.age ?? 0
  weight = dog.weight ?? 0
  color = dog.color ?? ""
  image = dog.image
}

Now, all of the values change with the dog when selected.

One last thing. By default, the sidebars are set to be able to close and open. If you want the sidebar to always be visible as a split view, add columnVisibility: .constant(.doubleColumn) to the NavigationSplitView. Also, add a navigationSplitViewStyle modifier as .balanced and set a frame minimum width.

NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
  // ...
} detail: {
  //...
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: 250)

Now, the sidebar will always stay open, even in portrait mode, unless you close it with the top button. Another thing you might not have noticed is that the ToolbarItem items have also been reoriented to the left sidebar. Before the NavigationSplitView was added here, they would have spread out across the top of the screen.

For now, you can switch back to iPhone-sized previews to save on screen space.

Adding Unique Attributes

Currently, people can create any number of dogs in the app, and they can choose an existing breed from the picker. However, nothing is preventing them from entering the same breed name over and over. This isn’t a huge issue with a few dogs, but with a large enough data set, it could add up to a lot of unnecessary storage. It would also populate the picker with duplicate names. There are also cases where you’d want to store a truly unique value, like a car’s VIN number, or a dog’s city license number.

@Attribute(.unique) var name: String

This is where the unique attribute comes in. You can go ahead and add the attribute to the BreedModel’s name property. You will add some defensive code in a bit, but for now give it a try. SwiftData as usual will do a lightweight migration.

Build and run the app on the Simulator. Either edit a dog or create a new one. Tap the Edit Breeds button, tap the + to create a new breed. Enter Mixed or any breed that you already have on the list of breeds. Tap Add Breed. Nothing appears to happen, but in fact, SwiftData has done a upsert.

Adding an Unknown Breed

Now that you’ve looked at having unique entries think about what you could do about unknown values. Using empty strings is fine, but there might come a day when you need to find and sort every dog. People can always enter Unknown in by hand. But you’re now becoming a SwiftData expert, and you can do better. People don’t like looking at an empty app. Get them started with a dog.

You can create the first dog when the app is installed, by customizing the app’s ModelContainer. Head over to the GoodDogApp.swift file. Below the view, declare a computed variable ModelContainer.

var container: ModelContainer {
  return container
}

Since this needs to be done on the main thread, add @MainActor before the declaration. You’ll also make a constant for the scheme, DogModel, and make a constant for the container. Update the container like this:

@MainActor
var container: ModelContainer {
  let schema = Schema([DogModel.self])
  let container = try! ModelContainer(for: schema)

  // make a dog here

  return container
}

Now you can check the data store to see if there’s a dog already with fetch(), and its fetchLimit. Create a FetchDescriptor on the DogModel to describe the parameters below the container.

var dogFetchDescriptor = FetchDescriptor<DogModel>()
dogFetchDescriptor.fetchLimit = 1
guard try! container.mainContext.fetch(dogFetchDescriptor).count == 0 else { return container }

You added a guard to check that the count is zero. If it’s not, you return the container early. If there are no dogs, you’ll create one with the Unknown Breed.

let dogs = [
  DogModel(
    name: "Rover",
    breed: BreedModel(name: "Unknown Breed"))
]

Next, you insert your new dog with the mainContext. Your whole container code will look like this:

@MainActor
var container: ModelContainer {
  let schema = Schema([DogModel.self])
  let container = try! ModelContainer(for: schema)

  // check that there are no dogs in the store
  var dogFetchDescriptor = FetchDescriptor<DogModel>()
  dogFetchDescriptor.fetchLimit = 1
  guard try! container.mainContext.fetch(dogFetchDescriptor).count == 0 else { return container }

  let dogs = [
    DogModel(
      name: "Rover",
      breed: BreedModel(name: "Unknown Breed"))
  ]

  for dog in dogs {
    container.mainContext.insert(dog)
  }

  return container
}

To implement the new container, replace the scheme DogModel with the container in the Window Groupas a modifier on DogListView.

WindowGroup {
  DogListView()
    .modelContainer(container)
}

Be sure to make a build with Command-B and check for errors. Behind the scenes, SwiftData will update the modelContainer as usual.

If you already have the app in the Simulator, you can delete all your dogs and stop or kill the app. The next time you run the app, your demo dog Rover with Unknown Breed will be created.

Adding a Query.

Now that you have this Unknown Breed available, there’s no reason to create a dog with an empty breed name. With SwiftData, you can use multiple queries. Currently, the NewDogView doesn’t have its own @Query. As a subview of DogList, it inherits that DogModel query when it inserts a new dog. You will need to check if there’s an Unknown Breed still available and if the user hasn’t deleted it. This can be done with a #Predicate on the breed name in the BreedModel. Then, you can either use found breed or reinsert it if needed.

Select the NewDogView from the Project Navigator. At the top of the file, import SwiftData. Add an @Query with a filter Predicate on BreedModel in angle brackets. Check if there are any breeds named Unknown Breed. Then, in the Create button, use it or add it to the insert.

// at the top of the file
import SwiftData

// at the top of the NewDogView struct add:
@Query(filter: #Predicate<BreedModel> { breed in
    breed.name == "Unknown Breed"
  }) private var breeds: [BreedModel]

Update the Create button with the first breed if there’s one found, otherwise, the app will add a new one.

let breed: BreedModel
if breeds.isEmpty {
  // make a new breed
  breed = BreedModel(name: "Unknown Breed")
} else {
  // found at least one
  breed = breeds[0]
}
let newDog = DogModel(
  name: name,
  breed: breed)
modelContext.insert(newDog)

In the next lesson, you’ll need to get rid of the unique attribute to use CloudKit. This code for adding the default brand will work instead of using unique.

Implementing Undo - Doggy Spin

Up to this point, users can create and update dogs. They can edit and delete dogs, as well. To add even more polish to your app, you’ll implement undo. The UndoManager is built into SwiftData, but as mentioned, you need to enable it to use it. The ability to undo and redo can be computationally expensive and consume memory, so it’s not enabled by default. Once again, you use the mainContext to access the UndoManager(). You can create a modelContainer and then add undo on the container. Alternatively, you can add it to your modelContainer, where you created the container if you’re not using any customization. Since you just added a custom container, you’ll use the first method.

Head over to the GoodDogApp.swift file. Inside the computed variable ModelContainer you made for the default dog, add the following as a property on the container’s mainContext.

container.mainContext.undoManager = UndoManager()

Before you go any further, add a do try catch to the container for safety. It’s not needed for undo, but it’s best practice when creating a custom container. Add the do before the schema constant and the catch below the last return container.

In the catch, add an error message.

Your container will look like this:

@MainActor
var container: ModelContainer {
  do {
    let schema = Schema([DogModel.self])
    let container = try! ModelContainer(for: schema)
    //container.mainContext.autosaveEnabled = false
    // here's the undo
    container.mainContext.undoManager = UndoManager()

    // check that there are no dogs in the store
    var dogFetchDescriptor = FetchDescriptor<DogModel>()
    dogFetchDescriptor.fetchLimit = 1
    guard try container.mainContext.fetch(dogFetchDescriptor).count == 0 else { return container }

    let dogs = [
    DogModel(
      name: "Rover",
      breed: BreedModel(name: "Unknown Breed")
    )
  ]

    for dog in dogs {
      container.mainContext.insert(dog)
    }

    return container
  } catch {
    fatalError("Failed to create container")
  }
}

To use the new undo, open the DogList from the Project Navigator. Notice that this is the DogList, not the DogListView. At the top of the DogList struct, add an @Environment variable with the \.undoManager keypath.

@Environment(\.undoManager) private var undoManager

Next, add a toolbar with the ToolBarItem button named “Undo”. Add it to the bottom of the Group after its closing curly brace. Use a system image arrow.uturn.left for the button. Put it just above the .onAppear

.toolbar {
  ToolbarItem {
    Button("Undo", systemImage: "arrow.uturn.left") {
      withAnimation {
        modelContext.undoManager?.undo()
      }
    }
    .disabled(modelContext.undoManager?.canUndo == false)
  }
}

While you’re here, change the .onAppear to a .task. Apple engineers have been suggesting using .task in place of .onAppear and .onDisappear. In the latter case, a task will end any running method when dismissing the view.

Now, if you haven’t already, go to the DogListView and set the Preview Device to an iPhone model. You might need to choose a phone from the More disclosure in the device picker. Notice that the undo button appears at the top of the screen, even though it’s in the DogList. Neat!

Build and Run to try the undo in the Simulator. Notice that the undo button is grayed out. Go ahead and create a new dog. Then, swipe to delete it. Now tap the undo button. This action undoes the deletion. Tap undo again. This undoes the creation.

If you’re concerned about the amount of memory used, you can add levelsOfUndo to the mainContext. Add this to the GoodDogsApp:

container.mainContext.undoManger?.levelsOfUndo = 2

Note: It’s possible to add undo to the Preview, but that’s beyond the scope of this course.

GoodDogs Schema

One last thing you’ll do in this lesson is to create a custom schema. In order to make a custom schema, you’ll add a ModelConfiguration. In the GoodDogsApp, add the following before creating the container:

let config = ModelConfiguration("GoodDogs", schema: schema)

Now, update the container declaration.

let container = try! ModelContainer(for: schema, configurations: config)

Before you build to the Simulator or a device, you should note that these new schema files will be named “GoodDogs”. The files will look like a new app, including the developer as well. Your previous files will be there as well.

GoodDogs.store
GoodDogs.store-shm
GoodDags.store-wal

You can navigate to the Simulator files from the console. As you’ve done before, select the printed path from the console up to the Library. Remember the space character in the Application Support breaks the trick, so don’t include it in your selection. Control-click the selected path and from the contextual menu, choose Services and Open. The Mac will prompt you to confirm, so choose Run Service.

The folder inside the Simulator’s app opens in the Finder. Open the Application Support folder, and you should see three files. Now you’ll see you new schema files.

Note: If you want to keep the data files you have, you can copy them to another folder. If you’re careful, you can rename the old default files to match the new schema, and your old dogs will be there. Unfortunately, there’s no way to do this on a device secured with a passcode because they’re encrypted.

In the final lesson of this series you’ll look at doing a proper migration. That’s it for this lesson. Continue to the conclusion.

See forum comments
Cinema mode Download course materials from Github
Previous: SwiftData Techniques Instruction Next: SwiftData Techniques Conclusion