Skip to content

17 - SwiftData

Guard

1
2
3
4
5
guard container != nil else {
    fatalError("This view needs a persistent container.")
}
// container is accessible here!
...

The unwrapping is a little unintuitive with guard - if let unwraps values for use inside a block. Here the guard statement has an associated block but it’s actually an else block - i.e., the thing you do if the unwrapping fails - the values are unwrapped straight into the same context as the statement itself.

Same as TypeScript/C# compiler analysis

1
2
if (!thingMaybeNull) return;
thingMaybeNull.nowItIsNotNull();

Extension

1
2
3
4
5
6
7
extension ViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, 
                    canEditRowAt indexPath: IndexPath) -> Bool {
        return true
    }
}
  • Add computed instance properties and computed type properties
  • Define instance methods and type methods
  • Provide new initializers
  • Define subscripts
  • Define and use new nested types
  • Make an existing type conform to a protocol
  • Extensions can add new functionality to a type, but they cannot override existing functionality.
1
2
3
4
5
6
7
extension Double {
    var km: Double { return self * 1_000.0 }
    var m: Double { return self }
    var cm: Double { return self / 100.0 }
    var mm: Double { return self / 1_000.0 }
    var ft: Double { return self / 3.28084 }
}

Although properties are implemented as computed properties, the names of these properties can be appended to a floating-point literal value with dot syntax, as a way to use that literal value to perform distance conversions.

1
2
3
4
5
6
7
8
9
let oneInch = 25.4.mm
print("One inch is \(oneInch) meters")
// Prints "One inch is 0.0254 meters"
let threeFeet = 3.ft
print("Three feet is \(threeFeet) meters")
// Prints "Three feet is 0.914399970739201 meters"
let aMarathon = 42.km + 195.m
print("A marathon is \(aMarathon) meters long")
// Prints "A marathon is 42195.0 meters long"

SwiftData

SwiftData is Apples latest addition (2023 WWDC, iOS 17) to SwiftUI trend - how to work with data. Underneath it is still powered by Core Data.
SwiftData is still somewhat limited, not all the features from Core Data are implemented.

Main missing components are:

  • NSCompoundPredicate - complex, multi-stage predicates
  • NSFetchedResultsController - manage and display fetched data
  • derived attributes - calculations done by db directly (ala dynamic createdAt)

and some more...

Since SwiftData is very new - less help and mysterious crashes...

Major change - SwiftData is code first approach, not like Core Data model based approach.

Model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import Foundation
import SwiftData

@Model
class Person {
    var firstName: String
    var lastName: String

    @Relationship(deleteRule: .cascade, inverse: \Contact.person) var contacts = [Contact]()

    init(firstName: String = "", lastName: String = "") {
        self.firstName = firstName
        self.lastName = lastName
    }
}

@Model macro - tell SwiftData, that this is our persisted domain class. Does lots of behind the scenes changes - generates getters and setters for change detection, data saving, lazy loading etc..

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import Foundation
import SwiftData

@Model
class Contact {
    var value: String
    var person: Person?

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

Attribute

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import Foundation
import SwiftData

@Model
class Person {
    var firstName: String
    var lastName: String
    @Attribute(.unique) var email: String

    @Relationship(deleteRule: .cascade, inverse: \Contact.person) var contacts = [Contact]()

    init(firstName: String = "", lastName: String = "", email: String = "") {
        self.firstName = firstName
        self.lastName = lastName
        self.email = email
    }
}
  • unique
  • ephemeral - not stored
  • externalStorage - store outside of table

Properties

SwiftData uses classes - allow sharing to many locations (reference). And allows reference to itself.
Structs and enums can be used, as long as they conform to Codable (serialization).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum Status: Codable {
    case active, inactive(reason: String)
}

struct Address: Codable {
    var line1: String
    var line2: String
    var city: String
    var postCode: String
}

@Model
class User {
    var name: String
    var status: Status
    var address: Address

    init(name: String, status: Status, address: Address) {
        self.name = name
        self.status = status
        self.address = address
    }
}

Relationships

Just by adding var contacts = [Contact]() to model is enough to get working 1:m relationship.
Default behavior on deleteing the one/primary side of the relationship leaves child records orphaned.

Use

1
@Relationship(deleteRule: .cascade) var contacts = [Contact]()

to configure relationship explicitly. Configuration can only be used on one side - other side is done by SwiftData.

SwiftData System Setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import SwiftUI
import SwiftData

@main
struct testApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Person.self)
    }
}

Add this modifier to your WindowGroup in app: .modelContainer(for: [<some model class>.self, <some other model>.self]).

