Cursors, RecyclerViews, and ItemAnimators

RecyclerView is an Android support library class that was introduced with the launch of Android “Lollipop” (compatible all the way back to Android 2.1). It has a ton of new capabilities and built on a new architecture that promises flexibility and scalability for Android apps.

It’s ListView predecessor didn’t age too well and became cumbersome to use and extend. ListView had a plethora of built in adapters (classes for binding data to the ListView), including an adapter for binding a ListView to a Cursor. RecyclerView is great, but it’s missing a cursor adapter. Because RecyclerView is so extensible, developers can easily write one, like the one written by Jason Yu (GitHub Gist).

/*
* Copyright (C) 2014 skyfish.jy@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import android.content.Context;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.support.v7.widget.RecyclerView;

/**
* Created by skyfishjy on 10/31/14.
*/

public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {

private Context mContext;

private Cursor mCursor;

private boolean mDataValid;

private int mRowIdColumn;

private DataSetObserver mDataSetObserver;

public CursorRecyclerViewAdapter(Context context, Cursor cursor) {
mContext = context;
mCursor = cursor;
mDataValid = cursor != null;
mRowIdColumn = mDataValid ? mCursor.getColumnIndex("_id") : -1;
mDataSetObserver = new NotifyingDataSetObserver();
if (mCursor != null) {
mCursor.registerDataSetObserver(mDataSetObserver);
}
}

public Cursor getCursor() {
return mCursor;
}

@Override
public int getItemCount() {
if (mDataValid && mCursor != null) {
return mCursor.getCount();
}
return 0;
}

@Override
public long getItemId(int position) {
if (mDataValid && mCursor != null && mCursor.moveToPosition(position)) {
return mCursor.getLong(mRowIdColumn);
}
return 0;
}

@Override
public void setHasStableIds(boolean hasStableIds) {
super.setHasStableIds(true);
}

public abstract void onBindViewHolder(VH viewHolder, Cursor cursor);

@Override
public void onBindViewHolder(VH viewHolder, int position) {
if (!mDataValid) {
throw new IllegalStateException("this should only be called when the cursor is valid");
}
if (!mCursor.moveToPosition(position)) {
throw new IllegalStateException("couldn't move cursor to position " + position);
}
onBindViewHolder(viewHolder, mCursor);
}

/**
* Change the underlying cursor to a new cursor. If there is an existing cursor it will be
* closed.
*/
public void changeCursor(Cursor cursor) {
Cursor old = swapCursor(cursor);
if (old != null) {
old.close();
}
}

/**
* Swap in a new Cursor, returning the old Cursor. Unlike
* {@link #changeCursor(Cursor)}, the returned old Cursor is <em>not</em>
* closed.
*/
public Cursor swapCursor(Cursor newCursor) {
if (newCursor == mCursor) {
return null;
}
final Cursor oldCursor = mCursor;
if (oldCursor != null && mDataSetObserver != null) {
oldCursor.unregisterDataSetObserver(mDataSetObserver);
}
mCursor = newCursor;
if (mCursor != null) {
if (mDataSetObserver != null) {
mCursor.registerDataSetObserver(mDataSetObserver);
}
mRowIdColumn = newCursor.getColumnIndexOrThrow("_id");
mDataValid = true;
notifyDataSetChanged();
} else {
mRowIdColumn = -1;
mDataValid = false;
notifyDataSetChanged();
//There is no notifyDataSetInvalidated() method in RecyclerView.Adapter
}
return oldCursor;
}

private class NotifyingDataSetObserver extends DataSetObserver {
@Override
public void onChanged() {
super.onChanged();
mDataValid = true;
notifyDataSetChanged();
}

@Override
public void onInvalidated() {
super.onInvalidated();
mDataValid = false;
notifyDataSetChanged();
//There is no notifyDataSetInvalidated() method in RecyclerView.Adapter
}
}
}

