Christian W
Christian W

Reputation: 88

Avoid jump in `UITableView` during background update from `NSFetchedResultsController`

I have a UITableView used with NSFetchedResultsController right from the book.

The CoreData objects are updated by a background process so every update automatically triggers an update of the FetchedResult and UITableView.

Pulling down the UITableView during these updates causes a disturbing jump back to the top followed by a snap back to the pulldown position when pulling further: screencast showing the effect

The entity:

@objc(Entity)
class Entity: NSManagedObject {

    @NSManaged var name: String
    @NSManaged var lastUpdated: NSDate

}

The results:

private lazy var resultsController: NSFetchedResultsController = {

    let fetchRequest = NSFetchRequest( entityName: "Entity" )
    fetchRequest.sortDescriptors = [ NSSortDescriptor( key: "name", ascending: true ) ]
    fetchRequest.relationshipKeyPathsForPrefetching = [ "Entity" ]

    let controller = NSFetchedResultsController(
        fetchRequest: fetchRequest,
        managedObjectContext: self.mainContext,
        sectionNameKeyPath: nil,
        cacheName: nil
    )
    controller.delegate = self
    controller.performFetch( nil )

    return controller
}()

The usual update logic combining UITableView and ResultsController:

func controllerWillChangeContent(controller: NSFetchedResultsController) {
    tableView.beginUpdates()
}

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {

    switch( type ) {
    case NSFetchedResultsChangeType.Insert:
        tableView.insertRowsAtIndexPaths( [ newIndexPath! ], withRowAnimation: UITableViewRowAnimation.Fade )
    case NSFetchedResultsChangeType.Delete:
        tableView.deleteRowsAtIndexPaths( [ indexPath! ], withRowAnimation: UITableViewRowAnimation.Fade )
    case NSFetchedResultsChangeType.Update:
        tableView.reloadRowsAtIndexPaths( [ indexPath! ], withRowAnimation: UITableViewRowAnimation.None )
    case NSFetchedResultsChangeType.Move:
        tableView.moveRowAtIndexPath( indexPath!, toIndexPath: newIndexPath! )
    default:
        break
    }
}

Background update (not the original one which is more complex) to show the effect:

override func viewDidLoad() {
    super.viewDidLoad()

    //...

    let timer = NSTimer.scheduledTimerWithTimeInterval( 1.0, target: self, selector: "refresh", userInfo: nil, repeats: true)
    NSRunLoop.mainRunLoop().addTimer( timer, forMode: NSRunLoopCommonModes )
}

func refresh() {
    let request = NSFetchRequest( entityName: "Entity" )

    if let result = mainContext.executeFetchRequest( request, error: nil ) {
        for entity in result as [Entity] {
            entity.lastUpdated = NSDate()
        }
    }
}

I have searched stackoverflow for hours now and tried almost everything. The only think making this stop is not calling tableView.reloadRowsAtIndexPaths() and also not beginUpdates() / endUpdates() so both seem to cause this effect.

But this is no option, because the UITableView would not update at all.

Any suggestions or solutions anyone?

Upvotes: 0

Views: 1039

Answers (2)

Christian W
Christian W

Reputation: 88

I just found a solution that works for me.

There are two actions that conflict with my background updates:

  1. dragging/scrolling
  2. editing (not mentioned above, but needed also)

So following Timur X's suggestion both actions will have to lock the updates. And this is what I did:

A locking mechanism for multiple reasons

enum LockReason: Int {
    case scrolling = 0x01,
    editing   = 0x02
}

var locks: Int = 0

func setLock( reason: LockReason ) {
    locks |= reason.rawValue
    resultsController.delegate = nil
    NSLog( "lock set = \(locks)" )
}

func removeLock( reason: LockReason ) {
    locks &= ~reason.rawValue
    NSLog( "lock removed = \(locks)" )
    if 0 == locks {
        resultsController.delegate = self
    }
}

Integrating the locking mechanism for scrolling

override func scrollViewWillBeginDragging(scrollView: UIScrollView) {
    setLock( .scrolling )
}

override func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        removeLock( .scrolling )
    }
}

override func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
    removeLock( .scrolling )
}

handle editing (delete in this case)

override func tableView(tableView: UITableView, willBeginEditingRowAtIndexPath indexPath: NSIndexPath) {
    setLock( .editing )
}

override func tableView(tableView: UITableView, didEndEditingRowAtIndexPath indexPath: NSIndexPath) {
    removeLock( .editing )
}

and this is the integration of editing/delete

override func tableView(tableView: UITableView, titleForDeleteConfirmationButtonForRowAtIndexPath indexPath: NSIndexPath) -> String! {
    return "delete"
}

override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
    return true
}

override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    if editingStyle == UITableViewCellEditingStyle.Delete {
        removeLock( .editing )
        if let entity = resultsController.objectAtIndexPath( indexPath ) as? Entity {
            mainContext.deleteObject( entity )
        }
    }
}

Upvotes: 1

Timur X.
Timur X.

Reputation: 161

What you can do is simple; Dont reload the table always for a coredata update, check first if the table is standing still..

At the moment when you need to update the tableview from the coredata update, check if tableview is scrolling(lots of ways to do so). If so, dont reliad, but do this: in the tableview class mark that the table needs to be updated afterwards with a boolean for example "shouldUpdateTable". Than in a tableview delegate method for "did finish scrolling animation" check if the table need to be reloaded from that boolean marker, and if true, reload the table and reset the boolean.

Hope this aproach helps.

Upvotes: 1

Related Questions