Working with SwiftData in SwiftUI iOS 30.12.2023

SwiftData was announced at WWDC in June 2023. SwiftData simplifies data persistence tasks by using declarative code. Built on top of Core Data, SwiftData is actually a framework designed to help developers manage and interact with data on a persistent store.

Main parts of of SwiftData are

  • @Model macro
  • .modelContainer() view modifier
  • @Environment(\.modelContext) property wrapper
  • @Query property wrapper

To get started, make sure you have Xcode 15.0 or a later version installed and iOS 17 or later.

Simple app

To create a model you need to mark the class with the macro @Model.

@Model
final class Movie: Identifiable {
    @Attribute(.unique) var id: String = UUID().uuidString
    var name: String
    var created: Date

    init(id: String = UUID().uuidString, name: String, created: Date) {
        self.id = id
        self.name = name
        self.created = created
    }
}

@Model macro is an essential item in SwiftData’s functionality. Using it you can define data model’s schema directly from your Swift code and can annotate your properties with additional metadata when necessary.

Properties in the @Model can have macros attached to them that will defined their behaviour. Currently there are 3 types of macros:

  • @Attribute. To specify an uniqueness constraint to a property.
  • @Relationship. To specify a relationship between two model classes.
  • @Transient. By default, SwiftData persists all non-computed attributes. In case we want to exclude properties from persisting, we can use the @Transient macro.

The ModelContainer serves as the persistent backend for your model types. It can operate with default settings, or be customized with configurations and migration options. To create a model container, simply specify the model types you want to store.

The .modelContainer modifer allows you to specify some options: inMemory, isAutosaveEnabled, isUndoEnabled, onSetup.

Once your container is set up, you’re ready to fetch and save data using model contexts.

import SwiftUI
import SwiftData

@main
struct SwiftDataApp: App {
    let modelContainer: ModelContainer

    init() {
        do {
            modelContainer = try ModelContainer(for: Movie.self)
        } catch {
            fatalError("Could not initialize ModelContainer")
        }
    }

    var body: some Scene {
        WindowGroup {
            MoviesView()
        }
        .modelContainer(modelContainer)
    }
}

ModelContext observes changes to your models and provides actions (CRUD) to do on them. It’s your main access for tracking updates, fetching data, saving changes. In SwiftUI you’ll get the ModelContext from your view’s environment after creating your model container.

import SwiftUI

struct MoviesView: View {
    @Environment(\.modelContext) private var modelContext    
    @Query private var items: [Movie]
    //@Query(sort: \.created, order: .reverse) var items: [Movie]

    var body: some View {
        VStack {
            Button(action: {
                let name = "Movie \(Int.random(in: 1..<100))"
                modelContext.insert(Movie(name: name, created: Date()))
            }) {
                Label("Add movie", systemImage: "plus")
            }
            List {
                ForEach(items) { item in
                    HStack {
                        Text(item.name)
                        Spacer()
                        Text(item.created, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: { offsets in
                    for index in offsets {
                        modelContext.delete(items[index])
                    }
                })
            }
        }
    }
}

MVVM & SwiftData

MovieDataSource will contain the ModelContainer

final class MovieDataSource {
    private let modelContainer: ModelContainer
    private let modelContext: ModelContext

    @MainActor
    static let shared = MovieDataSource()

    @MainActor
    private init() {
        self.modelContainer = try! ModelContainer(for: Movie.self)
        self.modelContext = modelContainer.mainContext
    }

    func append(_ item: Movie) {
        modelContext.insert(item)
        do {
            try modelContext.save()
        } catch {
            fatalError(error.localizedDescription)
        }
    }

    func fetchAll() -> [Movie] {
        do {
            return try modelContext.fetch(FetchDescriptor<Movie>())
        } catch {
            fatalError(error.localizedDescription)
        }
    }

    func remove(_ item: Movie) {
        modelContext.delete(item)
    }
}

Here are the App and the main view

@main
struct SwiftDataApp: App {
    var body: some Scene {
        WindowGroup {
            MoviesView()
        }
    }
}

struct MoviesView: View {
    @State private var vm = MovieViewModel()

    var body: some View {
        VStack {
            Button(action: {
                vm.append()
            }) {
                Label("Add movie", systemImage: "plus")
            }

            List {
                ForEach(vm.items) { item in
                    HStack {
                        Text(item.name)
                        Spacer()
                        Text(item.created, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: { offsets in
                    for index in offsets {
                        vm.remove(at: index)
                    }
                })
            }
        }
    }
}

Finally, the ViewModel

@Observable
class MovieViewModel {
    @ObservationIgnored
    private let dataSource: MovieDataSource

    var items: [Movie] = []

    init(dataSource: MovieDataSource = MovieDataSource.shared) {
        self.dataSource = dataSource
        items = dataSource.fetchAll()
    }

    func append() {
        let name = "Movie \(Int.random(in: 1..<100))"
        dataSource.append(Movie(name: name, created: Date()))

        items = dataSource.fetchAll()
    }

    func remove(at index: Int) {
        dataSource.remove(items[index])
    }
}