Search code examples
iosuitableviewswiftcore-datansfetchedresultscontroller

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?


Solution

  • 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 )
            }
        }
    }