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

Many to Many Relationships Demo

Episode complete

Play next episode

Next
Transcript

Visit the Dog Parks

A one-to-one relationship would be like having a model for dog licenses. Each dog could have a unique license. The licenses could be managed in other functions, so it’s like adding the field in the DogModel directly.

Dogs like to visit a variety of dog parks and that’s good for the dog’s health. However a dog park is shared by other dogs and owners, but not exclusively. This is where a Many to Many relationship is used.

It’s time for you to add the dog parks into the mix. Start again with the ParkModel. Import SwiftData and add the @Model. Once again you are using an array of dogs.

import Foundation
import SwiftData

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

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

Now in the DogModel add the parks as an array of ParkModels. Both sides are arrays so that’s many to many.

@Model
class DogModel {
  // ...

  // 1. add the parks under the vars
  var parks: [ParkModel]?

  init(
    // ...

    // 2. add the parks here
    parks: [ParkModel]? = nil
  ) {
    // ...

    // 3. and here
    self.parks = parks
  }
}

Also update the DogModel mock data. Create some parks and add one or more parks to each dogs.

While you are there you can add the mock image data. You should see some dog images in the Assets.xcassts in a dog folder.

At the top of the DogModel, import UIKit. This way you can use the mock data images in the asset catalog. You’ll use UIImage's pngData() in the previews. For example.

UIImage(resource: .sorcha).pngData()!

The pngData supports both PNG and JPG images.

Your final refactored DogModel will look like this.

extension DogModel {
  @MainActor
  static var preview: ModelContainer {
    let container = try! ModelContainer(for: DogModel.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true))

    let labrador = BreedModel(name: "Labrador Retriever")
    let golden = BreedModel(name: "Golden Retriever")
    let bouvier = BreedModel(name: "Bouvier")
    let mixed = BreedModel(name: "Mixed")

    let riverdale = ParkModel(name: "Riverdale Park")
    let withrow = ParkModel(name: "Withrow Park")
    let greenwood = ParkModel(name: "Greewood Park")
    let hideaway = ParkModel(name: "Hideaway Park")
    let kewBeach = ParkModel(name: "Kew Beach Off Leash Dog Park")
    let allan = ParkModel(name: "Allan Gardens")

    let macDog = DogModel(
      name: "Mac",
      age: 11,
      weight: 90,
      color: "Yellow",
      breed: labrador,
      image: UIImage(
        resource: .macintosh
      ).pngData()!,
      parks: [
        riverdale,
        withrow,
        kewBeach
      ]
    )
    let sorcha = DogModel(
      name: "Sorcha",
      age: 1,
      weight: 40,
      color: "Yellow",
      breed: golden,
      image: UIImage(
        resource: .sorcha
      ).pngData()!,
      parks: [
        greenwood,
        withrow
      ]
    )
    let violet = DogModel(
      name: "Violet",
      age: 4,
      weight: 85,
      color: "Gray",
      breed: bouvier,
      image: UIImage(
        resource: .violet
      ).pngData()!,
      parks: [
        riverdale,
        withrow,
        hideaway
      ]
    )
    let kirby = DogModel(
      name: "Kirby",
      age: 11,
      weight: 95,
      color: "Fox Red",
      breed: labrador,
      image: UIImage(
        resource: .kirby
      ).pngData()!,
      parks: [
        allan,
        greenwood,
        kewBeach
      ]
    )
    let priscilla = DogModel(
      name: "Priscilla",
      age: 17,
      weight: 65,
      color: "White",
      breed: mixed,
      image: nil,
      parks: []
    )


    container.mainContext.insert(macDog)
    container.mainContext.insert(sorcha)
    container.mainContext.insert(violet)
    container.mainContext.insert(kirby)
    container.mainContext.insert(priscilla)

    return container
  }
}

Before you move on, wrap the call to import UIKit to protect it when running on macOS, which doesn’t directly support UIKit. You will add support for the placeholder images later. Update the code like the following.

