Android DiffUtil Part 2: List Diffs on other platforms
This is the second post in a series that looks into calculating diffs between two lists on Android. You can read Part 1 here. In this post, we will look at how other platforms handle list diffing.
Edit: Edited to add code snippets for each platform.
Swift Standard Library
The Swift standard library has a difference(from:)
method on BidirectionalCollection
protocol that returns a CollectionDifference
result. This blog post does a deep dive into this API in Swift.
It looks like this facility is intended as a general purpose list diff API, not specific to UI programming. Remember, it is in the standard library so it can be used in backend server programming, for example.
Example
let oldList = ["A", "B", "C", "D"]
let newList = ["A", "B", "D", "E"]
let diffResult = newList.difference(from: oldList)
print(diffResult)
//CollectionDifference<String>(
// insertions: [.insert(offset: 3, element: "E", associatedWith: nil)],
// removals: [.remove(offset: 2, element: "C", associatedWith: nil)]
//)
Some notable features of this API
- The most interesting feature is the return type:
CollectionDifference
. This API provides you a way to iterate through all the diff operations, or even to only pick the insertions (or removals). This is different from how Android does it. More on this in a minute. - By default, it does not detect moves, but there’s an
inferringMoves()
method onCollectionDifference
that you can use if you want to do this - It uses equality by default for the comparison, but you can customize how the comparison occurs using
difference(from: by:)
variant. Here you pass in a closure that returns aBool
so you can use whatever logic you wish to compare the elements.
CollectionDifference
The CollectionDifference
provides in itself a Collection of CollectionDifference.Change
- which is an enum with 2 values: .insert
and .remove
.
.insert
provides you with anelement
and its offset in the final list.remove
provides you with anelement
and its offset in the original list- The
associatedWith
parameter of the enums inform you about moves
There’s no concept of “changes” in this API - i.e., it does not tell you if an item retained its identity but did not retain equality.
I could not find a way to convert positions between old and new lists, but I’m not sure if it is ever required when using this API in practice.
Swift Apple platforms
We started this series with an example of how Android’s RecyclerView animates between 2 lists using DiffUtil. It should come as no surprise that Apple’s UI frameworks have similar capabilities too.
It has always been possible to achieve this on Apple platforms but it has been verbose and error-prone (frequently giving rise to the Swift equivalent of ArrayIndexOutOfBoundsException
). Recent API improvements have greatly enhanced the developer ergonomics here.
The headline API is UITableViewDiffableDataSource
and friends (quite a mouthful!). This is completely out of my comfort zone so I’ll point you to these talks from WWDC (there are also PDF slides available) if you want to learn more. I will point out though, that the items participating in this API need to be Hashable
. This is how the framework decides that items have “changed”. It fulfils the role of areContentsTheSame()
from Android’s DiffUtil.
Flutter
I could not find any official API for List Diffs in Flutter. However, there’s a third party library that is inspired by Android’s DiffUtils. It is called AnimatedStreamList. The relevant files in this repo are myers_diff.dart
and diff_payload.dart
.
Example
List<String> oldList = ["A", "B" ,"C", "D"];
List<String> newList = ["A", "B" ,"D", "E"];
List<Diff> diffs = diffUtil.calculateDiff(oldList, newList);
//diffs[0] = DeleteDiff(2, 1)
//diffs[1] = InsertDiff(3, 1)
Of note in this library:
- It returns a
List<Diff>
. In this sense it is similar to the Swift implementation Diff
can be one ofInsertDiff
,DeleteDiff
orChangeDiff
. This library does not implement moves.- Each instance of
Diff
includes anindex
and asize
. In this respect, it is similar to the Android DiffUtil implementation. - It uses an
Equalizer
to customize the comparison.
Angular
Angular has an IterableDiffer
API that can be used to compute the diff between 2 Iterables. From what I can tell, it is not intended to be used directly by applications, instead it is used internally by the framework (for example, by the NgForOf
directive). This article goes into the nuts and bolts of this API.
Example
const oldList = ["A", "B", "C", "D"];
const newList = ["A", "B", "D", "E"];
const diffResult = differ.diff(ngForOf);
//diffResult consists of following IterableChangeRecords
//(item = "C", currentIndex = null, previousIndex = 2)
//(item = "E", currentIndex = 3, previousIndex = null)
//(item = "D", currentIndex = 2, previousIndex = 3)
The interesting classes are
IterableDiffer
: The entry point of the API. Offers thediff
functionIterableChanges
: The diff result, which in itself is an IterableIterableChangeRecord
: Each individual update operation
The IterableChanges
interface is pretty interesting: it exposes functions to iterate over the changes in a variety of ways (all updates, only additions, only removals etc). The DefaultIterableDiffer
accepts a TrackByFn
argument, which fulfils the role of Android’s DiffUtil.Callback
.
IterableChangeRecord
is also interesting: It does not directly state the diff operation. Instead, it contains currentIndex
and previousIndex
. Together, these can be used to decide if an item was added, removed etc. It also fulfils the role of position conversion APIs in Android.
In practice, you’d probably use the IterableChanges
API to figure out the additions and removals.
At a glance
Here’s a table summarizing all the diff APIs across these platforms.
Android | Swift | Flutter (3rd party) | Angular | |
---|---|---|---|---|
Detect Moves | Yes | Yes | No | Yes |
Change payloads | Yes | No | Yes | Yes |
Custom comparison | DiffUtil.Callback | difference(from:by:) , Hashable | Equalizer | TrackByFn |
Position conversion | Methods on DiffResult | NA | NA | IterableChangeRecord |
A note about declarative UI frameworks
This series of blog posts actually started when I was trying to implement custom animations for a list view on Android. When I started this research, the question I wanted to answer was
How do declarative UI frameworks deal with allowing custom animations for changes in lists?
Note that declarative UI frameworks in general receive a UI state and render that state. They don’t have a concept of “previous state” so “this item was removed” animation does not fit into this paradigm.
So far, I haven’t found an answer to this question!
- SwiftUI provides some default animations, but I did not find a way to customize them.
- Flutter has no official APIs for this use case.
- Angular has some APIs that look like they are used internally. I’m way out of my depth about Angular to form any practical opinion about it.
It will be really interesting to see how Jetpack Compose is going to solve this problem!
Conclusion
After my research for this post, I came to the conclusion that Android’s DiffUtil API is the most flexible of all. It is the lowest level API for exposing the diff operations (the ListUpdateCallback
). All other platforms expose collection-style APIs for this purpose.
I reckon Android has this low-level API because it plays well together with RecyclerView Adapter API. One can write a wrapper to expose it as a collection-style API.
That is exactly what we will do in the next post in this series: Look at an example situation where RecyclerView might not be best fit, and instead wrap the ListUpdateCallback
to implement some custom UI.