Search code examples
iossortingswiftuirealm

RealmSwift, how to sort attribute list on load and keep it updated during reorder?


I've got the following Object schema that I'm working with in Realm (I'm describing it using Typescript notation cause I'm comfortable with it):

Publisher {
    name: String (primary key),
    position: Int,
    games: RealmSwift.List<Game>,
}

Game {
    name: String (primary key),
    description: String,
    cover: String,
    position: Int,
}

So, every publisher has its own list of games and onAppear of the outermost view of the ContentView.swift I load all the publishers from Realm, and sort them by their position keyPath. However, I would also like the games list to be sorted by position for each publisher and I'm not sure if you can do it directly. For now this is the code I wrote under the onAppear method:

Relevant parts of ContentView.swift:

import SwiftUI
import RealmSwift

struct ContentView: View {        
    @State var publishers: Results<Publisher>? = nil
    private var realm: Realm
    
    init() {
        self.realm = try! Realm()
    }
    
    var body: some View {
        
        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 10) {
                    
                    if publishers != nil {
                        ForEach(publishers!, id: \.name) { publisher in
                            PublisherName(publisher: publisher.name)
                            
                            List {
                                ForEach(publisher.games, id: \.name) { game in
                                    HStack {
                                        Text(String(game.position))
                                            .font(.system(size:20))
                                            .fontWeight(.bold)
                                        GameRow(game: game)
                                    }
                                }.onMove(perform: {
                                    startIndex, destination in
                                    do {
                                        try self.realm.write {
                                            publisher.games.move(fromOffsets: startIndex, toOffset: destination-1)
                                        }
                                    }catch {
                                        print("Error: \(error)")
                                    }
                                })                                    
                            }
                        }
                    }
                    
                }
            }
            .onAppear(perform: {
                
                let realm = try! Realm()

                self.publishers
                    = realm.objects(Publisher.self)
                    .reduce(RealmSwift.List<Publisher>(), { publishersList, publisher -> RealmSwift.List<Publisher> in

                        let p = Publisher()
                        p.name = publisher.name
                        p.position = publisher.position

                        p.games = publisher.games.sorted(byKeyPath: "position")
                            .reduce(RealmSwift.List<Game>(), { gamesList, game -> RealmSwift.List<Game> in

                            gamesList.append(game)
                            return gamesList

                        })
                        publishersList.append(p)
                        return publishersList

                    })
                    .sorted(byKeyPath: "position")
                
            })
        }
    }
}

When I run the code above I get the following error at @main, apparently caused by the code inside the onAppear block (since if I remove it everything works fine):

"This method may only be called on RLMArray instances retrieved from an RLMRealm"

Also I would like that when in editing mode, on swapping position of two games, the changes should be written on the Realm database and immediately updated in my app, as in I would like to "reload" on change. The way it is, it already writes changes on Realm but changes don't happen unless I do something that reloads the View, like toggling the edit mode.