Well, great, now I can use CursorLoaders, Cursors, and RecyclerViews in my Android app. There’s one problem. Backing up a bit, RecyclerViews introduced ItemAnimators. They provide an easy way to run animations when items in the data backing a RecyclerView are inserted, removed, or changed. If you’ve done any type of animations with ListView, you’d appreciate the ease of ItemAnimators. RecyclerView determines which animation to run using RecyclerView.Adapter functions called in an implementing adapter:

  • notifyItemChanged
  • notifyItemRangedChanged
  • notifyItemInserted
  • notifyItemRangeInserted
  • notifyItemRemoved
  • notifyItemRangeRemoved
  • notifyItemMoved

Each of the mentioned functions requires an index, or range of indexes indicating which items were inserted, removed, or changed.

Going back to the problem, using a CursorLoader and Cursors in an Android app. A dataset change (new record, deleted record, changed record) causes CursorLoader to initiate the allocation of a new Cursor that points to the new dataset. This is great, we can pass that new Cursor to our CursorRecyclerViewAdapter seen above. This is where our problem rears it’s head. Our default implementation only knows that the dataset has changed, but doesn’t know the particulars (which records are new, removed, or changed)…so it calls notifyDataSetChanged, which causes the entire RecyclerView to be invalidated and redrawn. It looks bad and is inefficient (see explanation at the end).

I needed a way to determine which records were inserted, which were removed, and which were changed. I had a cursor to the old dataset and a cursor to the new dataset. My only options were to diff them, building a list of records that were added, removed, and changed. This allowed me to run the appropriate notifyItem* function and gain the corresponding animation that improved the user experience. This is the RecyclerView adapter I use in my podcast app, PremoFM (GitHub Gist).

