Migrating a simple structure from UserDefault to SwiftData

Thomas Ricouard
4 min readSep 22, 2023

--

Photo by benjamin lehman on Unsplash

The iOS 17 SDK introduced a powerful new tool to save and restore user data easily, SwiftData.

SwiftData is a high-level abstraction layer over a battle-tested technology CoreData. But SwiftData makes it dead simple: you don’t need a model editor, you don’t need to know about database, SQLite, or anything like that. You write your models entirely in code, register them in the context, and then do your queries directly from your SwitftUI views.

In Ice Cubes, when you start to compose a new post and then close the composer, you’re asked if you want to save the current text in your draft.

Until now, this was saved as part as UserDefaults in a simple [String].

While this was working fine, my user base is asking for drafts to be able to save attachments and all other kinds of information that definitely won’t be in a collection of strings. Anything more complex than this is definitely not a good fit for UserDefaults.

 @AppStorage("draft_posts") public var draftsPosts: [String] = []

Hence, I decided that with the recent bump of Ice Cubes to iOS 17+ and the migration to the Observation framework, it was time to add a bit of SwiftData in the codebase. I’ll then be able to add useful properties to drafts down the line.

Firs things first, let’s create the model

@Model public class Draft {
@Attribute(.unique) public var id: UUID
public var content: String
public var creationDate: Date

public init(content: String) {
self.id = UUID()
self.content = content
self.creationDate = Date()
}
}

The @Model Swift macro will automatically generate all the code that will allow your model to be saved in SwiftData. It’ll also make it Observable, so any changes done to this model will be reflected live in your SwiftUI views.

I also use the @Attribute macro, which marks the id as the unique identifier of this model type. It’s more the example in this article, it’s not strictly necessary as @Model are already automatically Identifiable.

Next, we need to register our model type with the SwiftData context and add it to our app. I already have what I call an AppRegistry, which registers all kinds of things with the app (like environments), so I’ve added the model container registry here.

@main
struct IceCubesApp: App {
var body: some Scene {
WindowGroup {
appView
.withModelContainer()
}
}
}

func withModelContainer() -> some View {
modelContainer(for: [
Draft.self,
])
}

Now we can start to work on our UI and queries.

The query for the list of drafts in the GIF above is the following, all the drafts sorted by their creationDate, from the newest to the oldest.

  @Query(sort: \Draft.creationDate, order: .reverse) var drafts: [Draft]

The drafts array will get updated, and then update the body of your view when a draft is added or deleted.

struct DraftsListView: View {  
@Environment(\.modelContext) private var context

@Query(sort: \Draft.creationDate, order: .reverse) var drafts: [Draft]

var body: some View {
NavigationStack {
List {
ForEach(drafts) { draft in
Text(draft.content)
}
.onDelete { indexes in
if let index = indexes.first {
context.delete(drafts[index])
}
}
}
}
}
}

In the code above, we’re building a simple list of drafts from the query. Note how I retrieved the modelContext from the environment, using the modelContext EnvironementKey

By adding a .onDelete on the ForEach I can then delete the model at the specific index from the context. It translates to a swipe to delete feature on the List.

One more thing, in this view, I want to migrate the previous drafts that were saved in UserDefault to the new SwiftData container.

@AppStorage("draft_posts") public var legacyDraftPosts: [String] = []

.onAppear {
migrateUserPreferencesDraft()
}

func migrateUserPreferencesDraft() {
for draft in legacyDraftPosts {
let newDraft = Draft(content: draft)
context.insert(newDraft)
}
legacyDraftPosts = []
}

This would be attached to the DraftsListView in the previous code snippet. As the view appears, I execute a migration that takes the existing drafts in UserDefault (using the @AppStorage property wrapper I was using prior to SwiftData) and inserts them in the SwiftData container as Draft instances. I then delete the drafts in UserDefault, so the next time that code is executed, it’ll do nothing. I’ll simply need to not forget to remove it in a few weeks.

If you want to read the code in more detail, the PR is available on the repository of Ice Cubes.

And if you want to try the app out, you can download it from the App Store

Thanks for reading! 🚀

--

--

Thomas Ricouard

📱 🚀 🇫🇷 [Entrepreneur, iOS/Mac & Web dev] | Now @Medium, @Glose 📖| Past @google 🔍 | Co-founded few companies before, a movies 🎥 app and smart browser one.