From Punched Cards to Prompts
AndroidIntroduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Writing code to display lists of data is an everyday task as a software engineer, regardless of platform. Operations to modify lists can vary in complexity, and force developers down paths that sacrifice user experience or performance. This article will show you how to leverage DiffUtil
and ListAdapter
to avoid those pitfalls, and provide a more efficient user experience, while reducing boilerplate code.
On Android, RecyclerView
is the go-to API for efficiently displaying lists of data. Our data is often changing, so out of the box, RecyclerView.Adapter
comes with many low-level granular APIs to update our user interface as the backing data changes, such as:
notifyItemChanged(position: Int) notifyItemRemoved(position: Int) notifyItemRangeChanged(positionStart: Int, itemCount: Int)
In order to explore these APIs, I’ve created a small sample app listing the known planets of our solar system shown in descending order by their distance from the sun. It seems Pluto has snuck its’ way into our list, so let’s remove it with notifyItemRemoved()
:
adapter.items.remove(pluto) adapter.notifyItemRemoved(plutoIndex)
Assuming you have access to the position of the item to be removed, we can make the appropriate method calls to remove the item from the list and provide smooth animations for our user to visualize the change.
These types of interactions are manageable for small changes but don’t scale well. For a more complex example, let’s have some of our planets change position. (Please ignore the real-world implications of this 🙈) Jupiter moves in front of Saturn, Mars and Earth swap, and Venus is flung out beyond Neptune. Resulting in an order like this:
[ Venus, Neptune, Uranus, Jupiter, Saturn, Earth, Mars, Mercury ]
How would we go about showing these changes to the user with the RecyclerView.Adapter
APIs we have available? notifyItemMoved(fromPosition: Int, toPosition: Int)
comes to mind… But that would involve keeping track of each items’ individual movement. These low-level APIs can be tedious to implement for complex use-cases, and are not always kept in mind when architecting your application. This tends to lead developers to use the “catch-all” notifyDataSetChanged()
method when updating their adapters. From the official documentation, we learn this is not ideal for the best user experience:
This event does not specify what about the data set has changed, forcing any observers to assume that all existing items and structure may no longer be valid. LayoutManagers will be forced to fully rebind and relayout all visible views.
And even goes on to say:
If you are writing an adapter it will always be more efficient to use the more specific change events if you can. Rely on
notifyDataSetChanged()
as a last resort.
Here is an example of notifyDataSetChanged()
displaying our new list of rearranged planets:
adapter.items = rearrangedPlanets adapter.notifyDataSetChanged()
Our data is displayed accurately, and the steps to make that change were much easier, but it resulted in a jarring user experience along with inefficient use of RecyclerView
APIs.
Fortunately, there have been many additions that augment the ease-of-use of RecyclerView
and RecyclerView.Adapter
.
One in particular that has gained some much-needed attention since release is DiffUtil. From the official documentation:
DiffUtil is a utility class that calculates the difference between two lists and outputs a list of update operations that converts the first list into the second one.
Seems simple enough. I have my oldList
, and my newList
. What are the minimum amount of steps needed to migrate oldList
=> newList
and display those steps to my user? –A perfect use-case to display the milky-way’s newly arranged solar system! 🪐
Create a new class, extending DiffUtil.Callback
. We supply this class with our new and old list and it calculates a DiffResult.
areItemsTheSame()
determines if oldItem
and newItem
are the same item (usually represented by comparing a unique id). areContentsTheSame()
determines if the values of newItem
and oldItem
have changed. A result is calculated determining the item’s new position (if it was moved), and it’s new contents (if it has changed).
class PlanetDiffUtilCallback(private val oldList: List<Planet>, private val newList: List<Planet>): DiffUtil.Callback() { override fun getOldListSize(): Int { return oldList.size } override fun getNewListSize(): Int { return newList.size } override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition].id == newList[newItemPosition].id } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition] == newList[newItemPosition] } }
Add a method to our PlanetAdapter
(a subclass of RecyclerView.Adapter
):
fun update(newList: List<Planet>){ val callback = PlanetDiffUtilCallback(items, newList) val result = DiffUtil.calculateDiff(callback) currentItems = newList // don't forget to update the backing list result.dispatchUpdatesTo(this) }
From our Activity
or Fragment
:
planetBasicAdapter.update(rearrangedPlanets)
How wonderful! Each modified planet in our RecyclerView
is updated with its new location, and the user is notified of the granular changes in the list with smooth animations 😎.
As perfect as DiffUtil
seems, it does come with some limitations. From the documentation :
The actual runtime of the algorithm significantly depends on the number of changes in the list and the cost of your comparison methods… Due to implementation constraints, the max size of the list can be 2^26.
So DiffUtil
could run into issues on very large lists with heavy modifications. It’s also worth noting, that in our current implementation, the “diffing” calculation is being done on the main thread. This could lead to unwanted UI “jank”. Fortunately, DiffUtil
offers a way to move its calculation to a background thread.
To send DiffUtil
calculations to the background, Android offers us AsyncListDiffer. For simplicity, the ListAdapter wrapper class can be used instead of AsyncListDiffer
directly. Below is a modified version of our above example, using ListAdapter
:
class PlanetListAdapter: ListAdapter<Planet, PlanetViewHolder>(PlanetDiff) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlanetViewHolder { return PlanetViewHolder(ItemPlanetBinding.inflate(LayoutInflater.from(parent.context), parent, false)) } override fun onBindViewHolder(holder: PlanetViewHolder, position: Int) { holder.bind(getItem(position)) } }
PlanetDiff
object:
object PlanetDiff: DiffUtil.ItemCallback<Planet>(){ override fun areContentsTheSame(oldItem: Planet, newItem: Planet): Boolean { return oldItem == newItem } override fun areItemsTheSame(oldItem: Planet, newItem: Planet): Boolean { return oldItem.id == newItem.id } }
ListAdapter
‘s implementation requires us to override onCreateViewHolder()
and onBindViewHolder()
, 1 less method than a normal RecyclerView.Adapter
. For most use-cases, ListAdapter
should be our go-to RecyclerView.Adapter
class because of its reduced boilerplate and added functionality.
From our Activity
or Fragment
:
planetAdapter.submitList(rearrangedPlanets)
That’s it! Now the application is performing its diff calculation on a background thread, saving our user from any potential lag. We also wrote less code and reduced boilerplate using ListAdapter
.
This simple example shows how ListAdapter
and DiffUtil
can be used to avoid hardship without sacrificing performance while displaying and modifying lists of data. APIs like this have been a great addition to the Android platform, so much so that other platforms have followed suit, implementing their own versions of DiffUtil
, all based on the Myers difference algorithm.
Introduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Jetpack Compose is a declarative framework for building native Android UI recommended by Google. To simplify and accelerate UI development, the framework turns the...
Big Nerd Ranch is chock-full of incredibly talented people. Today, we’re starting a series, Tell Our BNR Story, where folks within our industry share...