Getting started with RealmSwift

10 min read 2803

Getting started with RealmSwift

Overview

Data persistence is an important feature that all apps should have in order to save important data, such as the most recent feeds for fast loading, user preferences, and server-critical information. It is critical to properly manage local data in order to avoid losing data and providing an inconsistent experience.

In this article, we’ll learn how to use Realm as the data persistence engine with SwiftUI to manage your data in iOS apps in a simple way.

We will create to-do apps with the following features:

  • Automatic to-do list refreshing based on data changes using SwiftUI and Combine
  • Listing, storing, editing, and deleting tasks in the Realm local database
  • Modifying schema using Realm migration

Please note that this tutorial is using Xcode 12, Swift v5, and iOS 14.

Why Realm?

Let’s take a look at the main reasons you’ll benefit from using Realm before we begin our development.

  • Lightweight mobile database with an object-oriented data model — no ORM necessary!
  • Simple to use — you’ll spend less time setting Realm up, writing queries, creating nested objects, etc.
  • Easy to learn with comprehensive documentation and wide community support
  • Support for multiple platforms makes it easier to synchronize database structure across platforms

Setting up your SwiftUI project

Open Xcode and create a new SwiftUI project.

Creating a new Swift project

Installing the Realm SDK

In the Xcode menu, go to File > Swift Packages > Add Package Dependency, and enter the Realm repository URL as shown below.

https://github.com/realm/realm-cocoa

Click Next, and it will redirect to this screen. The latest version of this package is v10.15.1.

Adding the RealmSwift package

We made a custom demo for .
No really. Click here to check it out.

In this screen, check both Realm and RealmSwift packages.

Choose both the Realm and RealmSwift packages

Create a to-do model

Let’s create a to-do model called Task with the Identifiable protocol.

struct Task: Identifiable {
    var id: String
    var title: String
    var completed: Bool = false
    var completedAt: Date = Date()
}

Creating the main list view

In this section, we will create a list view and the reusable item view.

TaskRowView

Add a new SwiftUI View file called TaskRowView and update it with the below code.

struct TaskRowView: View {
    // 1
    let task: Task
    var body: some View {
        // 2
        HStack(spacing: 8) {
            Button(action: {
                // mark complete action
            }) {
                Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
                    .resizable()
                    .frame(width: 20, height: 20)
                    .foregroundColor(task.completed ? Color.green : Color.gray)
            }
            Text(task.title)
                .foregroundColor(.black)
            Spacer()
        }
        .padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20))
    }
}

Below is the details of the code written above:

  1. task is a view dependency data that is required during view initialization.
  2. The view contains a Button to mark the task completion status and a Text for the task title, which are managed in the horizontal stack.

Creating the task list view

In this project, I’ll use LazyVStack with ScrollView. LazyVStack is only available for iOS v14 and above, but is known as one of the great SwiftUI components for listing items.

Initially, we will use sample to-do data before we integrate with Realm.

Create a new file called TaskListView to show the list of to-dos.

struct TaskListView: View {
    // 1
    private var mockTasks = [
        Task(id: "001", title: "Eat Burger"),
        Task(id: "002", title: "Go Swimming with Fred"),
        Task(id: "003", title: "Make a Coffee"),
        Task(id: "004", title: "Travel to Europe"),
    ]
    var body: some View {
        ScrollView {
            LazyVStack (alignment: .leading) {
                // 2
                ForEach(mockTasks, id: \.id) { task in
                    // 3
                    TaskRowView(task: task)
                    Divider().padding(.leading, 20)
                }
                .animation(.default)
            }
        }
    }
}

Here’s the details on what we have written above:

  1. As you can see, some mock data is used before we integrate with Realm database.
  2. The TaskRowView is called in the ForEach closure to display each of the mockTasks items
  3. Finally, we pass the task object into TaskRowView.

Updating ContentView

Once we have finished creating these two task-related views, we need to update the main ContentView file to include the NavigationView and the newly created TaskListView. The code below will also add a navigation title.

struct ContentView: View {
    var body: some View {
        NavigationView {
            TaskListView()
            .navigationTitle("Todo")
            .navigationBarTitleDisplayMode(.automatic)
        }
    }
}

Now, if we try run the project, it will show a similar output to the below.

Current output with mock data

Great, we have created a view for the main to-do list. Now, let’s add a simple form in the list to enable us to add more tasks dynamically.

