Welcome to this practical guide on how to manage data using SwiftData, a new data persistence framework that streamlines the process of Create-Read-Update-Delete (CRUD) operations in a SwiftUI project. Are you tired of wrestling with CoreData and looking for a simpler and more efficient way to handle your app’s data? If yes, then SwiftData might just be the solution you’ve been searching for.
SwiftData is designed to provide a smoother and more intuitive experience when it comes to managing your app’s data. It is like CoreData’s younger sibling but with a refreshing simplicity and robustness that makes it a joy to work with, especially in SwiftUI projects. In this post, I’ll take you through the process of implementing CRUD operations using SwiftData.
Through a hands-on approach, I will guide you on how to manage and interact with your stored data. I am talking about schema definitions in a separate blog post about Modeling Data in SwiftData.
I’ll use a demo project called ‘SnippetBox’ – a tool that helps programmers save and organize frequently used code snippets – to bring the concepts to life.
⬇️ download the project from GitHub https://github.com/gahntpo/SnippetBox-SwiftData
By the end of this guide, you’ll have a solid understanding of how to set up SwiftData, create new instances of data objects, perform updates, deletions, and fetch data, and even animate and sort your data. So let’s get started and dive into the world of SwiftData!
Requirement: Xcode 15
SwiftData Basics
The SwiftData framework is made up of three main parts: the model definition, the model container, and the model Context. These components work together to handle and manage your app’s data effectively.
- Model Definition: This is where you define your data types or entities. Think of it as a database schema. Each model class represents a type of object that will be stored in your app.
- Model Container The model container is responsible for managing the persistent storage of data. It connects the context (the part of the stack where data manipulation happens) with the actual data store, which is an SQLite database.
- Model Context: The model context is the interface through which you interact with your stored data. It’s like a scratch pad for your managed objects (instances of your entities). Here, you can fetch, modify, and delete these objects. Any changes made in the context are local until the context is saved, which pushes the changes to the persistent store.
All interactions and CRUD (Create-Read-Update-Delete) operations are done with the model context. You will see how much we rely on it in the next sections.
You can find more details about the SwiftData stack here.
Demo Project: SnippetBox
I am using a SnippetBox project as a demo. SnippetBox is a handy tool designed to help programmers save and organize pieces of code that they use frequently. Think of it as a special box where you can store all your precious code snippets. With SnippetBox, you can easily add, edit, and categorize your snippets by language or tags. You can even group them into folders for better organization. No more wasting time searching for that piece of code you wrote a while ago. With SnippetBox, your code snippets are always just a few clicks away. Let’s dive in and see how it works!
I added different property types for Snippet like Date, UUID, and Bool. Each Snippet can belong to one folder and a folder can have many snippets, which is represented by a one-to-many relationship. On the other hand, each snippet can have many tags, and a tag can be added to many snippets, which is a many-to-many relationship. In the following graph, you can see the schema.
Setting up SwiftData in a SwiftUI Project
You can now set up the modelContainer for your SwiftData project in the main app file. The modelContainer is a modifier that ensures all windows in the group are configured to access the same persistent container.
import SwiftUI
import SwiftData
@main
struct SnippetBoxApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: [Snippet.self, Folder.self, Tag.self])
}
}
}
This not only sets up your container but also creates and sets a default ModelContext in the environment. The ModelContext is used to track changes to instances of your app’s types and can be accessed from within any scene or view using the environment property. You can access the model context in any of your views from the environment:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
var body: some View {
...
}
}
Creating new Object Instances in SwiftData
When you define your model classes, you have to set initialisers. In the following is an example for the Folder type:
import Foundation
import SwiftData
@Model final public class Folder {
var creationDate: Date
var name: String
var uuid: UUID
@Relationship(.cascade, inverse: \Snippet.folder) var snippets: [Snippet]
init(name: String = "",
snippets: [Snippet] = []) {
self.creationDate = Date()
self.uuid = UUID()
self.name = name
self.snippets = snippets
}
}
You can then create new objects and insert them into the model context to persist it like so:
let folder = Folder(name: "new folder")
context.insert(folder)
For example, I added a `New Folder` button in the folder list view like so:
struct FolderListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Folder.creationDate, order: .forward)
var folders: [Folder]
@Binding var selectedFolder: Folder?
var body: some View {
List(selection: $selectedFolder) {
ForEach(folders) { folder in
NavigationLink(value: folder) {
FolderRow(folder: folder,
selectedFolder: selectedFolder)
}
}
.onDelete(perform: deleteItems)
}
.navigationTitle("Folders")
.toolbar {
...
}
}
private func addFolder() {
withAnimation {
let folder = Folder(name: "new folder")
context.insert(folder)
selectedFolder = folder
}
}
private func deleteItems(offsets: IndexSet) {
...
}
}
When you create objects that you want to add a relationship to already stored objects, you dont need to insert it in the context. SwiftData understands that you add an object to the context. Here is an example, where I create a new Snippet instance and add it to a folder:
let folder: Folder
func addItem() {
let snippet = Snippet(title: "new snippet")
folder.snippets.append(snippet)
}
To save this folder and future changes, SwiftData uses an implicit save feature that triggers on UI lifecycle events and on a timer after the context changes. This works in conjunction with SwiftUI´s updating mechanism.
Deleting Objects in SwiftData
You use the context to delete objects in SwiftData:
@Environment(\.modelContext) private var context
private func delete(folder: Folder) {
context.delete(folder)
}
Also, all objects have a context property that gives you the context they belong to. Here is how you could use it to streamline you delete function:
private func delete(folder: Folder) {
if let context = folder.modelContext {
context.delete(folder)
}
}
How to update SwiftData objects
Updating model objets is very simple. You set the property of the object to the new value. For example, if you want to change the ´isFavorite´ property of a snippet object, you can set it to true like so:
import SwiftUI
struct SnippetDetailView: View {
var snippet: Snippet
var body: some View {
Button {
snippet.isFavorite = true
} label: {
Label("Mark as Favorite", systemImage: "heart")
}
...
}
}
SwiftData uses the new Observation API from SwiftUI. When you add the ´@Model´ macro to your classes, they will conform to the new Observable protocol. This API is much better at updating SwiftUI views when model properties changes. Thus, you should benefit from a better performance when using SwiftData.
I you want to work with a view like `Toggle `in SwiftUI that requires a binding, you can use the `@Bindable` property wrapper which is part of the new Observation feature:
import SwiftUI
struct SnippetDetailView: View {
@Bindable var snippet: Snippet
var body: some View {
Toggle(snippet.isFavorite ? "favorite" : "not",
isOn: $snippet.isFavorite)
...
}
}
When passing the snippet to SnippetDetailView, you simply pass the instance without the binding “$” indicator:
struct ContentView: View {
@State private var selectedFolder: Folder? = nil
@State private var selectedSnippet: Snippet? = nil
var body: some View {
NavigationSplitView {
FolderListView(selectedFolder: $selectedFolder)
} content: {
if let folder = selectedFolder {
SnippetListView(for: folder, selectedSnippet: $selectedSnippet)
} else {
Text("Placeholder")
}
} detail: {
if let snippet = selectedSnippet {
SnippetDetailView(snippet: snippet)
} else {
Text("Placeholder")
}
}
}
}
Fetching and Filtering with SwiftData
All-access to your data in SwiftData happens with the model context. For example to fetch all folders in the database, fetch the data from the context:
var folders = try context.fetch(FetchDescriptor<Folder>())
In SwiftUI you can fetch all folders with the @Query
property wrapper.
import SwiftUI
import SwiftData
struct FolderListView: View {
@Query(sort: \Folder.creationDate, order: .forward) var folders: [Folder]
var body: some View {
List {
ForEach(folders) { folder in
FolderRow(folder: folder)
}
}
}
}
This is a standing fetch request, which means it will always have the newest data. In contrast, the above fetch from context is only a snapshot of the database for the time you executed the fetch. It is not recommended by Apple to use view models and the MVVM pattern. Continuously fetching data from a view model is impossible because @Query
only works inside a SwiftUI view and we don’t have an equivalent of NSFethedResultsController
like in Core Data that allows for continuous fetching.
How to sort data in SwiftData
You can customize the way data is fetched with @Query. This is the most basic version:
@Query var folders: [Folder]
However, the order of the folders will not be consistent. I found that it can vary between launches and view appearances. It is thus best to specify how you want the resulting fetch sorted like so:
@Query(sort: \Folder.creationDate, order: .forward) var folders: [Folder]
Alternatively, you can use the new SortDescriptor type to do the same:
@Query(sort: [SortDescriptor(\Folder.creationDate)]) var folders: [Folder]
How to filter and search data
Additionally, you can use @Query to filter your data. This works with the new #Predicate macro. For example, I want to show all snippets that are marked as favourites:
import SwiftUI
import SwiftData
struct SnippetListView: View {
@Query(filter: #Predicate<Snippet> { $0.isFavorite },
sort: [SortDescriptor(\Snippet.creationDate)] )
var snippets: [Snippet]
var body: some View {
List(snippets) { snippet in
SnippetRow(snippet: snippet)
}
}
You can find more examples on filtering a searing in this blog post: How to fetch and filter data in SwiftData with Predicates
Animating Changes
You can also set how the appearance and disappearance of SwiftData objects are animated. In the following, I am using a smooth animation style:
import SwiftUI
import SwiftData
struct FolderListView: View {
@Query(sort: \Folder.creationDate, order: .forward, animation: .smooth)
var folders: [Folder]
var body: some View {
LazyVStack {
ForEach(folders) { folder in
FolderRow(folder: folder)
}
}
}
}
Note that I used a `LazyVStack` instead of a `List` in order to see the effects of the animation property. The List view has a predefined animation behavior which we can not change as developers.
How to undo/ redo changes in SwiftData
SwiftData offers an easy way of adding undo/redo functionality. You have to change the model container configuration to enable undo like so:
import SwiftUI
import SwiftData
@main
struct SnippetBoxApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: Snippet.self, isUndoEnabled: true)
}
}
}
This will add a UndoManager to your SwiftData stack that you can access from the model context. UndoManager has functions for undo and redo. The following is an example of an undo button in SwiftUI:
import SwiftUI
struct UndoButton: View {
@Environment(\.modelContext) private var context
var body: some View {
Button(action: {
context.undoManager?.undo()
}, label: {
Text("Undo")
})
}
}
Warning: The app crashed when I used the undo feature with SwiftData and added custom types and enum properties to my models. I got the following error: “Fatal error: expected attribute to be Codable”. Often SwiftData produces wired bugs that are caused by a combination of things. Thus making it hard to troubleshoot.
Conclusion
SwiftData, a new data persistence framework, is designed to streamline the process of handling and managing data in your apps. It effectively replaces CoreData, improving the performance of your apps and making it easier to handle data persistence.
Throughout this blog post, we have explored how SwiftData organizes data through a model definition, a model container, and a model Context. The model context, acting as a scratch pad, is where we interact with the stored data, facilitating data manipulation. It plays a unique role in the CRUD (Create, Read, Update, Delete) operations.
Implementing SwiftData in a SwiftUI project involves setting up the modelContainer in the main app file and accessing its model context through the environment property in the views. From there, you can create, delete, update, fetch, and filter objects using SwiftData.
Overall, SwiftData offers a powerful, yet intuitive, way to handle data persistence in Swift applications. I can already see how much potential it will have in the upcoming years. At the moment of writing, I would still recommend using CoreData because it is more mature and reliable.
Further Reading:
I’ve been struggling with SwiftData and its lack of good examples. Your examples are head and shoulders above them all.
Thanks…
I like the sentence:
“It is not recommended by Apple to use view models and the MVVM pattern.”
I have used MVVM for my project and I migrated to SwiftData without having implemented CoreData before. I refresh a main array of @Model class after insert/delete in SD or on demand (refresh list), and for the moment no problem.
I have struggled a little bit to make it work arrays of struct in my Class, and outside the storage process, I don’t use the @Model class but a clone struct instead because big arrays in @Model class seem to be very slow to append …
Many thanks for your clear, concise and imaginative example. There not a lot of guidance available on the latest iOS improvements. Your example is a great help.