Simplifying animations using batch updates on iOS

Ivan Rep
Undabot
Published in
5 min readApr 18, 2017

A lot of our work as iOS developers comes down to presenting data. In an effort to simplify the process, we often separate the data into small, workable chunks that we present in views called cells. We then present cells using table view or collection view, depending on our needs.

To update the data we can call reloadData() on the containing view. The problem with this approach is that it does not animate the changes.

To do the animations we need to manually determine which items need to be animated. This means calculating which items have changed and calling the following methods:

func deleteItems(at: [IndexPath])
func moveSection(Int, toSection: Int)
func moveItem(at: IndexPath, to: IndexPath)
func insertItems(at: [IndexPath])
func insertSections(IndexSet)
func deleteSections(IndexSet)

What if we could skip this part and use something as simple as reloadData() and be sure our cells are animating nicely? We’ll try to accomplish just that!

Without animations

Let’s start with a simple example. We all love Bruce Willis, so let’s make an app showing a list of his most memorable movies. To simulate content change I will add an option to delete movies and an option to reset the whole content to its original state. The full example is available at: https://github.com/Rep2/BatchUpdatesExample_Swift.git

I will use a view controller — presenter architecture to better demonstrate the problem.

The view controller presents movies and passes user actions to its event handler, i.e. presenter. The presenter implements the event handler protocol and updates the view when needed.

protocol MovieViewController: class {
var eventHandler: MovieViewEventHandler? { get set }
func present(presentables: [MoviePresentable])
}
protocol MovieViewEventHandler {
func didDelete(presentable: MoviePresentable)
func didPressReset()
}

Basic implementation of the MovieViewController takes an array of movies and reloads the table view, while Async call is needed if the method gets called from the background thread.

extension BasicMovieViewController: MovieViewController {
func present(presentables: [MoviePresentable]) {
self.presentables = presentables
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}

With manual animations

Let’s try to implement animations using the methods mentioned above.

To delete a movie we can use func deleteItems(at: [IndexPath]) . In order to access it, we need to expose it in the MovieViewController protocol.

But what about resetting the list? We could keep track of all deleted movies and then use func insertItems(at: [IndexPath]) to put them all back in. Again, we need to expose the method so that it can be called by the presenter.

We need to add two new methods to theMovieViewController protocol:

func deletePresentables(at indexPaths: [IndexPath], with 
presentables: [MoviePresentable])
func insertPresentables(at indexPaths: [IndexPath], with
presentables: [MoviePresentable])

and change the presenter so that it tracks which items it needs to animate:

func didDelete(presentable: MoviePresentable) {
if let presentableIndex = presentables.index(of: presentable) {
presentables.remove(at: presentableIndex)
view?.deletePresentables(
at: [IndexPath(row: presentableIndex, section: 0)],
with: presentables)
if let startIndex = MoviePresentable.mock
.index(of: presentable) {
deletedIndexes.append(startIndex)
}
}
}
override func didPressReset() {
presentables = MoviePresentable.mock
view?.insertPresentables(
at: deletedIndexes.map { IndexPath(row: $0, section: 0) },
with: presentables
)
deletedIndexes = []
}

Quite a bit of work, but we got our animations. The results are shown below, without animations on the left, and with animations on the right.

Without animations (left) vs with animations (right)

The main problem is, in my opinion, that we need to expose the inner implementation of view controllers data presentation. We are clearly stating that we present data in rows and we leave it to the presenter to animate these rows.

Finally, using batch updates

Is there a better way to do this? This is where batch updates come into play. Instead of manually determining which animations need to be done, ideally, we should try to find a way to get the work done for us

Let’s take a step back in the whole process. In the most basic terms, what are we doing? We are switching one data set with another. Whether we are deleting an item, inserting new ones or initially presenting data, we are switching one data set with another one.

If we can compare these sets, we can calculate which changes were made and we can animate those changes.

To calculate changes I use the function below. It calculates the difference between two arrays containing values implementing the Equatable protocol. The complexity of the function is O(n²) and it can be improved if the values also implement the Comparable protocol. So far we haven’t detected any performance issues while testing, but the function could probably be improved so please comment down bellow.

struct BatchUpdates {
let deleted: [Int]
let inserted: [Int]
let moved: [(Int, Int)]
let reloaded: [Int]
}
func compare<T: Equatable>(oldValues: [T], newValues: [T]) ->
BatchUpdates {
var deleted = [Int]()
var moved = [(Int, Int)]()
var remainingNewValues = newValues
.enumerated()
.map { (element: $0.element, offset: $0.offset,
alreadyFound: false) }
outer: for oldValue in oldValues.enumerated() {
for newValue in remainingNewValues {
if oldValue.element == newValue.element &&
!newValue.alreadyFound {

if oldValue.offset != newValue.offset {
moved.append((oldValue.offset, newValue.offset))
}

remainingNewValues[newValue.offset]
.alreadyFound = true
continue outer
}
}
deleted.append(oldValue.offset)
}
let inserted = remainingNewValues
.filter { !$0.alreadyFound }
.map { $0.offset }
return BatchUpdates(deleted: deleted, inserted: inserted,
moved: moved)
}

The function returns the BatchUpdates structure used to perform animations. The extensions bellow implement animations for the UICollectionView and UITableView using given the BatchUpdates structure.

extension UICollectionView {
func reloadData(with batchUpdates: BatchUpdates) {
performBatchUpdates({
self.insertItems(at: batchUpdates.inserted.map {
IndexPath(row: $0, section: 0) })
self.deleteItems(at: batchUpdates.deleted.map {
IndexPath(row: $0, section: 0) })
self.reloadItems(at: batchUpdates.reloaded.map {
IndexPath(row: $0, section: 0) })
for movedRows in batchUpdates.moved {
self.moveItem(
at: IndexPath(row: movedRows.0, section: 0),
to: IndexPath(row: movedRows.1, section: 0)
)
}
})
}
}
extension UITableView {
func reloadData(with batchUpdates: BatchUpdates) {
beginUpdates()

insertRows(at: batchUpdates.inserted
.map { IndexPath(row: $0, section: 0) }, with: .fade)
deleteRows(at: batchUpdates.deleted
.map { IndexPath(row: $0, section: 0) }, with: .fade)
reloadRows(at: batchUpdates.reloaded
.map { IndexPath(row: $0, section: 0) }, with: .fade)
for movedRows in batchUpdates.moved {
moveRow(at: IndexPath(row: movedRows.0, section: 0),
to: IndexPath(row: movedRows.1, section: 0))
}

endUpdates()
}
}

Now, instead of manually inserting and deleting items we can just call present data and the view will animate the changes by itself. In fact, we can use the same code we used in the first example. The only thing that we need to change is the present method.

func present(presentables: [MoviePresentable]) {
let oldPresentables = self.presentables
self.presentables = presentables
tableView.reloadData(
with: BatchUpdates.compare(
oldValues: oldPresentables,
newValues: presentables
)
)
}

We even got rid of the async call. Even better, this method works on any data change, as long as the elements we present are comparable. No matter what operation we do or how we change the data we can simply call the present method.

Thank you for reading, I hope you liked the post and found it useful. Feel free to comment or add suggestions for improvements.

Thanks to Sinisa Cvahte for the design.

Thank you for reading. Please comment, like or share it with your friends and we hope to see you soon.

Would you like to join us? Check out the open positions at our Careers page.

Undabot and Trikoder are partner organisations. We analyze, strategize, design, code and develop native mobile apps and complex web systems.

--

--