This will setup the whole SwiftData system and inject it into environment for usage in other components.

Query

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import SwiftUI
import SwiftData

struct PersonListingView: View {
    @Query var persons: [Person]

    var body: some View {
        List {
            ForEach(persons) { person in
                NavigationLink(value: person) {
                    VStack(alignment: .leading) {
                        Text(person.firstName + " " + person.lastName)
                            .font(.headline)
                    }
                }
            }
        }
    }    
}

@Query macro will load all data elements when view appears, watches db for changes, etc...
Also allows for sorting and filtering.

Edit

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import SwiftUI
import SwiftData

struct EditPersonView: View {
    @Bindable var person: Person
    @State private var newContact = ""


    var body: some View {
        Form {
            TextField("First name", text: $person.firstName)
            TextField("Last name", text: $person.lastName)


            Section("Contacts") {
                ForEach(person.contacts) { contact in
                    Text(contact.value)
                }

                HStack {
                    TextField("Add a new contact for \(person.firstName) \(person.lastName)", text: $newContact)
                    Button("Add", action: addContact)
                }
            }
        }
        .navigationTitle("Edit Person")
        .navigationBarTitleDisplayMode(.inline)
    }

    func addContact() {
        guard newContact.isEmpty == false else { return }

        withAnimation {
            let contact = Contact(value: newContact)
            person.contacts.append(contact)
            newContact = ""
        }
    }
}

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Person.self, configurations: config)
        let context = ModelContext(container)
        let examplePerson = Person(firstName: "First", lastName: "Last")
        context.insert(examplePerson)
        return EditPersonView(person: examplePerson)
            .modelContainer(container)
    } catch {
        fatalError("Failed to create model container.")
    }
}
  • ModelContainer is responsible for creating and managing the actual database file used for all SwiftData’s storage needs.
  • ModelContext has the job of tracking all objects that have been created, modified, and deleted in memory, so they can all be saved to the model container at some later point.
  • ModelConfiguration determines how and where data is stored, including which CloudKit container to use if any, and whether saving should be enabled or not. This configuration is provided to your model container to determine how it behaves.

Main view

@Query macro creates also _<propname> property for query itself. Does not allow simple modifcations - easiest way is to push it down one component level and set everything up in init.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import SwiftUI

struct ContentView: View {
    @Environment(\.modelContext) var modelContext

    @State private var path = [Person]()
    @State private var sortOrder = SortDescriptor(\Person.lastName)
    @State private var searchText = ""

    var body: some View {
        NavigationStack(path: $path) {
            PersonListingView(sort: sortOrder, searchString: searchText)
                .navigationTitle("ContactApp")
                .navigationDestination(for: Person.self, destination: EditPersonView.init)
                .searchable(text: $searchText)
                .toolbar {
                    Button("Add Person", systemImage: "plus", action: addPerson)

                    Menu("Sort", systemImage: "arrow.up.arrow.down") {
                        Picker("Sort", selection: $sortOrder) {
                            Text("FirstName")
                                .tag(SortDescriptor(\Person.firstName))

                            Text("LastName")
                                .tag(SortDescriptor(\Person.lastName))

                        }
                        .pickerStyle(.inline)
                    }
                }
        }
    }

    func addPerson() {
        let person = Person()
        modelContext.insert(person)
        path = [person]
    }

}

#Preview {
    ContentView()
}

List view

@Query macro creates also _<dataname> property for query itself. Does not allow simple modifcations - easiest way is to push it down one component level and set everything up in init.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import SwiftUI
import SwiftData

struct PersonListingView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: [SortDescriptor(\Person.lastName), SortDescriptor(\Person.firstName)]) var persons: [Person]


    var body: some View {
        List {
            ForEach(persons) { person in
                NavigationLink(value: person) {
                    VStack(alignment: .leading) {
                        Text(person.firstName + " " + person.lastName)
                            .font(.headline)
                    }
                }
            }
            .onDelete(perform: deletePersons)
        }
    }


    init(sort: SortDescriptor<Person>, searchString: String) {
        _persons = Query(filter: #Predicate {
            if searchString.isEmpty {
                return true
            } else {
                return $0.firstName.localizedStandardContains(searchString) ||  $0.lastName.localizedStandardContains(searchString)
            }
        }, sort: [sort])
    }

    func deletePersons(_ indexSet: IndexSet) {
        for index in indexSet {
            let person = persons[index]
            modelContext.delete(person)
        }
    }

}

#Preview {
    PersonListingView(sort: SortDescriptor(\Person.firstName), searchString: "")
}