Skip to content
Eli Slade

Eli Slade

Design + Code

I’m a Vancouver based UI Designer and Developer.

Blog Post

Using Realm With SwiftUI

Read

I recently had the opportunity to create an MVP completely in SwiftUI. I decided to choose Realm as it’s backing data layer and had to figure out how to fit it into SwiftUIs data life-cycle. When researching how to accomplish this I didn’t find what I was looking for. So here is an article I wish I had. I’m going to cover my four layers from Realm to DataObserver to DataStore and then finally to SwiftUI.

Realm Objects and Maps

The foundation of my solution ended up with duplication of data models, one for the backing realm object, and one for the presented user-interface. Usually, duplication of code is frowned upon, but like every rule, there is a time and place to break it. Having two different data models allows you to have more complex data types and enums that might otherwise not be supported with Realm. It also removes the Realm dependency from the user interface, making it easy to change or augment the backing data without much change to the UI layer. The main drawback to this approach is creating manual maps between the two models and if not updated the same every time, can lead to missing data.

// Realm Model
class RealmTodo:Object, UUIDIdentifiable {
    @objc dynamic var id:String = UUID().uuidString
    @objc dynamic var content:String = ""
    @objc dynamic var dateCreated = Date()
    @objc dynamic var dateReminder:Date?
    
    override static func primaryKey() -> String? {
        return "id"
    }
}

// UI Model
struct Todo: Equatable {
    var id:String = UUID().uuidString
    var content:String = ""
    var dateCreated = Date()
    var dateReminder:Date?
}

While figuring out my data stack I caught myself updating both the models while forgetting to update the mapping, I would then try to test the UI and be confused why data was not showing as expected. I eventually would realize the error of my ways and go back to update the maps. Needless to say, the duplicated models and mapping can be a pain in the ass sometimes.

// Mappings
extension RealmTodo {
    convenience init(_ obj: Todo) {
        self.init()
        self.id = obj.id
        self.content = obj.content
        self.dateCreated = obj.dateCreated
        self.dateReminder = obj.dateReminder
    }
}

extension Todo: RealmConvertible {
    func realmMap() -> RealmTodo {
        RealmTodo(self)
    }
    
    init(_ obj:RealmTodo) {
        self.id = obj.id
        self.content = obj.content
        self.dateCreated = obj.dateCreated
        self.dateReminder = obj.dateReminder
    }
}

The RealmConvertible protocol is the underpinning of the whole mappable data models. This protocol is what stores the reference to the RealmType and also it’s mapped data function and initializations.

protocol RealmConvertible where Self:Equatable & UUIDIdentifiable & Initializable {
    associatedtype RealmType: Object & UUIDIdentifiable
    
    func realmMap() -> RealmType
    init(_ dest:RealmType)
}

// Dynamic Realm Binding for live data editing

extension RealmConvertible {
    func realmBinding() -> Binding<Self> {
        let h = RealmHelper()
        return Binding<Self>(get: {
            if let r = h.get(self.realmMap()) {
                // get the latest realm version for most up to date data and map back to abstracted structs on init
                return Self(r)
            } else {
                // otherwise return self as it's the most up to date version of the data struct
                return self
            }
        }, set: h.updateConvertible)
    }
}

Only the UI data model layer needs explicit conformance and once it conforms it’s ready to be used in the DataObservable class.

DataObserver

DataObservable does all the heavy lifting by watching the mapped realm model results for changes then updating and mapping the models to the observed publishers.

class DataObservable<Type:RealmConvertible>: ObservableObject {
    
    private let helper = RealmHelper()
    private var notificationTokens: [NotificationToken] = []
    private var realmItems:RealmSwift.Results<Type.RealmType>
    
    @Published private(set) var items:[Type]
    
    init(_ filter:String = "") {
        // filter can be used to scope data on init
        
        if filter.count > 0 {
            realmItems = helper.list(Type.RealmType.self).filter(filter)
        } else {
            realmItems = helper.list(Type.RealmType.self)
        }
        
        self.items = realmItems.map { Type($0) }
        watchRealm()
    }
    
    private func watchRealm() {
        self.notificationTokens.append(realmItems.observe { _ in
            self.updateItems()
        })
    }
    
    private func updateItems() {
        DispatchQueue.main.async {
            self.items = self.realmItems.map { Type($0) }
        }
    }
    
    deinit { notificationTokens = [] }

    // CRUD Actions for the observed objects
    // ...
}

DataStore

While you could plug the DataObservable straight into SwiftUI, it doesn’t allow for data that may have multiple relationships. My todo example doesn’t need the DataStore object, but for anything that has data relationships, it’s a must. For example, you may have a product model with relations to a seller model, a related product list, and user models as part of a review model. This is very dynamic to your specific needs and how you structured the data models, but encapsulating all related data into a single DataStore is very handy when it comes to clean code on the UI layer.

final class DataStore: ObservableObject {

    private var todoCancellable:AnyCancellable?
    private(set) var todoDB = DataObservable<Todo>()
    // could store related references to other related DataObservables

    @Published private(set) var todos:[Todo] = []
    
    init() {
        todoDB = DataObservable<Todo>()
        todoCancellable = todoDB.$items.assign(to: \.todos, on: self)
    }
}

SwiftUI

As you can see we’re left with a very clean UI struct that utilizes the data store as a property wrapped @EnvironmentObject for todo data. We pass a realmBinding as defined in the RealmConvertible protocol to the cell.

struct TodoList: View {
    @EnvironmentObject var data:DataStore
    
    var body: some View {
        ScrollView {
            VStack(spacing:20){
                ForEach(data.todos.sorted(by: { $0.dateCreated < $1.dateCreated })){ s in
                    TodoCell(todo: s.realmBinding())
                        .background(Color(.secondarySystemFill).opacity(0.5))
                        .cornerRadius(18)
                        .padding(.horizontal)
                }
                Spacer()
            }.padding(.vertical)
        }.animation(Animation.spring().speed(2))
    }
}

Now any data that is changed in the cell will be saved as the binding updates. Every time a character is typed or removed the data will be persisted instantly. You could quit the app in the middle of typing a todo and data will be saved up to the last thing typed.

struct TodoCell: View {
    
    @EnvironmentObject var data:DataStore
    @Binding var todo:Todo
    
    var body: some View {
        HStack {
            TextField("Title", text: $todo.content).padding()
            Spacer()
            if todo.content.count > 0 {
                Button(action:{ data.todoDB.delete(todo) }){
                    HStack(spacing: 12) {
                        Image(systemName: "trash")
                        Text("Delete")
                        Spacer()
                    }.padding().foregroundColor(.red)
                }
            }
        }.font(.system(size: 18, weight: .bold, design: .rounded))
    }
}

Hopefully you found this article useful in deciding how you can fit Realm into your SwiftUI projects. You can view and download the source code for this project on GitHub.

Let's Work Together

And make something beautiful!