Adding new tasks with AddTaskView

Create a new view file called AddTaskView and update it with below code.

struct AddTaskView: View {
    // 1
    @State private var taskTitle: String = ""
    var body: some View {
        HStack(spacing: 12) {
            // 2
            TextField("Enter New Task..", text: $taskTitle)
            // 3
            Button(action: handleSubmit) {
                Image(systemName: "plus")
            }
        }
        .padding(20)
    }

    private func handleSubmit() {
        // some action
    }
}

Below is an explanation of each important point added in this view:

  1. taskTitle with the @State property wrapper is used to receive an update on every change that is made.
  2. Then, we added the TextField view to enable the user to add new text and bind it with the taskTitle variable using $ sign.
  3. handleSubmit is then added to the Button view as the action handler function, which we will integrate with the data insertion process in the next section.

After creating the form, we need to update the ContentView. Add a VStack inside the ContentView and include both the AddTaskView and TaskListView.

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                AddTaskView()
                TaskListView()
            }
            .navigationTitle("Todo")
            .navigationBarTitleDisplayMode(.automatic)
        }
    }
}

If we run the project again, the output will show the newly added form above the list.

Current output after added form view

Creating a Realm model

A Realm model is a regular Swift class that subclasses Realm Object protocol and conforms the objects created to the Realm database schema. The Realm object will be automatically saved as a table in the Realm database with all the defined properties. It also has additional features such as live queries, a reactive events handler, and real-time data updates.

These are the supported Swift data types that can be used in the Realm model:

  • String
  • Data
  • Int
  • Double
  • Float
  • Bool
  • Date

Creating the TaskObject Realm model

First, we will create another Realm model called TaskObject.

Now, we have two models, Task and TaskObject. The Realm TaskObject only communicates with the Realm object protocol and the database, while the Task class takes the data from the Realm object and communicates with Swift views. You can then make changes to the data via the Task class so that it can be used in other areas of the app. The Task model is used to display data that will have features such as formatting, encoding, and decoding in the future, while the TaskObject is created specifically for the Realm data model.

Create a new file called TaskObject that inherits the Realm Object class. Take note that each of the properties in the Realm model should be used with @Persisted wrapper to mark each property as part of the Realm model that will be handled accordingly during read and write operations.

import Foundation
import RealmSwift

class TaskObject: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var title: String
    @Persisted var completed: Bool = false
    @Persisted var completedAt: Date = Date()
}

Then, update the Task model with the custom init(taskObject:) function to enable quick data mapping with the Realm object.

struct Task: Identifiable {
    var id: String
    var title: String
    var completed: Bool = false
    var completedAt: Date = Date()

    init(taskObject: taskObject) {
        self.id = taskObject.id.stringValue
        self.title = taskObject.title
        self.completed = taskObject.completed
        self.completedAt = taskObject.completedAt
    }
}

Creating the task view model

View model to enable communication between our newly created views and the Realm database. Initially, we will focus on how to insert new tasks and get the list of all tasks.

Create a new file called TaskViewModel and add the below code.

// 1
import Foundation
import Combine
import RealmSwift

// 2
final class TaskViewModel: ObservableObject {
    // 3
    @Published var tasks: [Task] = []
    // 4
    private var token: NotificationToken?

    init() {
        setupObserver()
    }

    deinit {
        token?.invalidate()
    }
    // 5
    private func setupObserver() {
        do {
            let realm = try Realm()
            let results = realm.objects(TaskObject.self)

            token = results.observe({ [weak self] changes in
                // 6
                self?.tasks = results.map(Task.init)
                    .sorted(by: { $0.updatedAt > $1.updatedAt })
                    .sorted(by: { !$0.completed && $1.completed })
            })
        } catch let error {
            print(error.localizedDescription)
        }
    }
    // 7
    func addTask(title: String) {
        let taskObject = TaskObject(value: [
            "title": title,
            "completed": false
        ])
        do {
            let realm = try Realm()
            try realm.write {
                realm.add(taskObject)
            }
        } catch let error {
            print(error.localizedDescription)
        }
    }
    // 8
    func markComplete(id: String, completed: Bool) {
        do {
            let realm = try Realm()
            let objectId = try ObjectId(string: id)
            let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
            try realm.write {
                task?.completed = completed
                task?.completedAt = Date()
            }
        } catch let error {
            print(error.localizedDescription)
        }
    }
}

