Summarize table animation in iOS applications

Users want to see changes.
Animated list updates have always been a daunting task in iOS. Which is unpleasant, it has always been a routine.
Applications of large companies, such as Facebook, Twitter, Instagram, VK, use tables. Moreover, almost every iOS application is written using UITableView or UICollectionView and users want to see what changes on their screens, for this reason reloadData is not suitable for updating the screen. After looking at several existing frameworks for this task, I was surprised how much they generalize in themselves, in addition to calculating animations. Some, however, when inserting one element at the beginning, joyfully reported the movements of all other elements.
Starting to solve the problem of generalizing the construction and launch of animations, I still did not understand the amount of pitfalls in the UIKit wilds. But first things first.
Change Calculation
To try to animate a table, you first need to calculate what has changed in the two lists. There are 4 types of cell changes: add, delete, update, and move. Adding and removing is pretty easy to calculate; you can take two subtraction lists. If the item is not in the original list, but is in the new one, then it was added, and if it is in the original but not in the new one, then it was deleted.
var initialList: Set = [1,2,3,5,6,7]
var resultList: Set = [1,3,4,5,6]
let insertList = resultList.subtracting(initialList) // {4}
let deleteList = initialList.subtracting(resultList) // {7, 2}
Cell updates are a bit trickier. Comparison of the cells is not enough to determine the cell update and you have to put it on the user. To do this, the updateField field is entered , in which the user gives a sign of the relevance of the cell. It can be Date (Timestamp), some kind of integer or string hash (For example, the new text of the modified message). In general, this is the sum of the fields that are drawn on the screen.
The situation is similar with moving, but a little different. Theoretically, we can, comparing fields, find out that one has moved relative to the other, but in practice the following happens:
For example, there are two arrays,
let a = [1,2,3,4]
let b = [4,1,2,3]
Quickly casting a glance at the list, you can immediately see that “4” changed its position and moved to the left, but in fact it could happen that “1”, “2” and “3” moved to the right, and “4” remained in place . The result is the same, but the methods are completely different, and different not only logically, but visually the animation for the user will be different. However, it is impossible, having on hand only a way of comparing elements, to absolutely say what exactly has moved.
But what if we introduce the statement that elements only move up? Then it becomes clear thatit has moved. Therefore, it is possible to choose a priority direction for calculating displacements. For example, when writing a messenger, a certain chat will most likely move from bottom to top when adding a new message. However, a function can be provided to indicate cell movement. Suppose the view model has a lastMessageDate field that changes with a new message and, accordingly, the sort order of this view model changes relative to others.
As a result, we calculated all 4 types of changes. The point is small - apply them.
Apply changes to a table
In order to trigger changes in the table, UITableView and UICollectionView have special change mechanisms, so we just use the standard functions.
tableView.beginUpdates()
self.currentList = newList
tableView.reloadRows(animations.toUpdate, with: .fade)
tableView.insertRows(at: animations.toInsert, with: .fade)
tableView.deleteRows(at: animations.toDelete, with: .fade)
tableView.reloadRows(at: animations.toUpdate, with: .fade)
for (from, to) in animations.cells.toMove {
tableView.moveRow(at: from, to: to)
}
tableView.endUpdates()
We start, check, everything is fine, everything works. But only for the time being ...

