How to display Realm Results in SwiftUI List?

Viewed 8163

I have been able to save data in a Realm database, but have been unable to show the results in a SwiftUI List.

I know I have the data and have no problem printing the results in the console.

Is there a way to convert Realm Result into a format that can be displayed on a SwiftUI List?

import SwiftUI
import RealmSwift
import Combine

class Dog: Object {
    @objc dynamic var name = ""
    @objc dynamic var age = 0

    override static func primaryKey() -> String? {
        return "name"
    }
}

class SaveDog {
    func saveDog(name: String, age: String) {
        let dog = Dog()
        dog.age  = Int(age)!
        dog.name = name

        // Get the default Realm
        let realm = try! Realm()

     print(Realm.Configuration.defaultConfiguration.fileURL!)

        // Persist your data easily
        try! realm.write {
        realm.add(dog)
        }

        print(dog)
    }
}

class RealmResults: BindableObject {
    let didChange = PassthroughSubject<Void, Never>()

    func getRealmResults() -> String{
        let realm = try! Realm()
        var results = realm.objects(Dog.self) { didSet 
 {didChange.send(())}}
        print(results)
        return results.first!.name
    }
}

struct dogRow: View {
    var dog = Dog()
    var body: some View {
        HStack {
            Text(dog.name)
            Text("\(dog.age)")
        }
    }

}

struct ContentView : View {

    @State var dogName: String = ""
    @State var dogAge: String = ""

    let saveDog = SaveDog()
    @ObjectBinding var savedResults = RealmResults()
    let realm = try! Realm()

    let dogs = Dog()

