Tuesday, July 9, 2013

Updating UITableView with a dynamic data source

UITableView is one of the most versatile UIViews in UIKit.  It can be used in so many ways, the mind boggles.  And it's greatest benefit is efficiently showing data (as cells) with low overhead.  It not only can restrict generating UI to only the cells that are shown in an efficient manner, it can also update its UI with animations in a very efficient manner using deleteSections:withRowAnimation:, reloadSections:withRowAnimation:, insertSections:withRowAnimation:, moveSection:toSection:, deleteRowsAtIndexPaths:withRowAnimation:, reloadRowsAtIndexPaths:withRowAnimation:, insertRowsAtIndexPaths:withRowAnimation:, and moveRowAtIndexPath:toIndexPath:.  But what if the management of the data source that backs the table view isn't maintained by your application?  What if you have a REST API that returns a JSON data structure that you use to populate your UITableView?  If that data ever changes on a subsequent GET, you have little choice but to perform a reloadData, right?  No way!  We're PROgrammers! We can tackle this!

Dynamically updating a UITableView





So let's ask ourselves, what would we like to have happen when a data source changes?
  • We want it to update the UITableView (of course)
  • We want it to animate any changes that have occurred to show the user there was a change and what that change was
  • We want it to happen efficiently
Let's play this out with an example to help give us a visual model.  Say that our data source is an array of companies, with each company containing an array of products.  Let's assume we are looking at our table view with its initial state of 4 companies, each with 3 products.
  • Amazon
    • Kindle
    • Kindle Fire
    • Kindle Fire HD "7
  • Apple
    • iPad
    • iPhone
    • iPod
  • Blackberry RIM
    • Blackberry Q5
    • Blackberry Q10
    • Blackberry Z10
  • Google
    • Nexus 4
    • Nexus 7
    • Nexus 10
Great, we have a model for our table view to display.  The companies will be the titles of each table view section and the products will be the text in each table view cell.  Now let's say we have a 30 second refresh on that data and somewhere along the line, the data loads in from our hypothetical API with some changes:
  • Amazon
    • Kindle
    • Kindle Fire HD "7
    • Kindle Fire HD "8.9
  • Apple
    • New iPad
    • iPad Mini
    • iPhone 5
    • iPod Touch
  • Google
    • Nexus 4
    • Nexus 7
    • Nexus 10
    • Nexus Watch
  • Microsoft
    • Windows 8 Phone
    • Windows Surface RT
    • Windows Surface Pro
Well, we've got a different model now, so the table view needs to be updated.  We could do the easy thing and perform a reloadData which would reload the entire table view with a flash.  But we're PROgrammers, and we want to animate all the differences.  Let's track the differences.
  1. Addition of Microsoft as a company with 3 products: Windows 8 Phone, Windows Surface RT, and Windows Surface Pro
  2. Addition of iPad Mini to Apple's products
  3. Renaming of iPad to New iPad, iPhone to iPhone 5 and iPod to iPod Touch in Apple's products
  4. Addition of Nexus Watch to Google's products
  5. Addition of Kindle Fire HD "8.9 to Amazon products
  6. Removal of Kindle Fire from Amazon's products
  7. Removal of RIM as a company as well as a all of its products
This actually seems fairly straightforward, right?  We just map these changes into UITableView's delete, insert and reload methods to affect each section and row that changed.  Skipping the code that would determine the diff between the previous data model and the new data model, let's write the code that would be used to update to the new model (notice that we use beginUpdates and endUpdates to bookend the changes so that we perform all the changes at once).

[_tableView beginUpdates];

[_tableView insertSections:[NSIndexSet indexSetWithIndex:4] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView removeSections:[NSIndexSet indexSetWithIndex:2] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:1]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:1 inSection:1], [NSIndexPath indexPathForRow:2 inSection:1], [NSIndexPath indexPathForRow:3 inSection:1]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:3 inSection:3]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:3 inSection:0]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView deleteRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView deleteSections:[NSIndexSet indexSetWithIndex:4] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView endUpdates];

So we compile and run our test case and what do we get?  Crash!!!  What?  But why?  Well, UITableView actually has an order of operations that it adheres to, so it's not merely the first modification called is the first applied.  Apple's UITableView Batch Changing of Rows and Sections documentation states the order of operations plainly:

Ordering of Operations and Index Paths

You might have noticed something in the code shown in Listing 7-8 that seems peculiar. The code calls the deleteRowsAtIndexPaths:withRowAnimation: method after it callsinsertRowsAtIndexPaths:withRowAnimation:. However, this is not the order in which UITableView completes the operations. It defers any insertions of rows or sections until after it has handled the deletions of rows or sections. The table view behaves the same way with reloading methods called inside an update block—the reload takes place with respect to the indexes of rows and sections before the animation block is executed. This behavior happens regardless of the ordering of the insertion, deletion, and reloading method calls.
Deletion and reloading operations within an animation block specify which rows and sections in the original table should be removed or reloaded; insertions specify which rows and sections should be added to the resulting table. The index paths used to identify sections and rows follow this model. Inserting or removing an item in a mutable array, on the other hand, may affect the array index used for the successive insertion or removal operation; for example, if you insert an item at a certain index, the indexes of all subsequent items in the array are incremented.