Solution

  • This is absolutely doable. Assuming you had two models that looked like this you should be able to add, delete, and move rows around while updating the position properties of your model objects. I do this all the time.

    Let's assume your model objects look like this:

    class Publisher: Object, ObjectKeyIdentifiable {
        @Persisted(primaryKey: true) var name = ""
        @Persisted var position: Int = 0
        @Persisted var games: RealmSwift.List<Game>
    
        convenience init(name: String, position: Int, games: [Game]) {
            self.init()
            self.name = name
            self.position = position
            self.games.append(objectsIn: games)
        }
    }
    
    class Game: Object, ObjectKeyIdentifiable {
        @Persisted(primaryKey: true) var name = ""
        @Persisted var position: Int = 0
    
        convenience init(name: String, position: Int) {
            self.init()
            self.name = name
            self.position = position
        }
    }
    

    Now let's create a protocol that defines a position property.

    protocol Positionable {
        var position: Int { get set }
    }
    

    Now let's make our models conform to it:

    extension Game: Positionable {}
    extension Publisher: Positionable {}
    

    This Results extension will allow us to "move" objects sorted by position. Which means that it will update the position properties for all elements that need updating. Keep in mind, this requires that your result set is already sorted by position.

    extension Results where Element: Object & Positionable {
        func repositionElement(atOffset source: Int, toOffset destination: Int) {
            if source < destination {
                let nextElements = self[source+1..<destination]
                var target = self[source]
                for var element in nextElements {
                    element.position -= 1
                }
                target.position = destination - 1
    
            } else {
                let prevElements = self[destination..<source]
                var target = self[source]
                for var element in prevElements {
                    element.position += 1
                }
                target.position = destination
            }
        }
    }
    

    Now we need to do something similar for List. This one is a bit simpler because List offers a move(fromOffsets: toOffsets:) method. However, as you recall, you also need to update the position property for all affected elements.

    extension List where Element: Object & Positionable {
        func repositionElement(atOffset source: Int, toOffset destination: Int) {
            move(fromOffsets: [source], toOffset: destination)
            enumerated()
                .forEach {
                    var element = $0.element
                    element.position = $0.offset
                }
        }
    }
    

    Just for kicks let's add a delete method that updates the position property. Now we can use this method on List and Results since they both conform to RealmCollection. Again, this method assumes that the collection is already sorted by position.

    extension RealmCollection where Element: Object & Positionable {
        func deleteElement(atOffset offset: Int) {
            guard let element = objects(at: [offset]).first else { return }
            realm?.delete(element)
            for i in offset..<count {
                if var element = objects(at: [i]).first {
                    element.position -= 1
                }
            }
        }
    }
    

    As for the UI, make sure that you are using the @ObservedResults property wrapper for your Results<Publisher> property and using the @ObservedRealmObject property wrapper for your Publisher properties.

    Also, remember these property wrappers will freeze all of your objects so you will need to call thaw() in order to unfreeze them before any realm writes.

    Here is a sample UI for you:

    struct ContentView: View {
    
        @ObservedResults(
            Publisher.self,
            configuration: .defaultConfiguration,
            sortDescriptor: .init(keyPath: \Publisher.position)) var publishers
    
        let names = ["Bob","Tom","Tim","Sally","Joe","Tammy","Tina","Jeff","Meghan","Don","Kirk","Justin","Jason"]
    
        var body: some View {
    
            NavigationView {
                List {
                    ForEach(publishers) { publisher in
                        HStack {
                            Text("\(publisher.position):")
                                .font(.system(size: 20))
                                .bold()
                            Text(publisher.name)
                                .font(.system(size: 20))
                        }
                        GameList(publisher: publisher)
                    }
                    .onMove(perform: movePublisher(fromOffsets:toOffset:))
                    .onDelete (perform: deletePublisher(atOffsets:))
                }.toolbar {
                    ToolbarItemGroup.init(placement: .navigationBarTrailing) {
                        EditButton()
                        Button("Add") {
                            addPublisher()
                        }
                        .disabled(publishers.count == names.count)
                    }
                }
            }
        }
    
        func addPublisher() {
            let realm = try! Realm()
            let pos = Publisher.nextPosition(in: realm)
    
            guard let name = names.first(
                where: { publishers.filter("name == %@", $0).count == 0 }) else {
                return print("No more names")
            }
    
            let ordinalFormatter = NumberFormatter()
            ordinalFormatter.numberStyle = .ordinal
    
            let pub = Publisher(
                name: name,
                position: pos,
                games: (0..<3)
                    .map {
                    .init(
                        name: "\(name).game.\(ordinalFormatter.string(from: NSNumber(value: $0 + 1)) ?? "")",
                        position: $0)
                }
            )
    
            try! realm.write {
                realm.add(pub)
            }
        }
    
        func movePublisher(fromOffsets offsets: IndexSet, toOffset destination: Int) {
            guard let offset = offsets.first else { return }
    
            let realm = try! Realm()
            try! realm.write {
                publishers.thaw()?.repositionElement(
                    atOffset: offset,
                    toOffset: destination
                )
            }
        }
    
        func deletePublisher(atOffsets offsets: IndexSet) {
            guard let offset = offsets.first else { return }
            let realm = try! Realm()
            try! realm.write {
                guard let publishers = self.publishers.thaw() else { return }
                realm.delete(publishers[offset].games)
                publishers.deleteElement(atOffset: offset)
            }
        }
    
    }
    
    struct GameList: View {
    
        @ObservedRealmObject var publisher: Publisher
    
        var body: some View {
            ForEach(publisher.games) { game in
                HStack {
                    Text(String(game.position) + ":")
                    Text(String(game.name))
                }
                .padding(.leading)
            }.onMove(perform: moveGame(fromOffsets:toOffset:))
            .onDelete(perform: deleteGame(atOffsets:))
        }
    
        func moveGame(fromOffsets offsets: IndexSet, toOffset destination: Int) {
            guard let offset = offsets.first else { return }
    
            let realm = try! Realm()
            try! realm.write {
                publisher.thaw()?.games
                    .repositionElement(atOffset: offset, toOffset: destination)
            }
        }
    
        func deleteGame(atOffsets offsets: IndexSet) {
            guard let offset = offsets.first else { return }
            let realm = try! Realm()
            try! realm.write {
                publisher.thaw()?.games
                    .deleteElement(atOffset: offset)
            }
        }
    }