March 10, 2015
Like I’ve said before, UITableView is the core of the user interface in a huge number of iPhone & iPad apps. It’s a pretty common pattern to set up a table view then later on need to add or remove some of the rows, like an email app needs to handle receiving new messages and deleting read messages. So today’s topic is how to do UITableView updates in Swift.
The naive approach is to simply update the data source (e.g., the array of email messages) then call myTableView.reloadData()
. That approach works but it causes the cellForRowAtIndexPath
to get called again for all of the rows, not just the ones that were added or deleted. The more efficient way is to only update the rows that were changed. Like the Star Wars movies, when you release the prequels, you don’t need to refresh the original trilogy.
This tutorial has been updated to Swift 2.0 and Xcode 7.
There’s a set of functions for UITableView that allow you to specify which rows have been changed:
func beginUpdates() // allow multiple insert/delete of rows and sections to be animated simultaneously. Nestable
func endUpdates() // only call insert/delete/reload calls or change the editing state inside an update block. otherwise things like row count, etc. may be invalid.
func insertSections(sections: NSIndexSet, withRowAnimation animation: UITableViewRowAnimation)
func deleteSections(sections: NSIndexSet, withRowAnimation animation: UITableViewRowAnimation)
func reloadSections(sections: NSIndexSet, withRowAnimation animation: UITableViewRowAnimation)
func moveSection(section: Int, toSection newSection: Int)
func insertRowsAtIndexPaths(indexPaths: [AnyObject], withRowAnimation animation: UITableViewRowAnimation)
func deleteRowsAtIndexPaths(indexPaths: [AnyObject], withRowAnimation animation: UITableViewRowAnimation)
func reloadRowsAtIndexPaths(indexPaths: [AnyObject], withRowAnimation animation: UITableViewRowAnimation)
func moveRowAtIndexPath(indexPath: NSIndexPath, toIndexPath newIndexPath: NSIndexPath)
We’ll work with a few of these methods to build a demo app to show how we’d like the Star Wars series to progress:
- First there was the original trilogy
- Then came the prequels
- Soon we’ll have the sequels which will be so sweet that we’ll deny the prequels ever existed, all records of the prequels will disappear
We’ll have a button in our app that takes up from each phase of the series to the next. Here’s what it’ll look like when it’s done:
The final version of the code is at the bottom of this post if you don’t want to type along.
The Original Trilogy
To get started:
- Create a new Swift Single View app project in Xcode.
- In the storyboard, drop a tableview in the root view controller.
- Add 1 prototype cell. Set the prototype cell to have the basic style and cell identifier “Cell”.
- Using the Edit menu, embed the root view controller in a navigation controller.
- Make the ViewController the data source & delegate for the tableview.
- In the ViewController class, set up an IBOutlet for the tableview & hook it up in the storyboard.
- Add a mutable array of movies (var, not let, cuz we all know Star Wars movies multiply over time).
Set up the movies array to have the titles of the 3 original films:
var movies:Array? = [“Star Wars”, “The Empire Strikes Back”, “A New Hope”]
Also add a tapCount variable initialized to 0.
- Set up the tableview boilerplate to display the movie titles in the tableview cells.
Now catch your breath. Our view controller’s file now looks like:
import UIKit
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@IBOutlet weak var tableView: UITableView?
var movies:Array? = ["Star Wars", "The Empire Strikes Back", "A New Hope"]
var tapCount = 0
// MARK: - TableView
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return movies?.count ?? 0
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as UITableViewCell
if let movieTitle = movies?[indexPath.row]
{
cell.textLabel!.text = movieTitle
}
else
{
cell.textLabel!.text = ""
}
return cell
}
}
Save & run and you’ll discover just about the most boring app ever… So let’s get in to some interactivity.
Add, Don’t Revise
Now we want to add the prequels. Well, don’t want to but we need to if we’re being historically accurate. We could add the items to the movies array and then refresh the whole tableview but when you release the prequels, you don’t need to refresh the originals. So let’s fix it up so that only the new movies get inserted and the originals stay in their original true form.
First we need a button to tap to get the changes to happen. Add a right-bar button item to add the prequels (in the ViewController):
override func viewDidLoad() {
super.viewDidLoad()
let addButton = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "buttonTapped:")
self.navigationItem.rightBarButtonItem = addButton
}
func buttonTapped(sender: AnyObject) {
tapCount++
}
And then we need to build out that buttonTapped function to add the sequels the first time we tap the button:
func buttonTapped(sender: AnyObject) {
tapCount++
if (tapCount == 1)
{
// Add the prequels if we're not showing them yet
movies?.insert("The Phantom Menace", atIndex: 0)
movies?.insert("The Attack of the Clones", atIndex: 1)
movies?.insert("Revenge of the Sith", atIndex: 2)
// use a single call to update the tableview with 3 indexpaths
self.tableView?.insertRowsAtIndexPaths(
[NSIndexPath(forRow: 0, inSection: 0),
NSIndexPath(forRow: 1, inSection: 0),
NSIndexPath(forRow: 2, inSection: 0)],
withRowAnimation: .Automatic)
}
}
Two things need to happen here: the data source (our movies array) needs to get updated with the 3 additional movies and we need to tell the tableview that it has 3 more rows (and where those rows are). Then the tableview will ask the data source for the contents of the new cells (which is why we had to update the movies array first) and insert the cells for the new rows. To see what’s actually happening, add print("row: \(indexPath.row), title: \(cell.textLabel!.text!)")
right before return cell
in the cellForIndexPath
function. The output when you first run the app will be:
row: 0, title: Star Wars
row: 1, title: The Empire Strikes Back
row: 2, title: A New Hope
So it’s loading the cells for the original trilogy. Then when you tap the button, you’ll get:
row: 0, title: The Phantom Menace
row: 1, title: The Attack of the Clones
row: 2, title: Revenge of the Sith
It’s only loading the new cells, not reloading those that already exist. If we had taken the naive approach of calling self.tableView?.reloadData()
instead of self.tableView?.insertRowsAtIndexPaths(...)
then after tapping the button the second time we’d get:
row: 0, title: The Phantom Menace
row: 1, title: The Attack of the Clones
row: 2, title: Revenge of the Sith
row: 3, title: Star Wars
row: 4, title: The Empire Strikes Back
row: 5, title: A New Hope
And we wouldn’t get the pretty animation when the new rows get added. In this case it’s not a big deal since we don’t have many rows and the cells aren’t difficult to load. But fancy cells can be expensive to generate, especially if you’re loading images from the web, so why have the app do more work than it needs to? If so much effort hadn’t gone in to revising the original trilogy we might have the sequels by now!
A Brave New Hope (For future movies)
When adding the movies above we only had to make one update call to the tableview (insertRowsAtIndexPaths(...)
). But if we’re making multiple updates then we need to wrap the updates in beginUpdates/endUpdates so the tableview doesn’t get confused. You’ll know the tableview is confused if it starts giving you error messages about there being the wrong number of rows in the table after an update:
starWarsTableViewUpdates[35269:6545514] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to insert row 6 into section 0, but there are only 5 rows in section 0 after the update'
To add the sequels and remove the prequels we need to add handling for a second button tap (as I’m expecting brain washing techniques to be readily available by the time they finish Episode IX):
func buttonTapped(sender: AnyObject) {
tapCount++
if (tapCount == 1)
{
// Add the prequels if we're not showing them yet
movies?.insert("The Phantom Menace", atIndex: 0)
movies?.insert("The Attack of the Clones", atIndex: 1)
movies?.insert("Revenge of the Sith", atIndex: 2)
// use a single call to update the tableview with 3 indexpaths
self.tableView?.insertRowsAtIndexPaths(
[NSIndexPath(forRow: 0, inSection: 0),
NSIndexPath(forRow: 1, inSection: 0),
NSIndexPath(forRow: 2, inSection: 0)],
withRowAnimation: .Automatic)
}
else if (tapCount == 2)
{
// Add the sequels, even though they don't exist yet
// use one call to delete and one call to add rows to the tableview
// so we need to wrap them in beginUpdates()/endUpdates()
if let count = movies?.count
{
self.tableView?.beginUpdates()
// add sequels
movies?.append("The Force Awakens")
movies?.append("Episode VIII")
movies?.append("Episode IX")
self.tableView?.insertRowsAtIndexPaths(
[NSIndexPath(forRow: count - 3, inSection: 0),
NSIndexPath(forRow: count + 1 - 3, inSection: 0),
NSIndexPath(forRow: count + 2 - 3, inSection: 0)],
withRowAnimation: .Automatic)
// remove prequels
movies?.removeAtIndex(0)
movies?.removeAtIndex(0)
movies?.removeAtIndex(0)
self.tableView?.deleteRowsAtIndexPaths(
[NSIndexPath(forRow: 0, inSection: 0),
NSIndexPath(forRow: 1, inSection: 0),
NSIndexPath(forRow: 2, inSection: 0)],
withRowAnimation: .Automatic)
self.tableView?.endUpdates()
}
}
}
So we call beginUpdates()
, add the sequels from the data source & the tableview, remove the prequels from the data source & the tableview, and call endUpdates()
. The array indexes get a little funny. Even though you’re telling the tableview to add the sequels then remove the prequels that isn’t actually what happens. Apple’s documentation:
[UITableView] defers any insertions of rows or sections until after it has handled the deletions of rows or sections […] regardless of the ordering of the insertion, deletion, and reloading method calls.
So to get the indexPaths right we need to treat the actions in the order that the tableview will perform them: first deleting, then adding. For deleting, we’re removing the first 3 rows so that’s easy to do. We can remove the first movie from the array 3 times then tell the tableview that the first 3 rows should be deleted. For inserting, we’ll be adding the 4th, 5th & 6th rows, not the 7th, 8th & 9th, because the deletion will have happened already. So, if we started with 6 rows (movies?.count = 6
), then our rows to add will be:
count - 3 = 6 - 3 = 3
count - 3 + 1 = 6 - 3 + 1 = 4
count - 3 + 2 = 6 - 3 + 2 = 5
Kinda odd but that’s how it works. If you run the app and tap the button twice you’ll now see the animation to our final desired state happen. The output when you tap the button will be:
row: 3, title: The Force Awakens
row: 4, title: Episode VIII
row: 5, title: Episode IX
And those row numbers confirm that the tableview didn’t add the new cells until after it deleted the prequel cells. Otherwise the rows would have been 6, 7 and 8.
And that’s all about UITableView Updates
With the release of the sequels and subsequent denial that the prequels ever existed, all is good in the world :) We can only hope that the actual future of the series goes this way.
P.S. If you got tired of typing, here’s the final code.
If you’d like more Swift developer tips like this one, sign up below to get them sent directly to your inbox. Feel free to come back and visit too, we’re pretty friendly around here but we’ll understand if it’s just easier for us to come to you.