/*
* Copyright (C) 2016 Evan Halley
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.emuneee.adapter;

import android.database.Cursor;
import android.database.DataSetObserver;
import android.support.v7.widget.RecyclerView;
import android.text.TextUtils;
import android.util.SparseIntArray;

/**
* Provides ability to bind a recycler view to a Android database cursor
* Created by skyfishjy on 10/31/14.
* Modified by emuneee on 1/5/16.
*/
public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {

private static final int INSERTED = 1;
private static final int REMOVED = 2;
private static final int CHANGED = 3;
private static final int ALL = -1;

private final String mComparisonColumn;
private final DataSetObserver mDataSetObserver;
private int mRowIdColumn;
private Cursor mCursor;
private boolean mDataValid;

public CursorRecyclerViewAdapter(Cursor cursor) {
this(cursor, null);
}

public CursorRecyclerViewAdapter(Cursor cursor, String comparisonColumn) {
mCursor = cursor;
mComparisonColumn = comparisonColumn;
mDataValid = cursor != null;
mRowIdColumn = mDataValid ? mCursor.getColumnIndex("_id") : -1;
mDataSetObserver = new NotifyingDataSetObserver();

if (mCursor != null) {
mCursor.registerDataSetObserver(mDataSetObserver);
}
}

public Cursor getCursor() {
return mCursor;
}

@Override
public int getItemCount() {

if (mDataValid && mCursor != null) {
return mCursor.getCount();
}
return 0;
}

@Override
public long getItemId(int position) {

if (mDataValid && mCursor != null && mCursor.moveToPosition(position)) {
return mCursor.getLong(mRowIdColumn);
}
return 0;
}

@Override
public void setHasStableIds(boolean hasStableIds) {
super.setHasStableIds(true);
}

public abstract void onBindViewHolder(VH viewHolder, Cursor cursor, int position);

@Override
public void onBindViewHolder(VH viewHolder, int position) {

if (!mDataValid) {
throw new IllegalStateException("this should only be called when the cursor is valid");
}

if (!mCursor.moveToPosition(position)) {
throw new IllegalStateException("couldn't move cursor to position " + position);
}
onBindViewHolder(viewHolder, mCursor, position);
}

/**
* Change the underlying cursor to a new cursor. If there is an existing cursor it will be
* closed.
*/
public void changeCursor(Cursor cursor) {

if (mCursor == null) {
swapCursor(cursor, null);
} else {
SparseIntArray changes = null;

if (cursor != null && cursor != mCursor && !TextUtils.isEmpty(mComparisonColumn)) {
changes = diffCursors(mCursor, cursor);
}
Cursor old = swapCursor(cursor, changes);

if (old != null) {
old.close();
}
}
}

/**
* Processes two cursors, old/existing cursor and a new cursor, returning a list of indexes who's
* records were inserted, deleted, or changed
* @param oldCursor
* @param newCursor
* @return
*/
private SparseIntArray diffCursors(Cursor oldCursor, Cursor newCursor) {

SparseIntArray changedOrInserted = getChangeOrInsertRecords(oldCursor, newCursor);

// all records were inserted in new cursor
if (changedOrInserted.get(ALL) == INSERTED) {
return changedOrInserted;
}

SparseIntArray deleted = getDeletedRecords(oldCursor, newCursor);

if (deleted.get(ALL) == INSERTED) {
return deleted;
}
SparseIntArray changes = new SparseIntArray(changedOrInserted.size() + deleted.size());

for (int i = 0; i < changedOrInserted.size(); i++) {
changes.put(changedOrInserted.keyAt(i), changedOrInserted.valueAt(i));
}

for (int i = 0; i < deleted.size(); i++) {
changes.put(deleted.keyAt(i), deleted.valueAt(i));
}
return changes;
}

/**
* Returns a list of indexes of records that were deleted
* May also return whether or not ALL records were inserted
* @param oldCursor
* @param newCursor
* @return
*/
private SparseIntArray getDeletedRecords(Cursor oldCursor, Cursor newCursor) {
SparseIntArray changes = new SparseIntArray();
int newCursorPosition = newCursor.getPosition();

if (oldCursor.moveToFirst()) {
int cursorIndex = 0;

// loop old cursor
do {

if (newCursor.moveToFirst()) {
boolean oldRecordFound = false;

// loop new cursor
do {

// we found a record match
if (oldCursor.getInt(mRowIdColumn) == newCursor.getInt(mRowIdColumn)) {
oldRecordFound = true;
break;
}
} while(newCursor.moveToNext());

if (!oldRecordFound) {
changes.put(cursorIndex, REMOVED);
}
cursorIndex++;
}

} while (oldCursor.moveToNext());
}

// unable to move the old cursor to the first record, all records in new were adde
else {
changes.put(ALL, INSERTED);
}
newCursor.moveToPosition(newCursorPosition);
return changes;
}

/**
* Returns an array of indexes who's records were newly inserted or changed
* Will also return whether or not all the records were inserted or removed
* @param oldCursor
* @param newCursor
* @return
*/
private SparseIntArray getChangeOrInsertRecords(Cursor oldCursor, Cursor newCursor) {
SparseIntArray changes = new SparseIntArray();
int newCursorPosition = newCursor.getPosition();

if (newCursor.moveToFirst()) {
int columnIndex = oldCursor.getColumnIndex(mComparisonColumn);
int cursorIndex = 0;

// loop
do {

if (oldCursor.moveToFirst()) {
boolean newRecordFound = false;

// loop
do {

// we found a record match
if (oldCursor.getInt(mRowIdColumn) == newCursor.getInt(mRowIdColumn)) {
newRecordFound = true;

// values are different, this record has changed
if (!oldCursor.getString(columnIndex).contentEquals(newCursor.getString(columnIndex))) {
changes.put(cursorIndex, CHANGED);
}
break;
}
} while (oldCursor.moveToNext());

// new record not found in old cursor, it was newly inserted
if (!newRecordFound) {
changes.put(cursorIndex, INSERTED);
}
cursorIndex++;
}

// unable to move the new cursor, all records in new are inserted
else {
changes.put(ALL, INSERTED);
break;
}
} while (newCursor.moveToNext());
}

// unable to move new cursor to first
else {
changes.put(ALL, REMOVED);
}
newCursor.moveToPosition(newCursorPosition);
return changes;
}

/**
*
* @param newCursor
* @param changes
* @return
*/
private Cursor swapCursor(Cursor newCursor, SparseIntArray changes) {

if (newCursor == mCursor) {
return null;
}
final Cursor oldCursor = mCursor;

if (oldCursor != null && mDataSetObserver != null) {
oldCursor.unregisterDataSetObserver(mDataSetObserver);
}
mCursor = newCursor;

if (mCursor != null) {

if (mDataSetObserver != null) {
mCursor.registerDataSetObserver(mDataSetObserver);
}
mRowIdColumn = newCursor.getColumnIndexOrThrow("_id");
mDataValid = true;
} else {
mRowIdColumn = -1;
mDataValid = false;
}

if (changes != null) {
// process changes
if (changes.get(ALL) == INSERTED) {
notifyItemRangeInserted(0, newCursor.getCount());
} else if (changes.get(ALL) == REMOVED) {
notifyItemRangeRemoved(0, newCursor.getCount());
} else {

for (int i = 0; i < changes.size(); i++) {

switch (changes.valueAt(i)) {
case CHANGED:
notifyItemChanged(changes.keyAt(i));
break;
case INSERTED:
notifyItemInserted(changes.keyAt(i));
break;
case REMOVED:
notifyItemRemoved(changes.keyAt(i));
break;
}
}
}
} else if (mCursor != null) {
notifyItemRangeInserted(0, mCursor.getCount());
}
return oldCursor;
}

private class NotifyingDataSetObserver extends DataSetObserver {

@Override
public void onChanged() {
super.onChanged();
mDataValid = true;
}

@Override
public void onInvalidated() {
super.onInvalidated();
mDataValid = false;
}
}
}

