Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Updating UITableView with multiple sections from RLMResults.Observe()

Tags:

swift

realm

I am trying to create an auto-updating TableView, which is usually easy to do with help of Results.observe (the replacement of .addNotificationBlock)

The problem I'm facing is that I can't figure out how to handle a tableView with multiple sections, and cells that can move from 1 section to another.

With below table as example: (as seen on UITableView with Multiple Sections using Realm and Swift)

Bulldogs

Charlie

Max

German Shepherd

Bella

Buddy

Molly

Golden Retrievers

Bailey

Siberian Huskies

Daisy

With

class Dog: Object {
@objc dynamic var name String?
@objc dynamic var race: String?
}

And then something along the lines of:

let results = realm.objects(Dog.self)
    let token = dogs.observe { changes in
        switch changes {
        case .initial(let dogs):
             break
         case .update:
         // HANDLE MOVING CELL TO DIFFERENT SECTION HERE
             break
         case .error:
             break
         }
     }

Lets' say I have the tableView above, but 'Molly' had an identity crisis and turns out to be a Golden Retriever, so we change the race from within a detail screen.

How would I go about handling this change in an Observe block?

I have tried using 1 resultsList / token which triggers a modification when we change the race-property. But apart from a full reloadData(), which I can't use because I need animations, I can't figure out how to handle a delete & insert in 2 different sections because we can't reach the previous data in the 'dog'-object. Therefore I don't know how to figure out if a cell should move to a different section and what the previous section was.

I also tried using a resultsList per section, but this causes inconsistencies. When I change the race property it triggers a modification (the dog object was changed), a deletion (the resultList.count for the previous section is -1) and an insert (the resultList.count for the new section = +1). These notifications don't trigger at the exact same time which causes the error:

'attempt to delete item x from section x, but there are only x sections before the update'

Has anyone figured out how to handle this gracefully? I actually need something similar to this in multiple tableView in the project i'm working on for an internship.

Thanks in advance

