SwiftData Techniques Instruction
Until now, you’ve built an app that stores and persists data about your furry friends. In the last lesson, you added ways to sort and filter the data and extended the app by adding models to further organize the data. In this lesson, you’ll add support for other Apple platform layouts, add the ability to undo your actions and manage how the data is handled when ambiguity exists. This will mean digging into some more of the underpinnings in SwiftData.
Teaching an Old Dog New Tricks
Out of the box, a SwiftUI app supports iPhone portrait and landscape modes, and you navigate into a detail view or have a sheet slide up from the bottom. That’s a pretty convenient user experience. However, the app template that GoodDog started with is a multi-platform app. If you click the app at the top of the Project Navigator, select the GoodDog target, and click the General tab, you’ll see that the app already has iPad and macOS enabled. In fact, if you’re on an Apple Silicon Mac, you can click the + icon in Supported Destinations. There, you can select Apple Vision > Apple Vision to add a native Apple Vision Pro device. It’s really that simple to convert this app to run natively on visionOS.
Note: At the time of this writing, Xcode 15.1 and later don’t support building visionOS on Intel Macs. There are also some general guidelines for visionOS window styling and UI sizing. Have a look at Apple’s HIG for visionOS to add polish to your app here: Designing for visionOS
However, support for wider devices like iPad, macOS and visionOS isn’t great with the current app layout. Fortunately, iOS 16 introduced the NavigationSplitView, which has a basic two-column layout that fits this app. Using the init(sidebar:detail) initializer, you put your main DogListView into the sidebar and add the individual EditDogView into the detail.
// example NavigationSplitView setup
var body: some View {
NavigationSplitView {
List {
ForEach(dogs) { dog in
Text(dog.name)
}
}
} detail: {
EditDogView(dag: selectedDog)
}
}
You can also combine some code to prompt the user to choose a dog, along with some code to show the selectedDogin the view. This makes for a compelling app on platforms that support larger displays.
Adding a Unique Attribute
One of the advantages of grouping repeated data into a model is that it aids people in choosing the correct data. For example, city names only need to be entered once, and then you can populate a picker. Having uniform shared data can also prevent user input errors. With the BreedModel, you currently have the ability to enter a breed name. However, it can lead to errors and mismatches. Is the rule “i before e” in spelling the name Retriever? What if someone enters a second or third name with misspellings. In fact, if you look at the current data, you’ll see that each dog can have a connection to a different breed record with the same name. Currently, nothing is preventing multiple entries like this:
This is were the .unique attribute option in the data model comes into play. The unique attribute ensures that a value is unique in the model. Here the breed.name would only be added once.
@Attribute(.unique) var name: String
When a person enters a duplicate name, SwiftData will treat it as an upsert (insert + update). Under the hood, SwiftData will perform an update instead of an insert since the identical data already exists.
Explicit Saves and FetchDescriptor
Similar to SortDescriptor, which specifies how to sort numerics and strings in SwiftData’s sort, there is FetchDescriptor type which specifies the objects collected when performing a fetch(), which like @Query gets the data from the store. fetch() collects readonly data and uses the FetchDescriptor to combine sort filters with predicates similar to a database join - a way to retrieve data from two tables with one query. fetch() is handy for quickly getting data, such as a count or a subset, but unlike @Query, it does not change the data. If you want to save with fetch(), SwiftData objects have an .id modifier, so you can use the .id(refresh) to trigger an update in your view. fetch() has .sortBy and .fetchLimit to get a small set of records. @Query does not have limit capability.
Earlier, you saw that using modelContext(save) can also explicitly save the objects in the modeContext. This is necessary when autosave is disabled, but it can be used at any time to save the context’s contents.
Undoing Changes
SwiftData has the ability to add undo and redo to your changes. However you’ll need to enable it. Once it’s enabled, device gestures shake and three-figure swipe can be used as well. You can enable the UndoManager as a property in the app’s .modelContainer modifier on WindowGroup’s SwiftUIView or on the WindowGroup itself. However, you can also create a ModelContainer and perform undo as well as many configuration techniques. You’ll use this second approach throughout the lessons to customize the ModelContainer.
Recap: The ModelContainer, combines the model and the data values and sets them up as data objects in a ModelContext. The ModelContainer also manages where the data is stored on disc. The data on disc persists even when the power is off. SwiftData uses the ModelContext in memory so you can create, change, and delete data objects.
Similar to how you added the mock data in the DogModel, you first used the mainContext property to access ModelContainer’s ModelContext. Recall that the modelContext is where the changes live and are changed before saving to the persistent store. The mainContext is a handle into the ModelContext. To enable undo, you use this to add the UndoManager to your app’s modelContext.
// again just sample code
var container: ModelContainer {
let container = try! ModelContainer(for: [DogModel.self]
container.mainContext.undoManager = UndoManager()
return container
}
Note: You’ll add a do try catch to the above when you add it to the app. This example is for clarity. By the way, disabling autoSave would look the same. container.mainContext.autosaveEnabled = false
You’ll be setting more configurations this way in the lesson.
Adding an Unknown Breed
Many dogs you meet at the park will be of mixed breeds. Some owners have no idea what type of dog they have without a DNA test. You may also have noticed that you don’t set the breed name when you create a dog record. This is a common problem with data entry. You may not know all the values. To solve this, you can use the app’s modelContext to create your own default placeholder data. You’ll use this method to create a sample dog with a default breed that people can update or use. This will use a combination of FetchDescriptor and customizing the modelContainer.
Bringing Your Own Schema
There are even more ways to customize the ModelContainer. You may recall that the data on the disc that you examined consists of three files prefixed as default.store. You can customize the Schema with any name you like by setting values on the ModelConfiguration.
let schema = Schema([DogModel.self])
let config = ModelConfiguration("GoodDogs", schema: schema)
let container = try! ModelContainer(for: schema)
The code above will create the data store with the name GoodDogs. Schema is how model is defined. ModelConfiguration contains the schema or model settings. ModelContainer connects all of the schemas, model configurations, and storage.
If you want, you can set the storage path for the data as well in a configuration.
/* specify a custom path */
let storeURL = URL.libraryDirectory.appending(path: "Private Documents")
let config = ModelConfiguration(schema: schema, url: storeURL.appending(path: "GoodDogs.sqlite"))
You’ll be looking more into ModelConfiguration later in the course. For now, head to the demo for how to implement this changes.