In this Gist, I’ve added several things to make this diff possible, built on the great work of Jason Yu.

First, my dataset contains a column I use to record a timestamp when that record is updated. I pass the name of the column to the CursorRecyclerViewAdapter. This becomes my comparison column (mComparisonColumn). This saves me from having to compare each value, in each row in a record from one cursor, to each value in each row in a record in another cursor.

Second, I’ve added several functions that compare a new cursor and an old cursor:

  1. getChangeOrInsertRecords — iterates over the incoming cursor, then the outgoing cursor in an inner loop. Matches each record on a row ID. If a match is found, it then compares the comparison column. If the values in that column don’t match, this record was edited. If a record in the incoming cursor is not found in the outgoing cursor, then it’s a new record. If the incoming cursor is empty, then all the records were deleted. If the outgoing cursor is empty, then all the records in the incoming cursor can be assumed as inserted.
  2. getDeletedRecords — iterates over the outgoing cursor, then the incoming cursor in an inner loop. It matches each record on a row ID. If a record in the outgoing cursor is not found in the incoming cursor, then we can assume that the record has been deleted.

The result of running these two functions is a list that contains the index and that indexes difference (insert, remove, change). I use this data in swapCursor to run the corresponding notifyItem* function and I gain animations when items change.

What does all of this get me? Look at the user interface differences below.

notifyDataSetChanged
notifyItem*

Downsides? The diff is a O(n^2) operation. If you have a ton of records, this operation could take a while. A possible optimization is to only diff the records that are visible in the RecyclerView and not the entire dataset behind each cursor.

Let me know if you have any questions by hitting me up on Twitter. Also download my podcast app, PremoFM, in the Google Play Store now by going to premo.fm/app.

Why is using notifyDataSetChanged with RecyclerView bad and inefficient? Calling notifyItemChanged causes RecyclerView to make a requestLayout for the RecyclerView. So even if the text was updated in the first item in my RecyclerView, all the items will be redrawn. The function notifyDataSetChanged triggers a call to RecyclerView.AdapterDataObservable.notifyChange. In this function, each RecyclerViewDataObserver.onChanged function is called, which eventually triggers a requestLayout call.

Evidence is freely available for in the Android source code.


Originally published at emuneee.com on January 10, 2016.