Learn about queries, models, containers, and more, all while building a real app.
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.
SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.
Sponsor Hacking with Swift and reach the world's largest Swift community!
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:
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…
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:
Person
data. Again, our Person
class is really simple right now, but we'll be adding more later.NavigationStack
so we can control its path programmatically.ContentView
that creates the person then navigates to it immediately.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:
age
directly, we can get or set the integer.$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._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:
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:
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.@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:
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!
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!
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:
people
property into there.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.deletePeople()
method into PeopleView
.List
code, excluding its modifiers, into the body
property of PeopleView
, replacing its default code.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.
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!
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:
metAt
property of a Person
, that person will automatically be added or removed from the appropriate people
arrays in the Event
model.Event
objects – we don't need to adjust the modelContainer(for:)
modifier, because SwiftData can see Person
and Event
are linked. 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:
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.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!
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:
@MainActor
to ensure it also runs there.ModelConfiguration
. This has various useful options, but right now all we care about is that it's not storing data permanently.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.
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.
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:
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.
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!
SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.