(First post, so please don't hesitate to correct me when this post is not up to standards)

------ EDIT WITH MORE SPECIFIC EXAMPLE CODE -----

The data class i'm using with some non-important properties removed

    class CountInfo: Object, Encodable {
                    @objc dynamic var uuid: String?
                    @objc dynamic var productName: String?
// TableView is split in 2 sections based on this boolean-value  
                    @objc dynamic var inStock: Bool = false 
                }

The code-stub in viewDidLoad() I would like to use to update my tableView with 2 sections

        self.countListProducts = realm.objects(CountInfo.self)
        self.token = self.countListProducts.observe {
            changes in
            AppDelegate.log.debug(changes)
            if let tableView = self.tableView {
                switch changes {
                case .initial:
                    // if countInfo.isCounted = true: insert in section 0, if false: insert in section 1
                    // Currently handled by cellForRowAt
                    tableView.reloadData()
                case .update(_, let deletions, let insertions, let modifications):

                    // Remove deletion rows from correct section
                    // Insert insertions into correct section
                    // Reload Cell if modification didn't change 'isCounted' property
                    // Remove from old section and insert in new section if 'isCounted' property changed


                    tableView.beginUpdates()
                    tableView.insertRows(at: insertions.map({ /* GET ROW TO INSERT */ }),
                                         with: .automatic)
                    tableView.deleteRows(at: deletions.map({ /* GET ROW TO DELETE */ }),
                                         with: .automatic)
                    tableView.reloadRows(at: modifications.map({ /* UPDATE NAME OR MOVE TO OTHER SECTION IF 'inStock' value Changed */ }),
                                         with: .automatic)
                    tableView.endUpdates()

                case .error(let error):
                    // An error occurred while opening the Realm file on the background worker thread
                    fatalError("\(error)")
                }
            }
like image 987
Jeroen Koster Avatar asked Oct 18 '22 02:10

Jeroen Koster


1 Answers

This problem bothered me for a long time but I finally figured it out. I hope this helps someone.

When an object moves from one result set to the other you should expect two realm notifications. One for the old result set (a deletion) and one for the new result set (an insertion).

When we receive a notification for the first result set, then update the tableView for that section, the tableView will call numberOfRowsInSection for BOTH sections. When the tableView realizes that the result set has changed in the other section, but we didn't update the tableView for that section, it complains with NSInternalInconsistencyException.

What we need to do is trick the tableView into thinking that the other section was not updated. We do that by maintain a count of our own.

Basically, you need to do a few things.

  1. Maintain separate result sets for each section
  2. Maintain separate notification handlers for each section
  3. Maintain a manual count of objects for each section
  4. Update the object count when the notification handler fires with deletions or insertions
  5. Return the manual object count in numberOfRowsInSection (NOT THE RESULT SET COUNT)

Here's my model object:

class Contact: Object {
    
    @objc dynamic var uuid: String = UUID().uuidString
    @objc dynamic var firstName: String = ""
    @objc dynamic var lastName: String = ""
    @objc dynamic var age: Int = 0
    
    convenience init(firstName: String, lastName: String, age: Int) {
        self.init()
        self.firstName = firstName
        self.lastName = lastName
        self.age = age
    }

    override class func primaryKey() -> String? {
        return "uuid"
    }
}

In the sample code I have here there are two sections. The first contains a list of contacts under the age of 70, and the other a list of contacts over the age of 70. By maintaining a count of objects that we update manually when realm notifications fire, we are able to move objects from one result set to the next without UIKit complaining.

let elderAge = 70

lazy var allContacts: Results<Contact> = {
    let realm = try! Realm()
    return realm.objects(Contact.self)
}()

// KEEP A RESULT SET FOR SECTION 0

lazy var youngContacts: Results<Contact> = {
    return allContacts
        .filter("age <= %@", elderAge)
        .sorted(byKeyPath: "age", ascending: true)
}()

// KEEP A RESULT SET FOR SECTION 1

lazy var elderContacts: Results<Contact> = {
    return allContacts
        .filter("age > %@", elderAge)
        .sorted(byKeyPath: "age", ascending: true)
}()

// MANUALLY MAINTAIN A COUNT OF ALL OBJECTS IN SECTION 0

lazy var youngContactsCount: Int = {
    return youngContacts.count
}()

// MANUALLY MAINTAIN A COUNT OF ALL OBJECTS IN SECTION 1

lazy var elderContactsCount: Int = {
    return elderContacts.count
}()

// OBSERVE OBJECTS IN SECTION 0

lazy var youngToken: NotificationToken = {
    return youngContacts.observe { [weak self] change in
        guard let self = self else { return }
        switch change {
        case .update(_, let del, let ins, let mod):
            
            // MANUALLY UPDATE THE OBJECT COUNT FOR SECTION 0
            
            self.youngContactsCount -= del.count
            self.youngContactsCount += ins.count
            
            // REFRESH THE SECTION
            
            self.refresh(section: 0, del: del, ins: ins, mod: mod)
        default:
            break
        }
    }
}()

// OBSERVE OBJECTS IN SECTION 1

lazy var elderToken: NotificationToken = {
    return elderContacts.observe { [weak self] change in
        guard let self = self else { return }
        switch change {
        case .update(_, let del, let ins, let mod):
            
            // MANUALLY UPDATE THE OBJECT COUNT FOR SECTION 1
            
            self.elderContactsCount -= del.count
            self.elderContactsCount += ins.count
            
            // REFRESH THE SECTION
            
            self.refresh(section: 1, del: del, ins: ins, mod: mod)
        default:
            break
        }
    }
}()

func refresh(section: Int, del: [Int], ins: [Int], mod: [Int]) {
    tableView.beginUpdates()
    tableView.deleteRows(
        at: del.map { .init(row: $0, section: section) },
        with: .automatic
    )
    tableView.insertRows(
        at: ins.map { .init(row: $0, section: section) },
        with: .automatic
    )
    tableView.reloadRows(
        at: mod.map { .init(row: $0, section: section) },
        with: .automatic
    )
    tableView.endUpdates()
}


override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    
    // RETURN THE MANUALLY CALCULATED OBJECT COUNT (NOT THE ACTUAL RESULT SET COUNT)
    
    //return section == 0 ? youngContacts.count : elderContacts.count
    
    return section == 0 ? youngContactsCount : elderContactsCount
}
like image 113
Rob C Avatar answered Oct 21 '22 05:10

Rob C