#if !os(macOS)
import UIKit
#endif

This will silence any compiler errors for now, but in keep in mind that you’ll add support for macOS later on.

Now that the mock data is updated with images, you can select the dogs from the EditDogView. In the preview add an image to dog object.

let dog = DogModel(
  name: "Mac",
  age: 11,
  weight: 90,
  color: "Yellow",
  image: UIImage(
    resource: .macintosh).pngData()!
)

Note: You haven’t mocked a breed here, but now that the views are updated, you can go to Edit Breeds and add a breed and assign it to the mocked dog. Recall that the previews are using in-memory storage in the previews.

Setting up the Parks

The next steps are similar to what you’ve done before the set up the views to support SwiftData. Select the ParksView, add import SwiftData and at the top of the main struct add a modelContext, a @Query sorting the park names, and a @Bindable dog to save the changes.

// at the top
import SwiftData

// in the ParksView struct
@Environment(\.modelContext) private var modelContext
@Query(sort: \ParkModel.name) var parks: [ParkModel]
@Bindable var dog: DogModel

Next update the Preview’s mock data, by adding a mock dog, and an empty array for the parks.

#Preview {
  let container = try! ModelContainer(for: DogModel.self)
  let dog = DogModel(name: "Mac", parks: [])
  return ParksView(dog: dog)
    .modelContainer(container)
}

Update the ForEach to show the park name.

ForEach(parks) { park in
  Text(park.name)
}

Add ContentUnavailableView to handle the case where there are no parks, using the parks.count. Wrap the List and the LabeledContent in if condition and add the ContentUnavailableView in the else with a button to add a park. Replace the contents of the Group with this code.

Group {
  if !parks.isEmpty {
    List {
      ForEach(parks) { park in
        Text(park.name)
      }
    }
    LabeledContent {
      Button {
       // addRemove() will go here
      } label: {
        Image(systemName: "plus.circle.fill")
          .imageScale(.large)
      }
      .buttonStyle(.borderedProminent)
    } label: {
      Text("Create new park")
        .font(.caption)
        .foregroundStyle(.secondary)
    }
  } else {
    ContentUnavailableView {
      Image(systemName: "tree")
    } description: {
      Text("You need to create some parks.")
    } actions: {
      Button("Create Park") {
        newPark.toggle()
      }
      .buttonStyle(.borderedProminent)
    }
  }
}

The Create Park button will go to NewParkView. At the top of the main struck is a newPark bool set to false. In the code, you’ve also added the toggle to go to the NewParkView

Switch over to the NewParkView and add SwiftData support. Once again import SwiftData at the top, and add a modelContext to the view to handle creating parks.

// at the top of the file
import SwiftData

// at the top of the main struct
@Environment(\.modelContext) var modelContext

Update the Add Park button to save a new park.

Button("Add Park") {
  let newPark = ParkModel(name: name)
  modelContext.insert(newPark)
  try? modelContext.save()
  dismiss()
}

This is the same approach used in NewBreedView, creating a new ParkModel object, inserting it into the modelContext and asking the context to try saving.

Add and Remove Associated Parks

In a Many-to-Many relationship you can think of the objects as being associated with each other. To save the association on a particular dog, you can add and remove the park associations with each save. To accomplish this, add a func named addRemove() after the body view's closing curly braces.

Each dog will have an array of parks, so you’ll use the append function, from a list of parks. Initially you will have added one park through the ContentUnavailableView. You’ll then check if the dog.parks contains the park at the selected index and either add or remove it. Below the body view add the addRemove() function.

func addRemove(_ park: ParkModel) {
  if let dogParks = dog.parks {
    // check if parks is empty
    if dogParks.isEmpty {
      dog.parks?.append(park)
    } else {
      // check if park is associated
      // remove park if true
      // add park if false
      if dogParks.contains(park),
          let index = dogParks.firstIndex(where: {
            $0.id == park.id
          }) {
        dog.parks?.remove(at: index)
      } else {
        dog.parks?.append(park)
      }
    }
  }
}