When we try to update and move a cell, we fall with an error: attempt to delete and reload the same index path
The first thought that comes to mind: “But I'm not trying to delete a cell!”. In fact, move and update is nothing more than delete + insert , and the table really does not like such actions and throws an error (I always wondered why try-catch should not be done already). It is simply treated, we carry out updates in the next cycle.
tableView.beginUpdates()
// insertions, deletions, moves…
tableView.endUpdates()
tableView.beginUpdates()
tableView.reloadRows(animations.cells.toDeferredUpdate, with: .fade)
tableView.endUpdates()
Now we turn to one of the most difficult problems that we had to solve.
Everything seems to work fine, but when the cell is updated, a strange “blink” is visible, regardless of whether the animation style is .fade or .none, although this is not logical.
It seems to be a trifle, but in the presence of a decent amount of updates in the table, it begins to disgustingly “re-blink”, which I really do not want. To get around all this, you have to synchronize the insert-delete-move and update animations with each other. That is, until the first .endUpdates () ends , you cannot start a new .beginUpdates (). Because of this seemingly insignificant problem, I had to write an animation synchronization class that handles this whole thing. The only drawback was that now the changes are applied not synchronously, but delayed, that is, they are put in a sequential queue.
Animation Sync Code Using DispatchSemaphore
let operation = BlockOperation()
// 1. Синхронизируем анимации. Нельзя использовать семафоры на главном потоке, так что ожидаем завершение анимации в специальной стерилизованной очереди
operation.addExecutionBlock {
// 2. Получаем текущий список. Он не передаётся явно, а получается непосредственно перед рассчётом анимаций
// потому что может быть изменён предыдущей задачей в очереди
guard let currentList = DispatchQueue.main.sync(execute: getCurrentListBlock) else { return }
do {
// 3. Просим рассчитать анимации
let animations = try animator.buildAnimations(from: currentList, to: newList)
var didSetNewList = false
DispatchQueue.main.sync {
// 4. Применяем анимации вставки, удаления и перемещения
mainPerform(self.semaphore, animations)
}
// 5. Ждём завершения анимации
_ = self.semaphore.wait()
if !animations.cells.toDeferredUpdate.isEmpty {
// 6. Происходит то же самое, только для отложенной update операции
DispatchQueue.main.sync {
deferredPerform(self.semaphore, animations.cells.toDeferredUpdate)
}
_ = self.semaphore.wait()
}
} catch {
DispatchQueue.main.sync {
onAnimationsError(error)
}
}
}
self.applyQueue.addOperation(operation)
Inside mainPerform and deferredPerform , the following happens:
table.performBatchUpdates({
// insert, delete, move...
}, completion: { _ in
semaphore.signal()
})
Finally, completing the idea, I believed that I knew everything about the anomalies, until I came across a strange bug, not always repeating itself, but on certain sets of changes when applying updates along with movements. Even if updating and moving absolutely do not intersect in any way, the table can throw a fatal exception, and I finally made sure that this cannot be resolved in any way, except for taking reload to the next cycle of applying animations. “But you can ask a cell for a table and force it to update data from it,” you say. It is possible, but only if the height of the cells is static, because its recalculation cannot simply be called up.
Later another problem arose. With the AppStore, table fall errors often began to arrive. Thanks to the logs, it was not difficult to identify the problem. Invalid lists of the form passed to the animation calculation function:
let a = [1,2,3]
let b = [1,2,3,3,4,5]
That is, identical elements were duplicated. It is treated quite simply, the animator began to throw an error during the calculation (Pay attention to the listing above, there the calculation is just wrapped in a try-catch block, just for this reason). Determining inconsistency by comparing the number of elements in the original array (Array) and the set of elements (Set). When adding an element to Set, in which it already exists, it is replaced, and therefore there are fewer elements in Set than in the array. You can disable this check, but doing so is highly discouraged. Believe me, in so many places this saved us from an error, despite the confidence of the developers in the correctness of the arguments passed.
Conclusion
Animate tables in iOS is not so simple. Most of the complexity is added by the closed source code of UIKit, in which it is impossible to always understand in what cases it throws an error. Apple's documentation on this issue is extremely scarce and only says on which list indexes (old or new) the changes need to be transmitted. The way you work with table sections is no different from working with cells, so the examples are shown only on the cells. In the article, the code is simplified for easier understanding and size reduction.
The source code is on GitHub , you can pick it up using cocoapods or using the source code. The code is tested on many cases and currently lives in the production of some applications.
Compatible with iOS 8+ and Swift 4
Material used
Apple .endUpdates Documentation
Applying Changes in iOS 11