RecyclerView: DiffUtil & Pagination
The previous lesson's RecipeAdapter works, but it has a weakness once RecipeBox's recipe list actually starts changing — a new recipe added, one deleted, or a favorite toggled. This lesson fixes that with DiffUtil, and closes with a conceptual look at loading recipes a page at a time.
The Problem With notifyDataSetChanged()
The easy way to tell a RecyclerView "the data changed, please redraw" is:
recipes = recipeRepository.getAllRecipes(); // fresh list from the database
adapter.notifyDataSetChanged();
This works, but it throws away all the recycling benefits RecyclerView is built around: it re-binds every single visible row, even the 29 recipes that didn't change, just because one recipe's favorite flag flipped. On a long list this causes a visible flicker (every row appears to redraw at once) and does far more work than necessary.
DiffUtil: Computing the Minimal Update
DiffUtil compares an old list and a new list and works out the minimal set of insertions, removals, and content changes needed to turn one into the other — the same idea behind a text-file diff, applied to a list of objects. You implement a small DiffUtil.Callback describing how to compare two Recipe objects:
// RecipeDiffCallback.java
public class RecipeDiffCallback extends DiffUtil.Callback {
private final List<Recipe> oldList;
private final List<Recipe> newList;
public RecipeDiffCallback(List<Recipe> oldList, List<Recipe> newList) {
this.oldList = oldList;
this.newList = newList;
}
@Override
public int getOldListSize() { return oldList.size(); }
@Override
public int getNewListSize() { return newList.size(); }
@Override
public boolean areItemsTheSame(int oldPos, int newPos) {
// Same underlying recipe (by id), even if its fields changed
return oldList.get(oldPos).id.equals(newList.get(newPos).id);
}
@Override
public boolean areContentsTheSame(int oldPos, int newPos) {
// Same id AND every displayed field is identical -> no re-bind needed
return oldList.get(oldPos).equals(newList.get(newPos));
}
}
Calculating and Dispatching the Diff
Whenever fresh data arrives, compute the diff and hand the result to the adapter:
public void updateRecipes(List<Recipe> newRecipes) {
RecipeDiffCallback diffCallback = new RecipeDiffCallback(this.recipes, newRecipes);
DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(diffCallback);
this.recipes.clear();
this.recipes.addAll(newRecipes);
diffResult.dispatchUpdatesTo(this); // "this" is the RecipeAdapter
}
ListAdapter: Letting AndroidX Do the Diffing
androidx.recyclerview.widget.ListAdapter is a built-in base class that wraps this exact DiffUtil pattern for you, and — importantly — runs the diff calculation on a background thread automatically, so a large list never causes a jank on the main thread:
public class RecipeListAdapter extends ListAdapter<Recipe, RecipeAdapter.RecipeViewHolder> {
public RecipeListAdapter() {
super(DIFF_CALLBACK);
}
private static final DiffUtil.ItemCallback<Recipe> DIFF_CALLBACK = new DiffUtil.ItemCallback<Recipe>() {
@Override
public boolean areItemsTheSame(Recipe oldItem, Recipe newItem) {
return oldItem.id.equals(newItem.id);
}
@Override
public boolean areContentsTheSame(Recipe oldItem, Recipe newItem) {
return oldItem.equals(newItem);
}
};
// onCreateViewHolder / onBindViewHolder same as RecipeAdapter
}
With ListAdapter, updating the list is a single call — recipeListAdapter.submitList(newRecipes) — and the diffing, background threading, and dispatch all happen for you.
Pagination: Loading Recipes in Pages
If RecipeBox ever pulls recipes from a server instead of (or in addition to) a local database, loading all of them at once is wasteful. Pagination loads a page of items (say, 20 recipes) at a time, then fetches the next page only once the user scrolls near the bottom of what's already loaded.
The conceptual piece is a RecyclerView.OnScrollListener that checks how close the last visible item is to the end of the current list:
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView rv, int dx, int dy) {
LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
int lastVisible = lm.findLastVisibleItemPosition();
int totalItems = lm.getItemCount();
if (lastVisible >= totalItems - 5 && !isLoadingNextPage) {
loadNextPageOfRecipes();
}
}
});