Below is the explanation of each important point added in the code above:

  1. There are two additional frameworks that need to be imported, Combine and RealmSwift. Combine is a powerful Swift API that manages asynchronous events, and is part of the native iOS framework, so we can just import them into our project without any installation required. RealmSwift is also required in order to use its functions in accessing the Realm database.
  2. The view model is subclassing the ObservableObject protocol, which will emit important changes to the views.
  3. tasks is using the @Published wrapper to enable the subscriber’s views to receive updates when its value updated.
  4. token is a Realm NotificationToken that holds the observer object.
  5. The setupObserver() is mainly to setup an observer to watch any changes on the TaskObject list, such as the add, update, and delete operations.
  6. Every time the changes happen on the tasks variable, it will inform all of the subscriber views. The results will be sorted by the incomplete tasks first, then the completed tasks.
  7. We then added a function called addTask() that allows us to create new object to be stored in the Realm database.
  8. Then, we added another function markComplete() to change the completed status of the TaskObject by the given primary key (task ID).

Updating the main list and adding a form

After completing the model, we need to update the TaskListView and AddTaskView.

Updating the TaskListView

In the ForEach parameter, we’ll now pass tasks as the dynamic data fetched from the Realm database. We don’t have to write extra functions to keep the data up-to-date because the view will automatically reload itself once it receives update from the view model.

struct TaskListView: View {
    @EnvironmentObject private var viewModel: TaskViewModel
    var body: some View {
        ScrollView {
            LazyVStack (alignment: .leading) {
                ForEach(viewModel.tasks, id: \.id) { task in
                    TaskRowView(task: task)
                    Divider().padding(.leading, 20)
                }
                .animation(.default)
            }
        }
    }
}

AddTaskView

In this section, we are completing the handleSubmit function by calling the view model addTask function.

struct AddTaskView: View {
    @State private var taskTitle: String = ""
    @EnvironmentObject private var viewModel: TaskViewModel

    var body: some View {
        HStack(spacing: 12) {
            TextField("Enter New Task..", text: $taskTitle)
            Button(action: handleSubmit) {
                Image(systemName: "plus")
            }
        }
        .padding(20)
    }

    private func handleSubmit() {
        viewModel.addTask(title: taskTitle)
        taskTitle = ""
    }
}

The @EnvironmentObject wrapper

The environment object is a powerful feature in SwiftUI that automatically keeps the changes on a single shared object among multiple views.

As we can see in both the TaskListView and AddTaskView views, we need to use the @EnvironmentObject wrapper in order to observe any changes that may occur in the TaskViewModel.

To make the environment object available for use in a view, we need to pass the object using environmentObject(). In this case, we need to update the App file in TodoRealmSwiftUIApp.

@main
struct TodoRealmSwiftUIApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(TaskViewModel())
        }
    }
}

Awesome, now the main list is completely integrated with the Realm database. Let’s run the project, try adding some tasks, and marking a few of them as complete or incomplete.

Checkbox demo in the main list view after the Realm database integration

The task detail view

In this section, we will add one more view to show the details of each task in our list. We also will be adding edit and delete functions to this new view.

Create a new file called TaskView and update it with the following code.

import SwiftUI

struct TaskView: View {
    // 1
    @EnvironmentObject private var viewModel: TaskViewModel
    // 2
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @State private var taskTitle: String = ""
    // 3
    let task: Task

    var body: some View {
        VStack(alignment: .leading, spacing: 24) {
            // 4
            VStack(alignment: .leading, spacing: 4) {
                Text("Title")
                    .foregroundColor(Color.gray)
                TextField("Enter title..", text: $taskTitle)
                    .font(.largeTitle)
                Divider()
            }
            // 5
            Button(action: deleteAction) {
                HStack {
                    Image(systemName: "trash.fill")
                    Text("Delete")
                }
                .foregroundColor(Color.red)
            }
            Spacer()
        }
        .navigationBarTitle("Edit Todo", displayMode: .inline)
        .padding(24)
        .onAppear(perform: {
            taskTitle = task.title
        })
        // 6
        .onDisappear(perform: updateTask)
    }

    private func updateTask() {
        viewModel.updateTitle(id: task.id, newTitle: taskTitle)
    }

    private func deleteAction() {
        viewModel.remove(id: task.id)
        presentationMode.wrappedValue.dismiss()
    }
}