    var body: some View {
        VStack {
            Text("Hello World")
            TextField($dogName)
            TextField($dogAge)
            Button(action: {
                self.saveDog.saveDog(name: self.dogName, 
                age:self.dogAge)
//                self.savedResults.getRealmResults()
            }) {
                Text("Save")
            }
            //insert list here to show realm data

            List(0 ..< 5) { 
             item in
                Text(self.savedResults.getRealmResults())
            } //Displays the same thing 5 times
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif

Some of the code probably may not make sense because I was attempting several approaches to see if anything would work.

This line, for example, will display the result in the List View.

return results.first!.name

If I just return results, nothing displays in the List Text View.

As I have commented below I will attempt the ForEach approach when I have time. That looks promising.

4 Answers

The data that you pass in List or a ForEach must conform to the Identifiable protocol.

Either you adopt it in your Realm models or you use .identified(by:) method.


Even with that, the View won't reload if the data changes.

You could wrap Results and make it a BindableObject, so the view can detect the changes and reload itself:

class BindableResults<Element>: ObservableObject where Element: RealmSwift.RealmCollectionValue {

    var results: Results<Element>
    private var token: NotificationToken!

    init(results: Results<Element>) {
        self.results = results
        lateInit()
    }

    func lateInit() {
        token = results.observe { [weak self] _ in
            self?.objectWillChange.send()
        }
    }

    deinit {
        token.invalidate()
    }
}

And use it like:

struct ContentView : View {

    @ObservedObject var dogs = BindableResults(results: try! Realm().objects(Dog.self))

    var body: some View {
        List(dogs.results.identified(by: \.name)) { dog in
            DogRow(dog: dog)
        }
    }

}

This is the most straight forward way of doing it:

struct ContentView: View {
    @State private var dog: Results<Dog> = try! Realm(configuration: Realm.Configuration(schemaVersion: 1)).objects(Dog.self)

    var body: some View {
        ForEach(dog, id: \.name) { i in
        Text(String((i.name)!))
        }
    }
}

...That's it, and it works!

I have created a generic solution to display and add/delete for any Results<T>. By default, Results<T> is "live". SwiftUI sends changes to View when the @Published property WILL update. When a RealmCollectionChange<Results<T>> notification is received, Results<T> has already updated; Therefore, a fatalError will occur on deletion due to index out of range. Instead, I use a "live" Results<T> for tracking changes and a "frozen" Results<T> for use with the View. A full working example, including how to use a generic View with RealmViewModel<T> (shown below), can be found here: SwiftUI+Realm. The enum Status is used to display a ProgressView, "No records found", etc., when applicable, as shown in the project. Also, note that the "frozen" Results<T> is used when needing a count or single object. When deleting, the IndexSet by onDelete is going to return a position from the "frozen" Results<T> so it checks that the object still existing in the "live" Results<T>.

class RealmViewModel<T: RealmSwift.Object>: ObservableObject, Verbose where T: Identifiable {

typealias Element = T

enum Status {
    // Display ProgressView
    case fetching
    // Display "No records found."
    case empty
    // Display results
    case results
    // Display error
    case error(Swift.Error)
    
    enum _Error: String, Swift.Error {
        case fetchNotCalled = "System Error."
    }
}

init() {
    fetch()
}

deinit {
    token?.invalidate()
}

@Published private(set) var status: Status = .error(Status._Error.fetchNotCalled)

// Frozen results: Used for View

@Published private(set) var results: Results<Element>?

// Live results: Used for NotificationToken

private var __results: Results<Element>?

private var token: NotificationToken?

private func notification(_ change: RealmCollectionChange<Results<Element>>) {
    switch change {
        case .error(let error):
            verbose(error)
            self.__results = nil
            self.results = nil
            self.token = nil
            self.status = .error(error)
        case .initial(let results):
            verbose("count:", results.count)
            //self.results = results.freeze()
            //self.status = results.count == 0 ? .empty : .results
        case .update(let results, let deletes, let inserts, let updates):
            verbose("results:", results.count, "deletes:", deletes, "inserts:", inserts, "updates:", updates)
            self.results = results.freeze()
            self.status = results.count == 0 ? .empty : .results
    }
}

var count: Int { results?.count ?? 0 }

subscript(_ i: Int) -> Element? { results?[i] }

func fetch() {
    
    status = .fetching
    
    //Realm.asyncOpen(callback: asyncOpen(_:_:))
    
    do {
        let realm = try Realm()
        let results = realm.objects(Element.self).sorted(byKeyPath: "id")
        self.__results = results
        self.results = results.freeze()
        self.token = self.__results?.observe(notification)
        
        status = results.count == 0 ? .empty : .results
        
    } catch {
        verbose(error)
        
        self.__results = nil
        self.results = nil
        self.token = nil
        
        status = .error(error)
    }
}

func insert(_ data: Element) throws {
    let realm = try Realm()
    try realm.write({
        realm.add(data)
    })
}

func delete(at offsets: IndexSet) throws {
    let realm = try Realm()
    try realm.write({
        
        offsets.forEach { (i) in
            guard let id = results?[i].id else { return }
            guard let data = __results?.first(where: { $0.id == id }) else { return }
            realm.delete(data)
        }
    })
}

}

Here is another option using the new Realm frozen() collections. While this is early days the UI is updated automatically when 'assets' get added to the database. In this example they are being added from an NSOperation thread, which should be a background thread.

In this example the sidebar lists different property groups based on the distinct values in the database - note that you may wish to implement this in a more robust manner - but as a quick POC this works fine. See image below.

struct CategoryBrowserView: View {
    @ObservedObject var assets: RealmSwift.List<Asset> = FileController.shared.assets
    @ObservedObject var model = ModelController.shared
    
    @State private var searchTerm: String = ""
    @State var isEventsShowing: Bool = false
    @State var isProjectsShowing: Bool = false
    @State var isLocationsShowing: Bool = false
    
    var projects: Results<Asset> {
        return assets.sorted(byKeyPath: "project").distinct(by: ["project"])
    }
    var events: Results<Asset> {
        return assets.sorted(byKeyPath: "event").distinct(by: ["event"])
    }
    var locations: Results<Asset> {
        return assets.sorted(byKeyPath: "location").distinct(by: ["location"])
    }
    @State var status: Bool = false
    
    var body: some View {
        VStack(alignment: .leading) {
        ScrollView {
            VStack(alignment: .leading) {
                
                // Projects
                DisclosureGroup(isExpanded: $isProjectsShowing) {
                    
                    VStack(alignment:.trailing, spacing: 4) {
                        
                        ForEach(filteredProjectsCollection().freeze()) { asset in
                            HStack {
                                    Text(asset.project)
                                    Spacer()
                                Image(systemName: self.model.selectedProjects.contains(asset.project) ? "checkmark.square" : "square")
                                        .resizable()
                                        .frame(width: 17, height: 17)
                                    .onTapGesture { self.model.addProject(project: asset.project) }
                            }
                        }
                    }.frame(maxWidth:.infinity)
                    .padding(.leading, 20)
                    
                } label: {
                    HStack(alignment:.center) {
                        Image(systemName: "person.2")
                        Text("Projects").font(.system(.title3))
                        Spacer()
                    }.padding([.top, .bottom], 8).foregroundColor(.secondary)
                }
                
                // Events
                DisclosureGroup(isExpanded: $isEventsShowing) {
                    
                    VStack(alignment:.trailing, spacing: 4) {
                        
                        ForEach(filteredEventsCollection().freeze()) { asset in
                            HStack {
                             Text(asset.event)
                                Spacer()
                            Image(systemName: self.model.selectedEvents.contains(asset.event) ? "checkmark.square" : "square")
                                    .resizable()
                                    .frame(width: 17, height: 17)
                                .onTapGesture { self.model.addEvent(event: asset.event) }
                            }
                        }
                    }.frame(maxWidth:.infinity)
                    .padding(.leading, 20)
                    
                } label: {
                    HStack(alignment:.center) {
                        Image(systemName: "calendar")
                        Text("Events").font(.system(.title3))
                        Spacer()
                    }.padding([.top, .bottom], 8).foregroundColor(.secondary)
                }
                
                // Locations
                DisclosureGroup(isExpanded: $isLocationsShowing) {
                    
                    VStack(alignment:.trailing, spacing: 4) {
                        
                        ForEach(filteredLocationCollection().freeze()) { asset in
                            HStack {
                             Text(asset.location)
                                Spacer()
                            Image(systemName: self.model.selectedLocations.contains(asset.location) ? "checkmark.square" : "square")
                                    .resizable()
                                    .frame(width: 17, height: 17)
                                .onTapGesture { self.model.addLocation(location: asset.location) }
                            }
                        }
                    }.frame(maxWidth:.infinity)
                    .padding(.leading, 20)
                    
                } label: {
                    HStack(alignment:.center) {
                        Image(systemName: "flag")
                        Text("Locations").font(.system(.title3))
                        Spacer()
                    }.padding([.top, .bottom], 8).foregroundColor(.secondary)
                }
                
            }.padding(.all, 10)
            .background(Color(NSColor.controlBackgroundColor))
        }
            SearchBar(text: self.$searchTerm)
                .frame(height: 30, alignment: .leading)
        }
    }
    
    func filteredProjectsCollection() -> AnyRealmCollection<Asset> {
        if self.searchTerm.isEmpty {
            return AnyRealmCollection(self.projects)
        } else {
            return AnyRealmCollection(self.projects.filter("project CONTAINS[c] %@ || event CONTAINS[c] %@ || location CONTAINS[c] %@ || tags CONTAINS[c] %@", searchTerm, searchTerm, searchTerm, searchTerm))
        }
    }
    func filteredEventsCollection() -> AnyRealmCollection<Asset> {
        if self.searchTerm.isEmpty {
            return AnyRealmCollection(self.events)
        } else {
            return AnyRealmCollection(self.events.filter("project CONTAINS[c] %@ || event CONTAINS[c] %@ || location CONTAINS[c] %@ || tags CONTAINS[c] %@", searchTerm, searchTerm, searchTerm, searchTerm))
        }
    }
    func filteredLocationCollection() -> AnyRealmCollection<Asset> {
        if self.searchTerm.isEmpty {
            return AnyRealmCollection(self.locations)
        } else {
            return AnyRealmCollection(self.locations.filter("project CONTAINS[c] %@ || event CONTAINS[c] %@ || location CONTAINS[c] %@ || tags CONTAINS[c] %@", searchTerm, searchTerm, searchTerm, searchTerm))
        }
    }
    func filteredCollection() -> AnyRealmCollection<Asset> {
        if self.searchTerm.isEmpty {
            return AnyRealmCollection(self.assets)
        } else {
            return AnyRealmCollection(self.assets.filter("project CONTAINS[c] %@ || event CONTAINS[c] %@ || location CONTAINS[c] %@ || tags CONTAINS[c] %@", searchTerm, searchTerm, searchTerm, searchTerm))
        }
    }
    func delete(at offsets: IndexSet) {
        if let realm = assets.realm {
            try! realm.write {
                realm.delete(assets[offsets.first!])
            }
        } else {
            assets.remove(at: offsets.first!)
        }
    }
    
}

struct CategoryBrowserView_Previews: PreviewProvider {
    static var previews: some View {
        CategoryBrowserView()
    }
}

struct CheckboxToggleStyle: ToggleStyle {
    func makeBody(configuration: Configuration) -> some View {
        return HStack {
            configuration.label
            Spacer()
            Image(systemName: configuration.isOn ? "checkmark.square" : "square")
                .resizable()
                .frame(width: 22, height: 22)
                .onTapGesture { configuration.isOn.toggle() }
        }
    }
}

enter image description here

Related