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));
    }
}
Important for Java: areContentsTheSame() relies on Recipe.equals() comparing every field. Unlike Kotlin's data class, which generates equals() and hashCode() automatically, a plain Java class gives you the default identity-based equals() unless you override it yourself. Make sure Recipe has a proper equals()/hashCode() override (covering id, title, ingredients, steps, photoUri, category, prepMinutes) — otherwise areContentsTheSame() will always report a change, and you lose the whole benefit of DiffUtil.

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
}
Explanation: calculateDiff() does the comparison work and returns a DiffResult; dispatchUpdatesTo(adapter) then fires the precise notifyItemInserted(), notifyItemRemoved(), and notifyItemChanged() calls needed — only for the rows that actually differ. The rest of the visible list is left completely untouched, so there's no flicker and far less work.

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();
        }
    }
});
Explanation: the threshold of 5 items from the end gives the network call time to finish before the user actually scrolls to the bottom, so new items appear seamlessly. The isLoadingNextPage flag guards against firing multiple overlapping page requests while one is already in flight. This is the RecyclerView side of the picture only — the actual network request that fetches the next page of recipes is exactly the kind of work covered later in Retrofit (Java) + Gson and Volley.
Next up: adding a Toolbar/AppBar and menu options above RecipeBox's recipe list → Toolbar, AppBar, Menus.