The following is the details explanation of each important point added in the code above:

  1. In this code, we’ve used TaskViewModel as an EnvironmentObject variable to enable access to the shared view model.
  2. We then used presentationMode to dismiss the view programmatically.
  3. The task is added as a dependency model during initialization
  4. T``extField is included to enable us to edit the task’s title.
  5. Then, we added a delete button to delete tasks from the Realm database
  6. Finally, the updateTask is called to save the data once the user leaves the view.

Updating the view model

Next, update the TaskViewModel with delete and update functions.

func remove(id: String) {
    do {
        let realm = try Realm()
        let objectId = try ObjectId(string: id)
        if let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId) {
            try realm.write {
                realm.delete(task)
            }
        }
    } catch let error {
        print(error.localizedDescription)
    }
}

func updateTitle(id: String, newTitle: String) {
    do {
        let realm = try Realm()
        let objectId = try ObjectId(string: id)
        let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
        try realm.write {
            task?.title = newTitle
        }
    } catch let error {
        print(error.localizedDescription)
    }
}

Adding navigation to the TaskListView item

Finally, update the item view in TaskListView with NavigationLink, so that whenever the user taps the row, it will navigate to the detail view.

NavigationLink (destination: TaskView(task: task)) {
    TaskRowView(task: task)
}

Edit and delete operations

Great. We have successfully implemented all of the CRUD operations.

Schema migration

Migration becomes very important when we want to modify the database schema in any of the following ways:

  1. Adding new properties or fields
  2. Changing property data types
  3. Renaming properties
  4. Updating property default values

In the following example, we are going to add a new task field called Due Date. We will need to make small update changes to our views and models.

Adding the due date field to our views and models

Add a new field called dueDate with an optional Date type to both the TaskObject and Task model.

TaskObject model
We’ll create a new TaskObject model, same as we did above.

class TaskObject: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var title: String
    @Persisted var completed: Bool = false
    @Persisted var completedAt: Date = Date()
    // New property
    @Persisted var dueDate: Date? = nil
}

Task model
In the updated code below, we’ll add a new property (dueDate), the computed variable for formatting the date, and update the init function.

struct Task: Identifiable {
    var id: String
    var title: String
    var completed: Bool = false
    var completedAt: Date = Date()
    // New property
    var dueDate: Date? = nil

    init(taskObject: TaskObject) {
        self.id = taskObject.id.stringValue
        self.title = taskObject.title
        self.completed = taskObject.completed
        self.completedAt = taskObject.completedAt
        // Also map the new property
        self.dueDate = taskObject.dueDate
    }

    var formattedDate: String {
        if let date = dueDate {
            let format = "MMM d, y"
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = format
            return "Due at " + dateFormatter.string(from: date)
        }
        return ""
    }
}

Update the task view model

Then, update the view model to store the due date value in update() function.

func update(id: String, newTitle: String, dueDate: Date?) {
        do {
            let realm = try Realm()
            let objectId = try ObjectId(string: id)
            let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
            try realm.write {
                task?.title = newTitle
                // Update due date value (Optional value)
                task?.dueDate = dueDate
            }
        } catch let error {
            print(error.localizedDescription)
        }
    }

Migration required error

As a reminder, migration is required every time a user adds or updates a new property. Let’s try running the project before migration to see the error output in the Xcode log, which will be caught from the exception handler.

Migration is required due to the following errors:
- Property 'TaskObject.dueDate' has been added.

Setting up the migration

The default schema version is 1, so we have to change the schema to 2 in the configuration.

Add or update your AppDelegate file with this code. In the configMigration function, we have specified the schema version to 2.

import UIKit
import RealmSwift

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        configMigration()
        return true
    }

    private func configMigration() {
        // perform migration if necessary
        let config = Realm.Configuration(
            schemaVersion: 2,
            migrationBlock: { migration, oldSchemaVersion in
                // additional process such as rename, combine fields and link to other object
            })
        Realm.Configuration.defaultConfiguration = config
    }
}

Also make sure to include the AppDelegate adaptor.

import SwiftUI

@main
struct TodoRealmSwiftUIApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    ...
}

Now, everything should work properly. Run the project and the results will be similar to the below screenshots.

Our final app screens

Project completed

Congratulations! We have completed building a to-do app using Realm and SwiftUI. The entire source code is available for download from my GitHub repository. You may want to try implementing Realm into your future Swift projects.

Thanks for reading and happy coding!

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.

Leave a Reply