📜 ⬆️ ⬇️

We summarize the animation tables in iOS applications

image

Users want to see changes.


Animated updating of lists has always been a challenge in iOS. What is unpleasant, it has always been a routine task.

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. Having looked at several existing frameworks for this task, I was surprised at how much they generalize in themselves, besides calculating animations. Some even when inserting one element in the beginning, happily reported the movements of all other elements.

Having started to solve the problem of generalizing the construction and launch of animations, I still did not understand such a large number of pitfalls in the wilds of UIKit. But first things first.

Calculation of changes


To try to animate a table, you first need to figure out what has changed in the two lists. There are 4 types of cell changes: add, delete, update and move. Adding and deleting calculations is quite simple; you can take two subtract lists. If the element is not in the original list, but is in the new one, then it was added, and if it is in the original one, but not in the new one, then it was deleted.
')
var initialList: Set<Int> = [1,2,3,5,6,7] var resultList: Set<Int> = [1,3,4,5,6] let insertList = resultList.subtracting(initialList) // {4} let deleteList = initialList.subtracting(resultList) // {7, 2} 

Updating a cell is a bit more complicated. Comparison of cells is not enough to determine the update of the cell and you have to assign it to the user. To do this, the updateField field is started , in which the user gives a sign of the cell relevance. This could be a Date (Timestamp), some kind of integer or string hash (For example, a new text of a modified message). In general, this is the sum of the fields that are drawn on the screen.
The situation is similar with movement, but a bit different. Theoretically, we can, comparing the 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 glancing at the list, it is immediately obvious 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 also visually, the animation for the user will be different. However, it is impossible, having on hand only a way of comparing the elements, absolutely say what exactly has moved.
But what if we introduce the statement that elements only move up? Then it becomes clear what exactly has moved. Therefore, it is possible to choose the priority direction for calculating movements. For example, when writing an instant messenger, a particular chat is likely to move from the bottom up when a new message is added. However, you can provide a function to indicate the movement of a cell. Suppose in a model view there is a field lastMessageDate , which changes with a new message and, accordingly, the sort order of the view model is changed relative to the others.
As a result, we calculated all 4 types of changes. Things are easy - apply them.

Apply changes to a table


In order to start changes in the table, special mechanisms of changes are provided in the UITableView and UICollectionView, 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 ...

image

When we try to update and relocate a cell, we fall with the error:

The first thought that comes to mind is: “But I'm not trying to delete a cell!”. In fact, move and update is nothing more than delete + insert , and the table does not like such actions and throws an error (I was always surprised why not try try-catch ). It is treated simply, we make updates in the next cycle.

 tableView.beginUpdates() // insertions, deletions, moves… tableView.endUpdates() tableView.beginUpdates() tableView.reloadRows(animations.cells.toDeferredUpdate, with: .fade) tableView.endUpdates() 

We now turn to one of the most difficult problems that had to be solved.
Everything seems to work fine, but when the cell is updated, a strange “blinking” is seen, and 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 starts to disgustingly “remarc”, which is something you don’t want. To get around this, you have to synchronize the insert-delete-move and update animations. That is, until the first .endUpdates () is finished, you cannot start a new .beginUpdates () . Because of this seemingly minor problem, I had to write a sync animation class that handles the whole thing. The only drawback was that now the changes are not applied synchronously, but deferred, that is, they are put in a sequential queue.

Animation Synchronization Code with 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, having completed the idea, I believed that I knew everything about anomalies, until I came across a strange bug that does not always recur, but on certain sets of changes, when you apply updates along with movements. Even if the update and the move absolutely do not intersect at all, the table can throw a fatal exception, and I finally made sure that it could not be resolved, except for bringing the reload into the next cycle of animations. “But you can ask a cell from a table and forcibly update its data,” you say. It is possible, but only in the case when the height of the cells is static, because it cannot simply be recalculated.

There was another problem later. With the AppStore, errors in the fall of the table began to arrive frequently. Thanks to the logs, it was not difficult to identify the problem. In the calculation function of the animation passed invalid lists of the form:

 let a = [1,2,3] let b = [1,2,3,3,4,5] 

That is, the same elements were duplicated. It is treated quite simply, the animator began to throw an error in the calculation (Note the listing above, there the calculation is wrapped in a try-catch block, just for this reason). Determining inconsistency by comparing the number of elements in the source array (Array) and the set of elements (Set). When adding an element to the Set, in which it already exists, it is replaced, and therefore the elements in the Set refuse less than in the array. This check can be turned off, but it is not recommended to do this. Believe me, in so many places it saved from an error, despite the developers' confidence in the correctness of the arguments passed.

Conclusion


Animated tables in iOS is not so easy. Most of the complexity is added by the closed source code of UIKit, in which it is impossible to always understand in which cases it throws an error. Apple's documentation on this issue is extremely scarce and says only about which indices of the list (old or new) it is necessary to transfer changes. The way of working with sections of tables is no different from working with cells; therefore, examples are shown only on cells. In the article, the code is simplified for easier comprehension and size reduction.

The source code is on GitHub , you can pick it up using cocoapods or using the source code. The code has been tested on many cases and currently lives in the production of some applications.
Compatible with iOS 8+ and Swift 4

Material used


Apple documentation on .endUpdates
Applying changes in iOS 11

Source: https://habr.com/ru/post/350230/


All Articles