So our entire expectation of how the batch update will occur was wrong, we'll need to rewrite the update. I've elected to maintain the order of operations by rearranging the order in which I call the change methods - for clarity sake.

[_tableView beginUpdates];

// Deletes

[_tableView deleteSections:[NSIndexSet indexSetWithIndex:2] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView deleteRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationAutomatic];

// Reloads

[_tableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:1], [NSIndexPath indexPathForRow:1 inSection:1], [NSIndexPath indexPathForRow:2 inSection:1]] withRowAnimation:UITableViewRowAnimationAutomatic];

// Inserts

[_tableView insertSections:[NSIndexSet indexSetWithIndex:3] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:3 inSection:0]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:1]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:3 inSection:2]] withRowAnimation:UITableViewRowAnimationAutomatic];

[_tableView endUpdates];

Now this will effectively animate in all the changes we need.  Great for hardcoding a single known change, but we want to be dynamic so that when a change occurs we can detect the change and apply the change at once.  The high level answer is we need to establish a diff and then apply it to the table view.

  • Traverse the old sections versus the new sections to determine what sections were inserted, deleted and changed (i.e. needing a reload).
  • For each section that was not deleted, inserted or changed; we also want to traverse the old rows versus the new rows to determine what rows were inserted, deleted and changed (i.e. needing a reload).
  • If we run into anything we can't handle (for example this implementation will be forgoing the ability to determine a move) in row checking, we will reload the entire section.  
  • Likewise if we run into anything we can't handle in section checking, we will reload the entire table.
We'll aggregate these changes into a NSMutableIndexSets for the section changes NSMutableArrays for the row changes. Once we've amassed the changes, and we aren't reloading the entire table view, we apply them in order:
  1. Delete sections
  2. Delete rows
  3. Reload sections
  4. Reload rows
  5. Insert sections
  6. Insert rows
And then we'll have it - a complete mechanism to update our table view with an animation!

Now the shrewd will notice a very important piece missing before we can rush into implementing this - how do we identify if any given section or row is different?  And for that matter how do we get the necessary information of the data source's structure to be able to establish a diff?  Well, we can take what UITableViewDataSource does well (establishing the layout structure of a data source's structure) and expand it into a protocol that also get's identifiable information of the elements of that data structure, both of the new update version and the old previous version.

I've created a header for implementing this that should be more than enough to get started for implementing the updateData:

#import <UIKit/UIKit.h>

@protocol UITableViewUpdatingDataSource;

@interface UITableView (Updating)
- (void) updateData;
@end


@protocol UITableViewUpdatingDataSource <UITableViewDataSource>

@required

// Identify the number of sections
- (NSInteger) numberOfPreviousSectionsInTableView:(UITableView*)tableView;
- (NSInteger) numberOfSectionsInTableView:(UITableView *)tableView;

// Identify the number of rows in a given section
- (NSInteger) tableView:(UITableView*)tableView numberOfRowsInPreviousSection:(NSInteger)section;
- (NSInteger) tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section;

// Retrieve an object representation for a section
- (NSObject*) tableView:(UITableView*)tableView objectForPreviousSection:(NSInteger)section;
- (NSObject*) tableView:(UITableView*)tableView objectForSection:(NSInteger)section;

// Retrieve an object representation for a row
- (NSObject*) tableView:(UITableView*)tableView objectAtPreviousIndexPath:(NSIndexPath*)indexPath;
- (NSObject*) tableView:(UITableView*)tableView objectAtIndexPath:(NSIndexPath*)indexPath;

// Retrieve unique identifier for a given section or row's object representation
- (NSObject<NSCopying>*) tableView:(UITableView*)tableView keyForSectionObject:(NSObject*)object;
- (NSObject<NSCopying>*) tableView:(UITableView*)tableView keyForRowObject:(NSObject*)object;

@optional

// Optional compare if two objects (with the same key) are equal.  If not implemented, isEqual: is used.
- (BOOL) tableView:(UITableView *)tableView isPreviousSectionObject:(NSObject*)previousObject equalToSectionObject:(NSObject*)object;
- (BOOL) tableView:(UITableView *)tableView isPreviousRowObject:(NSObject*)previousObject equalToRowObject:(NSObject*)object;

// Optional callbacks for the start and end of an update
- (void) tableViewWillUpdate:(UITableView*)tableView;
- (void) tableViewDidUpdate:(UITableView*)tableView;

@end

Two important optimizations can be made to updateData as well. First, if the table view is not actually visible, there is no need to animate and reloadData can be used. Second, if the dataSource of the table view does not conform to the UITableViewUpdatingDataSource protocol, we can also just fallback to calling reloadData. Lastly, the algorithm for updateData can be achieved in O(n) time (so a data set with thousands of cells will update in less than 250ms). Expanding it, however, to support rows and sections being moved will require O(n^2) time (and is why I left it out of my solution).

But of course, I didn't just create a header - I've added UITableView+UpdateData.h and UITableView+UpdateData.m to my NSProgrammer github repo and even added an example application named TableViewChanges to show how it works. Enjoy!

No comments:

Post a Comment