UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Build your first app with SwiftUI and SwiftData

Learn about queries, models, containers, and more, all while building a real app.

Paul Hudson       @twostraws

In this article we're going to build a complete iOS app using SwiftUI and SwiftData, all while building a real app so you can see all the techniques in action. The app we're building is called FaceFacts, which is designed to help you remember the names, faces, and personal details of folks you meet at your workplace, school, events, and more.

Note: This project requires some Swift and SwiftUI knowledge, but I'll do my best to explain all the SwiftData things along the way. We'll be targeting iOS 17, so you need Xcode 15 or later.

BUILD THE ULTIMATE PORTFOLIO APP Most Swift tutorials help you solve one specific problem, but in my Ultimate Portfolio App series I show you how to get all the best practices into a single app: architecture, testing, performance, accessibility, localization, project organization, and so much more, all while building a SwiftUI app that works on iOS, macOS and watchOS.

Get it on Hacking with Swift+

Sponsor Hacking with Swift and reach the world's largest Swift community!

Bringing SwiftData into the project

Start by making a new iOS project called FaceFacts, making sure to choose SwiftUI for the interface. Although we'll be using SwiftData here, please leave the Storage option as None so that Xcode doesn't bring in lots of extra code we don't need.

There are three small steps required to bring SwiftData into an app:

  1. Defining the data you want to work with.
  2. Creating some storage for that data.
  3. Reading the data wherever you need it.

The first thing we'll do is design our data. This will be simple at first, but we'll add more over time. For now we'll store just three pieces of information: their name, their email address, and a free text field where you can add any extra information you want.

So, start by creating a new Swift file called Person.swift, which we'll use to store the SwiftData class describing a single person in our app. This means adding an import for SwiftData, then adding this class:

class Person {
    var name: String
    var emailAddress: String
    var details: String
}

You'll need to create an initializer for that because it's a class, but typing "in" inside the class should prompt Xcode to create one automatically for you:

class Person {
    var name: String
    var emailAddress: String
    var details: String

    init(name: String, emailAddress: String, details: String) {
        self.name = name
        self.emailAddress = emailAddress
        self.details = details
    }
}

That's just a regular Swift class right now, but we can make SwiftData load and save instances of it by adding the @Model macro at the start, like this:

@Model
class Person {

Macros let Swift rewrite our code at compile time, adding extra functionality. In the case of @Model, Swift rewrites the class so that all the properties automatically get backed by SwiftData – those aren't simple strings any more, but instead read and write from SwiftData's storage.

Tip: If you ever want to see what a macro does to your code, right-click on it and choose Expand Macro. Once you're done exploring, right-click on the macro again and choose Hide Macro Expansion.

The second step is to tell SwiftData that we want to use that Person class in our app. This is done by creating a model container for the class, which is SwiftData's way of loading and saving data from the iPhone's SSD.

To do that, open FaceFactsApp.swift, give it another SwiftData import, then add the modelContainer(for:) modifier to WindowGroup, like this:

WindowGroup {
    ContentView()
}
.modelContainer(for: Person.self)

The first time that code runs, SwiftData will create the underlying storage for all the Person objects we'll create over time, but on all subsequent runs SwiftData will load all the existing objects and continue from there. Behind the scenes this is a database, but SwiftData adds all sorts of extra features on top such as iCloud synchronization.

And now the third step is to read some data wherever we want to use it. For us that will be ContentView, at least for now, so add another SwiftData import there.

Reading information from SwiftData is done through the @Query macro, which in its simplest form just needs to be told what kind of data will be loaded. For us, that will be an array of our Person objects, so we can add this property to ContentView:

@Query var people: [Person]

That tells SwiftData to load all our Person objects into one array, and… that's literally all it takes. Even better, the array will automatically be kept up to date when the data changes in the future.

We'll look at sorting and filtering later on, but for now we're done with our SwiftData setup code and can write a little SwiftUI to show all those people in a list.

Replace the default view body with this:

NavigationStack {
    List {
        ForEach(people) { person in
            NavigationLink(value: person) {
                Text(person.name)
            }
        }
    }
    .navigationTitle("FaceFacts")
    .navigationDestination(for: Person.self) { person in
        Text(person.name)
    }
}

Yes, that navigates to a simple Text view; that's just a placeholder until we fill something more in later.

You can run the app now if you want, but I'm afraid it will be rather dull. Yes, all our SwiftData code is in place and we have some UI to show all the people we've met, but right now there's no way to actually add people!

Let's fix that next…

Adding and editing

When working with user data, it's important to give them not only the ability to add custom data, but also to edit that data - to change an existing value without deleting and re-adding.

If you look at the way Apple's Notes app solves this, you'll see it does something quite brilliant: when you add a new note, it creates an empty note immediately then navigates straight to editing it – it merges both adding and editing into a single view, and in doing so eliminating extra work.

We can take exactly the same approach here: we can create a method that creates a new, blank Person object, then immediately navigate to that for editing.

Completing this goal takes a few steps:

  1. Creating a view for editing Person data. Again, our Person class is really simple right now, but we'll be adding more later.
  2. Changing our existing NavigationStack so we can control its path programmatically.
  3. Writing a method in ContentView that creates the person then navigates to it immediately.
  4. Calling that method from a toolbar button.

And once we complete those, we can make adding and editing work for our data – we'll actually have a usable app.

To get started, press Cmd+N to make a new SwiftUI view, naming it EditPersonView. This needs to know what person we're editing, so we'll add a property store that:

var person: Person

That's immediately going to cause an error in the view's preview, but rather than correct that error I'd like you to comment out the preview just for now – we'll fix this later on, but I want to get the main app finished first.

So, just comment out the whole #Preview macro like this:

//#Preview {
//    EditPersonView()
//}

We will come back and fix this later, but for now let's stick with adding and editing.

Our current Person class has three properties we want to be able to edit: their name, their email address, and some extra details to store about them. In SwiftUI all three of those can be handled by TextField, but as you'll see there's a small speed bump here.

To see the problem, start filling in the body property with this:

Form {
    Section {
        TextField("Name", text: $person.name)
            .textContentType(.name)
    }
}
.navigationTitle("Edit Person")
.navigationBarTitleDisplayMode(.inline)

…and our speed bump strikes straight away: Swift won't understand what $person means.

When we're using a local property with @State or similar, SwiftUI automatically creates three ways of accessing the property. For example, if we had an integer called age, then:

  1. If we read age directly, we can get or set the integer.
  2. If we use $age, we access a binding to the data, which is a two-way connection to the data that we can attach to SwiftUI views. For example, if this were attached to a Stepper, changing the stepper will change the value of age, but changing the value of age will also update the stepper.
  3. If use _age we can access the State property wrapper directly, which is helpful when we need to initialize it in a custom way.

We aren't using @State in our current code, which means the person property just holds a simple value – it doesn't have a way of exposing bindings for us to use with TextField or any other SwiftUI views.

Fortunately, this is easy to fix because SwiftUI has a property wrapper that automatically creates bindings for an object. In fact, all it takes is adding that property wrapper to our property, and the problem goes away:

@Bindable var person: Person

So, our view still expects to be given a Person object to edit, but when it gets passed in SwiftUI will automatically create bindings for us – we can now use $person.name as we wanted in the first place.

With that in place, we can go ahead and fill out the rest of the form:

Form {
    Section {
        TextField("Name", text: $person.name)
            .textContentType(.name)

        TextField("Email address", text: $person.emailAddress)
            .textContentType(.emailAddress)
            .textInputAutocapitalization(.never)
    }

    Section("Notes") {
        TextField("Details about this person", text: $person.details, axis: .vertical)
    }
}

Tip: Using axis: .vertical for the details text field allows it to grow vertically as the user types more than one line.

We'll add more to that later on, but it's enough for now.

The second step in this goal is changing the NavigationStack in ContentView so we can control its path programmatically. This means creating some local state to store its path, then binding that path to the NavigationStack.

Although navigation paths can store all sorts of different objects, here we just need one type of data because we're just trying to show the person we're editing. So, our path can be an empty array of Person, like this:

@State private var path = [Person]()

And now we can bind that to our NavigationStack like this:

NavigationStack(path: $path) {

That's a two-way binding, which means as the user navigates between views our array will be updated automatically, and also that if we change the array manually the navigation stack will update to show the data we asked for.

The third step in this goal is writing a method in ContentView that creates the person then navigates to it immediately. We can actually break this down into four mini-steps:

  1. Getting access to where SwiftData stores information.
  2. Creating our data.
  3. Telling SwiftData to store it.
  4. Navigating to the editing screen.

The first of those takes us straight into an important SwiftData concept called model context.

You've already met model containers because we create one in FaceFactsApp.swift – it's responsible for loading and saving our data from the iPhone's permanent storage. Model contexts are a bit like a data cache: reading and writing everything from storage would be quite inefficient, and so SwiftData provides us with a model context that stores all the objects we're working with right now.

So, when we load objects using @Query, SwiftData fetches them all from the underlying database, and stores in its model context. We can then make all the changes we want to them, and at some point in the future SwiftData will save those changes back to the container.

This model context approach allows SwiftData to batch work together for efficiency, but it also means when create a new Person object we don't just write it to permanent storage immediately. Instead, we insert it into the model context and let SwiftData take it from there.

Here's where SwiftData does three neat things on our behalf:

  1. When we used that modelContainer(for)` modifier earlier, SwiftData silently created a model context for us to use called the main context. This always runs on Swift's main actor, so it's safe to use from our SwiftUI code.
  2. It automatically places that model context into SwiftUI's environment, so we can read it out and use it to insert our objects in the future.
  3. The @Query macro we used earlier automatically finds the model context in SwiftUI's environment, and uses it to read the data. This is how @Query is able to locate all our data without any extra work from us.

So, our first mini-step is getting access to where SwiftData stores information, which means we need to read that model context from SwiftUI's environment. This means adding a new property to ContentView:

@Environment(\.modelContext) var modelContext

I know, that was a lot of explaining for very little code, but hopefully now you understand what this model context does and where it comes from!

The second mini-step is creating our data, so add the following new method to ContentView:

func addPerson() {
    let person = Person(name: "", emailAddress: "", details: "")
}

Giving that Person object empty text for all three properties means when we display it for editing, users will see our placeholder prompts rather than some dummy text.

Our third mini-step is telling SwiftData to store that new person – just creating it isn't enough. This means inserting the person into our model context, which takes just one line of code. Put this at the end of the addPerson() method:

modelContext.insert(person)

And now for the final mini-step: now that we've created a new person and insert it into SwiftData, we need to navigate to the editing screen. This means adjusting the path property we made earlier, by adding this after the previous two lines in addPerson():

path.append(person)

But then we also need to adjust the navigationDestination() modifier we used earlier, so we navigate to EditPersonView rather than a Text view:

.navigationDestination(for: Person.self) { person in
    EditPersonView(person: person)
}

And now all that remains is the fourth and final step for this goal: calling the addPerson() method from a toolbar button.

Add this after the navigationDestination() modifier:

.toolbar {
    Button("Add Person", systemImage: "plus", action: addPerson)
}

Now go ahead and run the app, because it already works surprisingly well! You can:

  • Tap the + button to create a new person.
  • Fill in all their details.
  • Go back to the original list and see that person.
  • Tap on them and edit their details.
  • Go back and see those edits reflected in the list.
  • Quit the app and relaunch and see the data restored correctly.

Given how little actual SwiftData code we've written, it's doing a heck of a lot for us. Not only is it correctly reading and writing data for us, but it's also refreshing SwiftUI's view so they stay in sync. Nice!

Deleting people

There are four basic tasks that most database-driven tools want to implement: creating, reading, updating, and deleting, often just shortened to CRUD. We've got the first three of those already, so I guess we have CRU.

To add that extra D, we need to write a method that can delete people from the model context based on whatever data SwiftUI passes in, then attach that to an onDelete() modifier to enable swipe to delete and more.

In the same way that inserting objects is as simple as calling modelContext.insert(), deleting objects just takes a call to modelContext.delete(), telling it exactly what to delete. If you've used SwiftUI's onDelete() modifier before you'll know it passes in an IndexSet of objects to remove, so we can loop over that and call modelContext.delete() on each of them – add this to ContentView now:

func deletePeople(at offsets: IndexSet) {
    for offset in offsets {
        let person = people[offset]
        modelContext.delete(person)
    }
}

And that can then be attached directly to an onDelete() modifier – add this to the ForEach:

.onDelete(perform: deletePeople)

That's deleting done!

Searching for someone special

At this point SwiftData probably looks very easy, because it takes care of so many things on our behalf and also ties really neatly into SwiftUI.

Well, our next task is more challenging: we're going to let users filter the list of people based on a search string. This is tricky because SwiftData doesn't let us dynamically change the filter for its queries; we need to construct a new query each time.

That alone wouldn't be too hard, except for the fact that we can't change @Query property in-place – we can't adjust the query from ContentView, because although the data might change over time the query itself is read-only.

So, instead we're going to rearrange our code just a little: we'll make ContentView responsible for handling the navigation stack, including its title, navigation destination, and toolbar, but then we'll make a subview specifically responsible for running the SwiftData query. This means ContentView can also handle searching, and every time we change the search text we'll recreate the subview along with its SwiftData query.

First, add this new property to ContentView, to store whatever text the user wants to search for:

@State private var searchText = ""

Second, bind that to a search bar by adding this modifier below the toolbar we added previously:

.searchable(text: $searchText)

Now for the tricky part: we need to split ContentView up into two parts, so that the query, List, and deletePeople() parts go into a subview.

Start by making a new SwiftUI view called PeopleView. Once you have that:

  1. Add an import for SwiftData into PeopleView.swift
  2. Move the people property into there.
  3. Copy the modelContext property there – we need it in ContentView so we can insert a new person, but we also need it in PeopleView so we can delete people.
  4. Move the whole deletePeople() method into PeopleView.
  5. Move the whole List code, excluding its modifiers, into the body property of PeopleView, replacing its default code.
  6. Now put PeopleView() where the List code was in ContentView.

You can run the app again if you want, but there isn't much point – if everything has gone to plan it will look identical to what we had before, because all we've done is move the code around a little.

However, this reorganization serves an important purpose: we can create a custom initializer for PeopleView that accepts a string to search for, and use that to recreate its query.

Filtering SwiftData queries takes some very precise code, so for now we'll just put some placeholder code in there – I want to fill in the rest of the code before returning to this in more detail.

So, for now please just add this custom initializer to PeopleView:

init(searchString: String = "") {
    _people = Query(filter: #Predicate { person in
        true
    })
}

Giving the search string a default value of an empty string means we don't need to change the preview at all. However, we do want to pass in the searchString property from ContentView, so that as the user types into the search bar it automatically gets sent into PeopleView.

Adjust that code in ContentView to this:

PeopleView(searchString: searchText)

And now let's return to that initializer. This does three things all at once, and behind the scenes it leverages some of the most advanced Swift code I've ever seen.

Filtering a SwiftData query is done by applying a series of predicates, which are tests we can apply to individual objects in our data. SwiftData will hand us a single person from its data, and our job is to return true if that person should be in the final array, or false otherwise.

Now, remember that behind the scenes SwiftData stores all our information in a database. That database has no idea how to execute Swift code, so Swift does something quite magical: it is able to convert Swift code into Structured Query Language, or just SQL, which is the language used to talk to databases.

It can't convert all Swift code, of course, and in fact supports only a fairly limited subset of Swift. However, once you get the hang of how it works, you'll find these predicates work really well.

With all that in mind, let's take a look at the placeholder code we wrote earlier:

_people = Query(filter: #Predicate { person in
    true
})

That #Predicate part is another macro, which is what enables it to rewrite our code. Like I said, behind the scenes this converts all our Swift code into SQL. It doesn't do this directly, though – if you right-click on #Predicate and choose Expand Macro you'll see it gets converted into a Predicate object containing a PredicateExpressions. At runtime they then get converted into SQL and executed, but the best part is that all this happens completely transparently to us; we just don't care for the most part.

The second neat thing this code does is receive a single Person object to check, and return true straight away. That means our filter does nothing, and simply allows all people to be shown. Obviously we want something more interesting here: we want to show only those people who have a name containing the string we're looking for.

Now, we could write the predicate like this:

_people = Query(filter: #Predicate { person in
    person.name.contains(searchString)
})

But that's not ideal, because contains() is case-sensitive. You might then think to lowercase both the name and the search string, like this:

_people = Query(filter: #Predicate { person in
    person.name.lowercased().contains(searchString.lowercased())
})

But that won't even compile. Remember when I said SwiftData supports only a limited subset of Swift? This is a good example: we can't use lowercased() inside a predicate, because it's just not supported.

Fortunately, Swift provides a great alternative called localizedStandardContains(), which is the same as contains() except it ignores case by default, and it also ignores diacritics, which means it ignores things like acute accents and macrons.

So, a better way to write the predicate is like this:

_people = Query(filter: #Predicate { person in
    person.name.localizedStandardContains(searchString)
})

That's a big improvement, and almost works. But there's a problem: when the search string is empty, we want to send back everyone rather than checking whether someone's name contains an empty string.

So, the final version of this predicate will return true if the search string is empty, or otherwise call localizedStandardContains(), like this:

_people = Query(filter: #Predicate { person in
    if searchString.isEmpty {
        true
    } else {
        person.name.localizedStandardContains(searchString)
    }
})

At last, that new query gets stored in _people, which lets us access the underlying query itself. If we use people here without the underscore it would mean we were trying to change the array that gets produced by the query, not the query itself.

And now we have searching working well – you can run the app, add several users, then use the search bar to filter them correctly.

We can do better, though: if the user types part of someone's email address or details, that should be used in the filtering. So, really we want to say "if the name matches OR IF the email address matches OR IF the details match, then return true." In Swift, that means using the binary OR operator, ||, like this:

_people = Query(filter: #Predicate { person in
    if searchString.isEmpty {
        true
    } else {
        person.name.localizedStandardContains(searchString)
        || person.emailAddress.localizedStandardContains(searchString)
        || person.details.localizedStandardContains(searchString)
    }
})

It's a small but welcome improvement!

Tip: SwiftData evaluates these predicates in the order we write them, so it's generally best to arrange them in an efficient order. That might mean putting faster checks before slower checks, or putting checks that eliminate objects more effectively nearer to the start – it really depends on the app you're building.

Sorting data

Now that we have searching working, the sorting part is much easier because it builds on the same principle – just like how we can't change a query's filter dynamically, we can't change its sort order dynamically either, and so we must inject that into the PeopleView initializer along with the user's search text.

Sorting in SwiftData can be done in two different ways, but the one we're going to use here is both easy and powerful: we'll pass our query an array of a new type called SortDescriptor, which lists properties we should use for sorting and whether they should be sorted ascending or descending.

Start by adding this property to ContentView:

@State private var sortOrder = [SortDescriptor(\Person.name)]

That's an array containing a single SortDescriptor, which contains a key path point to Person.name – we're saying we want to sort our people by their names. I've marked that with @State so we can change it over time, which is exactly what we're going to do next.

To let the user change the sort order, we're going to create a picker bound to the sortOrder property we just created. Inside that picker we can then add various Text views containing the sort options we want to offer, but here's the important part: each of those views needs to have a tag containing its matching SortDescriptor array, which is what will be assigned to sortOrder when that option is selected.

I'm going to add just two options here: sorting by name alphabetically, or sorting by name reverse alphabetically. Put this into your toolbar in ContentView:

Menu("Sort", systemImage: "arrow.up.arrow.down") {
    Picker("Sort", selection: $sortOrder) {
        Text("Name (A-Z)")
            .tag([SortDescriptor(\Person.name)])

        Text("Name (Z-A)")
            .tag([SortDescriptor(\Person.name, order: .reverse)])
    }
}

Tip: Wrapping the Picker in a Menu means we get a nice sort icon in the navigation bar, rather than seeing "Name (A-Z)" up there.

That gives us all the UI to control sorting, but doesn't actually do the sorting. For that we need to adjust the PeopleView initializer so that it accepts a sort descriptor array:

init(searchString: String = "", sortOrder: [SortDescriptor<Person>] = []) {

As you can see, SortDescriptor uses Swift's generics system – this array doesn't just any sort descriptors, it contains sort descriptors for our Person class.

To actually apply that array to our query, we need to pass a second parameter into the Query, after the predicate, like this:

_people = Query(filter: #Predicate { person in
    // current predicate code
}, sort: sortOrder)

Changing the initializer didn't break our code in ContentView because we have a default value of an empty array, but that needs to change – we need to pass in the sortOrder property we made earlier, like this:

PeopleView(searchString: searchText, sortOrder: sortOrder)

And that's sorting done!

Time for relationships

At this point we have a fairly simple SwiftData app, but now I want to take it a step further and track where the user first met various people.

This means adding a second SwiftData model called Event, then linking it back to our original Person model.

First, create a new Swift file called Event.swift, add an import for SwiftData there, then give it the following code:

@Model
class Event {
    var name: String
    var location: String

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

That's a good start, but this time I want to add something extra: I want each event to store exactly who we met there. This means giving the model an extra property to store all the people we first met at that event:

var people = [Person]()

We're also going to do the opposite: we'll make each Person remember the event where we first met them, like this:

var metAt: Event?

Tip: I've made metAt optional because it won't have a value initially; users will need to select an event while editing the user.

We can also extend the initializer to include the extra value:

init(name: String, emailAddress: String, details: String, metAt: Event? = nil) {
    self.name = name
    self.emailAddress = emailAddress
    self.details = details
    self.metAt = metAt
}

This change means our connection goes both ways: each person knows where we first met them, and each event knows all the people we met.

At this point, SwiftData does three really smart things on our behalf:

  1. It sees these two model classes reference each other, so it creates a relationship between the two – if we set the metAt property of a Person, that person will automatically be added or removed from the appropriate people arrays in the Event model.
  2. Because of that relationship, it will automatically create all the database storage to handle Event objects – we don't need to adjust the modelContainer(for:) modifier, because SwiftData can see Person and Event are linked.
  3. It will automatically upgrade its database storage for the Person class to include an empty value for where we first met all the existing people we added.

Those all happen automatically – we don't even need to think about them.

Adding and editing events is partly code you've seen before, and partly new code. First, the easy part: create a new SwiftUI view called EditEventView, then give it the following property so that it knows what event it's editing:

@Bindable var event: Event

That will break the preview code again, and once more I want you to just comment that out. Don't worry, we will return to this later on to make previews work correctly!

Now you can fill in the body of the view with two text fields, like this:

Form {
    TextField("Name of event", text: $event.name)
    TextField("Location", text: $event.location)
}
.navigationTitle("Edit Event")
.navigationBarTitleDisplayMode(.inline)

Now here's where the new work comes in: we need a way to connect someone to the event where we first met them, which means adding some extra code to EditPersonView.

Start by adding a new import SwiftData at the top of the file, then add the following query to read all the events being managed by SwiftData:

@Query(sort: [
    SortDescriptor(\Event.name),
    SortDescriptor(\Event.location)
]) var events: [Event]

That sort order won't change dynamically, so we can fix it right inside the property definition.

When it comes to adding an event, we'll call a new addEvent() method that handles creating a new event, inserting it into SwiftData's model context, then navigating to it for editing. The code for that method will come in a moment, but we can at least put a little method stub in for now – add this to EditPersonView:

func addEvent() {

}

For the UI of all this, we're going to add a new section to the existing form, using a Picker to select from one of the existing events, or have an "Unknown event" option for the default value for new people. We'll also use this section to add a button calling addEvent(), so users can get to that screen easily.

Place this before the Notes section:

Section("Where did you meet them?") {
    Picker("Met at", selection: $person.metAt) {
        Text("Unknown event")

        if events.isEmpty == false {
            Divider()

            ForEach(events) { event in
                Text(event.name)
            }
        }
    }

    Button("Add a new event", action: addEvent)
}

Now we can return to that addEvent() method: this needs to create a new event, insert it into SwiftData's model context, then navigate to it for editing.

That means adding a new property to EditPersonView, so we can get access to the main model context:

@Environment(\.modelContext) var modelContext

And now we can fill in addEvent() like this:

func addEvent() {
    let event = Event(name: "", location: "")
    modelContext.insert(event)
}

However, there's a problem: how can we trigger navigation to the event, so users can edit it? Even if we had access to the path property from ContentView, we still couldn't place our new event into it because it's an array of Person – it won't accept events.

SwiftUI has a solution for this, and it's so easy to use we just need to modify one line of code. The solution is called NavigationPath, and it's a way of storing multiple navigation destinations in a single value. If you're feeling technical, it's a type-erased wrapper around our navigation destinations, which means it can hold people, events, or anything else that conforms to the Hashable protocol.

So, change the path property in ContentView to this:

@State private var path = NavigationPath()

We don't need to change the way it's used, because NavigationPath also has an append() method.

That solves one problem: we can now push to both Person and Event objects. Now for the second problem: how can we manipulate that from inside EditPersonView?

One easy option is just to pass the navigation path into EditPersonView as a binding, so we can change it directly. That means adding this new property to EditPersonView:

@Binding var navigationPath: NavigationPath

Then changing the navigation destination in ContentView so that it passes the path in:

.navigationDestination(for: Person.self) { person in
    EditPersonView(person: person, navigationPath: $path)
}

Now we can go back to addEvent() and correctly navigate to the new event by adding an extra line to the end:

navigationPath.append(event)

Last but not least, we can add another navigationDestination() modifier, this time to show EditEventView when we navigate to an event. I'd prefer to add this to the end of the form in EditPersonView because that's where the navigation happens, but it can be elsewhere if you prefer:

.navigationDestination(for: Event.self) { event in
    EditEventView(event: event)
}

That's most of the code in place, but before we look at what's missing I'd like you to run the app now.

You should notice two things:

  1. When you get to EditPersonView, Xcode's debug log will show "Picker: the selection "nil" is invalid and does not have an associated tag, this will give undefined results" on a red background, which is SwiftUI's way of telling us we screwed up.
  2. If you add a new event it will appear in the event picker correctly, but choosing it won't actually work.

Both are these are linked to the same fundamental problem, and both have the same fix: we need to attach tags to the picker values, so it understands what each option refers to.

So, change the "Unknown event" text to this:

Text("Unknown event")
    .tag(Optional<Event>.none)

Then change the ForEach to this:

ForEach(events) { event in
    Text(event.name)
        .tag(event)
}

That will silence the error message in Xcode's debug console, but it still won't make selection work. The problem here is a subtle one, and often catches folks out when working with SwiftUI: our metAt property is an optional Event, not a concrete Event, which makes it a different type.

Under the hood, Swift's optionals are implemented as a generic enum called Optional, which is designed to wrap some kind of value inside it. You can see that in the tag for the "Unknown event" text – it's not just nil because nil makes no sense in isolation, but instead it's Optional<Event>.none, which means "an optional able to wrap an event, but that right now holds nothing at all."

In our current ForEach over all our events, we're providing non-optional events as the tags, but SwiftData expects to store an optional event. Yes, we know these optionals all definitely do have a value, but it's important the type matches.

So, to make selection work we need to change the tags like this:

ForEach(events) { event in
    Text(event.name)
        .tag(Optional(event))
}

And now we have that relationship fully working – awesome!

Making previews work

There's still more work to do on this app, but at this point I want to pause for a moment to resolve an outstanding issue: how do we get Xcode's previews working?

Well, you might think we can just create an example Person or Event object and just pass it in, alongside a constant navigation path. For EditPersonView, such a solution would look like this:

#Preview {
    let person = Person(name: "Dave Lister", emailAddress: "dave@reddwarf.com", details: "")

    return EditPersonView(person: person, navigationPath: .constant(NavigationPath()))
        .modelContainer(for: Person.self)
}

However, that code doesn't work because SwiftData is sneaky: as soon as you call the Person initializer, it silently looks for whatever is the currently active model container to make sure everything is configured correctly.

In our preview code, we create the model container only after creating a sample person, which means our preview won't work – it will in fact just crash.

Fixing this means creating a model container before creating sample data, but while we're there we also want to enable a custom configuration option that tells SwiftData to store its data in memory only. That means anything we insert into the model container is just temporary, which is perfect for previewing purposes.

This takes quite a few lines of code, and because we need it in more than one place we're going to isolate this functionality in a new struct called Previewer. This will have the job of setting up an example container and creating some previewable data, so we can use that shared code wherever we want previewing to work.

So, create a new Swift file called Previewer.swift, add an import there for SwiftData, then give it this code:

@MainActor
struct Previewer {
    let container: ModelContainer
    let event: Event
    let person: Person

    init() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try ModelContainer(for: Person.self, configurations: config)

        event = Event(name: "Dimension Jump", location: "Nottingham")
        person = Person(name: "Dave Lister", emailAddress: "dave@reddwarf.com", details: "", metAt: event)

        container.mainContext.insert(person)
    }
}

There are a few important details I want to pick out in there:

  1. Because SwiftData's main context always runs on the main actor, we need to annotate the whole struct with @MainActor to ensure it also runs there.
  2. Making SwiftData store its data in memory means using ModelConfiguration. This has various useful options, but right now all we care about is that it's not storing data permanently.
  3. Creating a model container ourselves is a throwing operation, so I've made the whole initializer throwing rather than try to handle errors here.
  4. Two example pieces of data are created, but only one is inserted. That's okay – again, SwiftData knows the relationship is there, so it will insert both.
  5. The container, person, and event are all stored in properties for easier external access. I've made them all constants because it doesn't make sense to change them once they have been created.

With that in place, we can now return back to EditPersonView and fill in its preview correctly:

#Preview {
    do {
        let previewer = try Previewer()

        return EditPersonView(person: previewer.person, navigationPath: .constant(NavigationPath()))
            .modelContainer(previewer.container)
    } catch {
        return Text("Failed to create preview: \(error.localizedDescription)")
    }
}

Notice how we handle errors by sending back some text, but also how we use a slightly different form of the modelContainer() variant – we're passing in an existing container created from our previewer, rather than making a new one here.

We can also go to EditEventView, which looks very similar:

#Preview {
    do {
        let previewer = try Previewer()

        return EditEventView(event: previewer.event)
            .modelContainer(previewer.container)
    } catch {
        return Text("Failed to create preview: \(error.localizedDescription)")
    }
}

If you wanted to have good previews everywhere, you should also adjust the preview for ContentView to this:

#Preview {
    do {
        let previewer = try Previewer()
        return ContentView()
            .modelContainer(previewer.container)
    } catch {
        return Text("Failed to create preview: \(error.localizedDescription)")
    }
}

And the preview for PeopleView to this:

#Preview {
    do {
        let previewer = try Previewer()
        return PeopleView()
            .modelContainer(previewer.container)
    } catch {
        return Text("Failed to create preview: \(error.localizedDescription)")
    }
}

That should now mean all your previews should show some meaningful example data.

Importing a photo

At this point we have a pretty good app in place, but there's another major feature I want to add: I want users to be able to import photos of the people they've met, to make them easier to remember.

SwiftData actually handles this rather elegantly: rather than storing big image blobs right inside our database, we can suggest to SwiftData that they be stored as separate files, then just reference their filename from the database.

We don't need to do all that naming and referencing; SwiftData takes care of all that for us. Instead, we just need to tell SwiftData that a particular property would work best as external storage. This is done with yet another macro, this time @Attribute, and it's attached directly to properties we want to customize.

In order to write pictures out to disk, we need to store them as optional Data instances. So, add this property to Person now:

@Attribute(.externalStorage) var photo: Data?

As you can see, that specifically tells SwiftData this property would work best stored externally. Note that this is a suggestion – SwiftData is free to do whatever it thinks is best, but honestly it doesn't matter to us because the whole storage system is completely opaque.

Now that we have somewhere to store a person's photo, we can build some UI to select and display that photo inside EditUserView.

If you've used SwiftUI's PhotosPicker view before you'll know how this is done, but if not let's walk through the steps together.

First, we need to add another import to EditPersonView, this time for the PhotosUI framework:

import PhotosUI

We can then add a property to EditPersonView to store the user's selection:

@State private var selectedItem: PhotosPickerItem?

Now we can bind that to a PhotosPicker view, which will take care of showing all the photo selection UI for us. Place this section at the start of the form, before asking for the person's name or email address:

Section {
    PhotosPicker(selection: $selectedItem, matching: .images) {
        Label("Select a photo", systemImage: "person")
    }
}

When it comes to handling photo selection, we can assign the result of loadTransferable(type:) directly to the photo property of the person we're editing, but it's really important that this happens on the main actor to avoid threading problems.

So, add this method to EditPersonView, to handle loading the photo data safely:

func loadPhoto() {
    Task { @MainActor in
        person.photo = try await selectedItem?.loadTransferable(type: Data.self)
    }
}

That needs to be called whenever the selectedItem property changes, which means attaching an onChange() modifier below the navigationDestination() from earlier:

.onChange(of: selectedItem, loadPhoto)

At this point we have in place all the code required to select and load a user photo, so now we just need to put it on the screen somewhere. SwiftUI's Image view doesn't have a native way of loading image data, so we need to bounce it through a UIImage first.

Add this to the first Section, before the PhotosPicker:

if let imageData = person.photo, let uiImage = UIImage(data: imageData) {
    Image(uiImage: uiImage)
        .resizable()
        .scaledToFit()
}

And that's done! Users can now import a photo for a user, and it's automatically saved as an external file by SwiftData. Notice how we haven't had to make any sort of special accommodation for the external storage – it's all just taken care of for us automatically.

One last thing: storing data in iCloud

Before we're done with this project, I want to add one last feature: I want to let users upload their data to iCloud, so they can have all the people and events on all their devices.

This is actually fairly straightforward, because SwiftData takes care of almost all of it for us. There is one catch, though: iCloud has special data requirements that SwiftData doesn't, and we need to follow these if we're to sync our data to iCloud.

To get started, select your app's target, then go to the Signing & Capabilities tab. Here you need to:

  • Press + Capability and choose iCloud.
  • When that loads, check the box next to CloudKit.
  • Check the box next to an existing CloudKit container, or press + to make a new one. Containers should be called "iCloud." followed by your bundle identifier, which for me means "iCloud.com.hackingwithswift.FaceFacts".
  • Press + Capability again, this time choosing Background Modes.
  • When that loads, check the box next to Remote Notifications, so our app can be notified when new updates are available in the cloud.

That completes the configuration changes, so now I'd like you to run the app again. This time you'll see Xcode's log full of debug information, because CloudKit really loves just spewing text down there.

However, if you scroll up near the top you'll see a bunch of warnings in yellow – these are the ones telling us CloudKit can't be used because our model classes don't follow its rules. Specifically, we need to make sure every property has a default value, and all relationships be marked as optional.

In this app it's really easy to make all these changes. For Person, we can give all the strings a default value of an empty string:

var name: String = ""
var emailAddress: String = ""
var details: String = ""

And for our Event class, we can do the same for the strings there, but then make the people array optional like this:

var name: String = ""
var location: String = ""
var people: [Person]? = [Person]()

And with that the iCloud errors will go away, because our project is now ready to sync with the cloud. In fact, if you try using it straight away you'll see it works great, including synchronizing images we attach to people.

Now, there is one important proviso here, and no matter how many times I say it I still get folks completely ignoring me: using iCloud in the simulator regularly misbehaves or fails entirely, and the only reliable way to test synchronization is to use physical devices.

That's not to say the situation is always perfect on devices, mind you – the plague of developers' lives is the dreaded CloudKit Error 500, which is Apple's way of saying the SwiftData syncing completely failed. If this happens to you during development, the best way to fix it is to log in to Apple's CloudKit Dashboard, selecting the container you're using, then pressing Reset Environment.

Where next?

At this point we've covered a huge amount of ground, and built something that uses SwiftUI, SwiftData, PhotosUI, relaionships, external storage, sorting, filtering, previewing, and more.

There's still a lot more we could add to this app. For example, one place to start would be to add a TabView so users can move between the current list of people and an alternative list of all events – it would allow them to see everyone they met at a particular event, but also to edit and delete existing events.

You could also lets users track the date they met someone, or link different people together, or add more sorting options, or add a UI that works better on iPad, or use SwiftUI's ContentUnavailableView to show something meaningful when the app is launched with no people added, and more – there are lots of potential directions you could take this if you wanted, and I hope you do!

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.