This is part 3 in a series of posts that looks into DiffUtil on Android. The previous posts in this series are:
- Part 1 where we take a deep dive into the DiffUtil API
- Part 2 where we compare this API with similar APIs on other platforms.
In this post, we'll look at an example of when to use custom animations.
The sample app
Here's an example of the animations that we will achieve. The code for this sampe is available here
This is admittedly a goofy example, but it serves a purpose. I originally wanted to demonstrate this using visualization of RingBuffer data structure as an example, but I settled for this Color Circles example because it prevents us from getting distracted learning ring buffer!
Here is how the sample works:
- There are 7 slots, arranged in a circle
- Each slot can be empty, or occupied by a colored view (I chose the colors of the rainbow, hence the number 7)
- Each view can be "expanded" or not
The data for the view is a
CircleInfo is defined as follows:
The "Toggle" button switches between two hard-coded lists. It submits the entire list to the
ColorCirclesView, which in turn applies a diff and runs some animations:
- If an item's
expandedproperty changed, then we animate the change in width and height of that view
- If an item was removed, then we shrink the view until it disappears, while simultaneously moving it to the center of the circle
- If an item was added, then we expand it from size 0 to its final size, while simultaneously moving it from the center of the circle to its final position along the circumference.
- For items that were present in both lists, but their positions in the list changed, we move the views along the circumference of the circle to arrive at the new positions. This animation "makes room" for items being inserted, and "fills the gap" created by disappearing items.
Finally, we run all these animations in a pre-determined order:
- All removals first
- Then, change animations together with move along circumference animations
- Finally the insertion animations are run
As you might have guessed, this example was carefully chosen to demonstrate the use of custom diffs. This example is not suitable for
- For one, there's no straightforward way of arranging RV items along a circle
- Even if you do find a circular
LayoutManagerfor RV, you are unlikely to get it to work well with RV's
ItemAnimatorframework (this framework, although very powerful and flexible, requires you to understand way too much of the RV internal workings)
- In this specific example, there's no recycling happening, so you don't really need RV. There's a fixed limit to the number of items (7) and they all fit on screen at once.
Here's another example with more operations happening: There's 2 removals, 2 changes and one insertion (I recommend to run the app on an emulator to see the real animations, the fidelity of GIF is not good enough)
Note: There are some situations that this sample does not handle. For example if you submit a list while animations for the previous diff are already in progress, it could crash. However, this is not related to the DiffUtil wrapper that we are discussing in this post, so I'll leave it as is.
A wrapper for DiffUtil
In the previous posts, we discussed about wrapping Android's DiffUtil in a collection-style API. It is necessary to do this for our sample. This is because of the order in which we want to run our animations: We want to run all removals together. The standard
ListUpdateCallback has no way of telling us "here are all the items that were removed".
So, we can start off by doing the obvious: maintain our own list of diff operations (change, remove etc) and keep adding to this list when our
ListUpdateCallback is called. This is implemented here and a snippet is like this
RawDiffOperation is defined as
But we can do better. In our sample, we don't actually care about the exact order in which the diff operations need to be applied. All we care about is the final set of changes, removals and additions. In other words, instead of
Item at index 3 was deleted, then item at index 1 was deleted, then an item was inserted at index 0
we want to say
Items at index 1 and 3 were deleted; and an item was inserted at index 0
So, we use a combination of the RawDiffOperations and DiffUtil's position conversion methods (
convertOldPositionToNew()) to expose an API like this:
Note that this API gives us everything we need to perform our animations. It gives us the item itself in addition to positions. In case of item changes, it gives us both the old and new items. You can see the implementation of AtomicDiffResult here.
Implementation note: This implementation of AtomicDiffResult is leaves some room for optimization since it performs 2 extra iterations over the lists (once over the new list and once over the old one). In this example it is negligible.
Entry point into the API
Now that we know what we want the result to look like, let's consider how we want to calculate the diff. We want to provide the following pieces of information
- Old list
- New list
- How to compare items in the list
This leads us to the following signature
In this post, I explored one way to wrap DiffUtil's
ListUpdateCallback into a more ergonomic API. This is by no means the most generic way:
- It does not handle moves
- It ignores the change payloads
- In some cases you do really want access to the raw diff operations in the order they were performed (note that
AtomicDiffresultdoes expose the underlying
List<RawDiffOperation>for this purpose)
However, it does handle a lot of use cases where you might want to use
ListUpdateCallback. The API style I proposed here is closest to Angular's style.
The most practical applications of wrapping DiffUtil are in situations where you have lists of data but you don't want to use RecyclerView to display them. Examples include
- Situations where you have limited number of items and no recycling happening
- Lists shown in bottom sheets
- Custom UI like the one shown in the sample, or visualizing a ring buffer data structure
You might also apply this technique when you want to display custom animations and RV's ItemAnimator does not suffice for your use case.