Now you’ll need a button to add or remove the park. You’ll use a systemImage with a empty or filled circle to show the state of the associated park. Update the ForEach(parks) with the button code.

ForEach(parks) { park in
  HStack {
    if let dogParks = dog.parks {
      if dogParks.isEmpty {
        Button {
          addRemove(park)
        } label: {
          Image(systemName: "circle")
        }
      } else {
        Button {
          addRemove(park)
        } label: {
          Image(
            systemName:
              dogParks.contains(
                park
            ) ? "circle.fill" : "circle"
          )
        }
      }
    }
    Text(park.name)
  }
}

At the top of the EditDogView struct there is a state variable to show the park list.

@State private var showParks = false

Under the Edit Breeds button in EditDogView add a button to toggle showParks

VStack {
  Button("Parks", systemImage: "tree") {
    showParks.toggle()
  }
  .buttonStyle(.borderedProminent)
}

Now add a sheet(isPresented) below the to show the parks while passing in the current dog object.

.sheet(isPresented: $showParks) {
  ParksView(dog: dog)
    .presentationDetents([.large])
}

You can now open the ParksView, select and add a park to your dog. However you will need to add the newPark.toggle() to the button with the plus.circle.fill image.

Button {
  // newPark.toggle will go here
  newPark.toggle()
} label: {
  Image(systemName: "plus.circle.fill")
    .imageScale(.large)
}

Leaving the Park

The last bit of functionality for the ParksView is to delete a park. Again, you will find the park at the index and remove it. Add the onDelete() to the bottom of the ForEach after the closing curly brace. Recall that the default delete rule is nullify. That means you can delete a park, but the dog will be persisted. Also when you delete a dog, the parks are persisted.

.onDelete(perform: { indexSet in
  // find the park at index
  indexSet.forEach { index in
    // 2. clear the local park here in the view
    if let dogParks = dog.parks,
        dogParks.contains(parks[index]),
        let dogParkIndex = dogParks.firstIndex(
          where: { $0.id == parks[index].id }
        ) {
          dog.parks?.remove(at: dogParkIndex)
        }
      // 1. remove the park in the data store, with autosave
      modelContext.delete(parks[index])
    }
})

Parks: Collect Them All

The last thing that app needs at this point is a way to display the dog’s favorite parks. There is a horizontal stack view in the app to do that. Select the ParkStackView from the Project Navigator. Add support for SwiftData. At the top of the file import SwiftData and at the top of the ParkStackView struct add the modelContext.

// at the top of the file
import SwiftData

// at the top of the main struct
@Environment(\.modelContext) var modelContext

Add a parks array of ParkModel as well.

var parks: [ParkModel]

Update the Preview by adding a local modelContext and some parks

#Preview {
  let container = try! ModelContainer(for: DogModel.self)
  let riverdale = ParkModel(name: "Riverdale Park")
  let withrow = ParkModel(name: "Withrow Park")
  let greenwood = ParkModel(name: "Greewood Park")
  let parks = [riverdale, withrow, greenwood]

  return ParkStackView(parks: parks)
    .modelContainer(container)
}

Update the ForEach to use parks and show the park name.

ForEach(parks) { park in
  Text(park.name)
  // ...
}

Finally go back the EditDogView, add the code to display the ParkStackView. Below the Section’s closing curly brace add a ViewThatFits with this code. This is after the section, just after the Parks Button.

VStack {
  if let parks = dog.parks {
    ViewThatFits {
      ScrollView(.horizontal, showsIndicators: false) {
        ParkStackView(parks: parks)
      }
    }
  }
}

Now when you add a park to your dog’s collection they will display here.

That was another marathon session. Now that the coding is done, move on the the conclusion of this lesson.

See forum comments
Cinema mode Download course materials from Github
Previous: One to Many Relationships Demo Next: Sorting, Filtering & Relationships Conclusion