Overview

This week I have come across an interesting limitation of UITableView

Suppose you have a list:

  • A: 10:00 AM
  • B: 09:00 AM
  • C: 08:00 AM

Where this list updates over time and the goal is to have the most recent item at the very top. Let’s say C now updates and as such it needs to be moved to the top and get a new timestamp

The desired final list is:

  • C: 11:00 AM
  • A: 10:00 AM
  • B: 09:00 AM

Two operations are needed :

  • Move index 2 to 0
  • Update index 0 (after the move)

Animating Changes

A quick thought would be to

func animateChanges()
{
    tableView.moveRowAtIndexPath(NSIndexPath(forRow: 2, inSection: 0), toIndexPath: NSIndexPath(forRow: 0, inSection: 0))
    tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: 0, inSection: 0)], withRowAnimation: .Automatic)
}

This will appear to work, however we will hit a road block the second we start working with bulk changes to animate all at once.

Bulk Changes

We can use tableView’s beginUpdates() and endUpdates() to perform batch updates

func animateChanges()
{
    tableView.beginUpdates()
    // ...
    tableView.moveRowAtIndexPath(NSIndexPath(forRow: 2, inSection: 0), toIndexPath: NSIndexPath(forRow: 0, inSection: 0))
    tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: 0, inSection: 0)], withRowAnimation: .Automatic)
    // ...
    tableView.endUpdates()
    
    // Results in Exception:
    //  Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Attempt to create two animations for cell'
}

This is when we hit the limitation I mentioned earlier, we’ll get an exception raised in this scenario

Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Attempt to create two animations for cell’

As the error states, we can’t set two animation on a single cell within one batch. That is effectively what ends up happening when we ask the tableView to move and reload the same cell.

The Workaround

One workaround would be perform updates after the batch (taking care to update the right cells!)

func animateChanges()
{
    tableView.beginUpdates()
    // ...
    tableView.moveRowAtIndexPath(NSIndexPath(forRow: 2, inSection: 0), toIndexPath: NSIndexPath(forRow: 0, inSection: 0))
    // ...
    tableView.endUpdates()
    
    tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: 0, inSection: 0)], withRowAnimation: .Automatic)
}

This will do the trick, but if you inspect the resulting animations closely you may notice a glitch. To make the glitch more noticeable, we can add another update, for example to the last cell, such that our operations are now:

  • Move index 2 to 0
  • Update index 0 (after the move)
  • Update index 2 (after the move)
func animateChanges()
{
    tableView.beginUpdates()
    // ...
    tableView.moveRowAtIndexPath(NSIndexPath(forRow: 2, inSection: 0), toIndexPath: NSIndexPath(forRow: 0, inSection: 0))
    // ...
    tableView.endUpdates()
    
    tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: 0, inSection: 0), NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic)

    // Notice the resulting animation, no longer looks like smooth move
}

The animation no longer looks like the move of one cell, instead everything fades into its final destination. One can argue its no big deal, right? but if you’ve gone to the effort of making your table view animate its updates, surely you want it to look great too?

Defering Updates

One option I tested was to defer the update animations until the moves have completed (adapted from wtmoose/TLIndexPathTools and this stack overflow question). This works for the most part however the final outcome doesn’t look as smooth and infact the previous attempt with the glitch looks better.

func animateChanges()
{
    CATransaction.begin()
    CATransaction.setCompletionBlock {
         self.tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: 0, inSection: 0), NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic)
    }

    tableView.beginUpdates()
    // ...
    tableView.moveRowAtIndexPath(NSIndexPath(forRow: 2, inSection: 0), toIndexPath: NSIndexPath(forRow: 0, inSection: 0))
    // ...
    tableView.endUpdates()
    
    CATransaction.commit()
}

The Solution

I carried on experimenting with options and variation on this and concluded with this solution which does require some additional legwork but produces the best results.

func animateChanges()
{
    tableView.beginUpdates()
    // ...
    tableView.moveRowAtIndexPath(NSIndexPath(forRow: 2, inSection: 0), toIndexPath: NSIndexPath(forRow: 0, inSection: 0))
    // ...
    tableView.endUpdates()
    
    tableView.beginUpdates()
    updateCellAtIndexPath(NSIndexPath(forRow: 0, inSection: 0))
    updateCellAtIndexPath(NSIndexPath(forRow: 2, inSection: 0))
    tableView.endUpdates()
}

func updateCellAtIndexPath(indexPath: NSIndexPath)
{
   var cell = tableView.cellForRowAtIndexPath(indexPath)
   cell.contentView.fadeTransition(0.3)
   configureCell(cell, atIndexPath:indexPath)
}

func configureCell(cell: UITableViewCell, atIndexPath indexPath: NSIndexPath)
{
   // ...
   cell.textLabel!.text = "Updated Text!"
   // ...
}

// MARK - UITableViewDataSource

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! UITableViewCell
    
    // to avoid duplicating code used to update cells
    configureCell(cell, atIndexPath:indexPath)
    return cell
}

// MARK - Extensions

// Taken from http://stackoverflow.com/a/27645516
extension UIView {    
    func fadeTransition(duration:CFTimeInterval) 
    {         
        let animation:CATransition = CATransition()         
        animation.timingFunction = CAMediaTimingFunction(name:kCAMediaTimingFunctionEaseInEaseOut)        
        animation.type = kCATransitionFade         
        animation.duration = duration         
        self.layer.addAnimation(animation, forKey: kCATransitionFade)     
    } 
}

What I’m doing here is avoiding the use of tableView’s reloadRowsAtIndexPaths method, and updating the cell content directly. Note for this to work, the assumption is the data model has already been changed ahead of the animations and the update action is applied to the cells using their new locations and not their original ones.

Here you are free to apply your own custom animations, however for a generic fade, we can leverage the CATransition animation. (Taken from this stack overflow post). Notice how we are applying this on the cell’s content view and not the entire cell, otherwise we end up with similar issues we saw when using reloadRowsAtIndexPaths.

That’s it! Happy Coding!