Search code examples
androidandroid-recyclerviewandroid-cursorloaderandroid-loadermanager

Problem using LoaderManager.LoaderCallbacks<Cursor> with custom RecyclerView.Adapter


I have tried loading the list using the ListView along with LoaderManager.LoaderCallbacks and custom CursorAdapter and it works fine. But I am trying to accomplish the same using RecyclerView along with custom RecyclerView.Adapter but I am getting this issue:

I am getting the list displayed for the first time but when I rotate the device the list disappears.

enter image description here

enter image description here

Here is the code, please have a look.

CatalogActivity

public class CatalogActivity extends AppCompatActivity implements ItemAdapter.OnItemClickListener,
        LoaderManager.LoaderCallbacks<Cursor> {
    private static final int ITEMS_LOADER_ID = 1;
    public static final String EXTRA_ITEM_NAME = "extra_item_name";
    public static final String EXTRA_ITEM_STOCK = "extra_item_stock";

    @BindView(R.id.list_items)
    RecyclerView mListItems;

    private ItemAdapter mItemAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_catalog);
        ButterKnife.bind(this);

        setupListItems();

        getLoaderManager().initLoader(ITEMS_LOADER_ID, null, this);
    }

    private void setupListItems() {
        mListItems.setHasFixedSize(true);
        LayoutManager layoutManager = new LinearLayoutManager(this);
        mListItems.setLayoutManager(layoutManager);
        mListItems.setItemAnimator(new DefaultItemAnimator());
        mListItems.addItemDecoration(new DividerItemDecoration(this, LinearLayout.VERTICAL));
        mItemAdapter = new ItemAdapter(getApplicationContext(), this);
        mListItems.setAdapter(mItemAdapter);
    }

    @Override
    public void OnClickItem(int position) {
        Intent intent = new Intent(this, EditorActivity.class);

        Item item = mItemAdapter.getItems().get(position);
        intent.putExtra(EXTRA_ITEM_NAME, item.getName());
        intent.putExtra(EXTRA_ITEM_STOCK, item.getStock());

        startActivity(intent);
    }

    private ArrayList<Item> getItems(Cursor cursor) {
        ArrayList<Item> items = new ArrayList<>();

        if (cursor != null) {
            while (cursor.moveToNext()) {
                int columnIndexId = cursor.getColumnIndex(ItemEntry._ID);
                int columnIndexName = cursor.getColumnIndex(ItemEntry.COLUMN_NAME);
                int columnIndexStock = cursor.getColumnIndex(ItemEntry.COLUMN_STOCK);

                int id = cursor.getInt(columnIndexId);
                String name = cursor.getString(columnIndexName);
                int stock = Integer.parseInt(cursor.getString(columnIndexStock));

                items.add(new Item(id, name, stock));
            }
        }

        return items;
    }

    @Override
    public Loader<Cursor> onCreateLoader(int loaderId, Bundle bundle) {
        switch (loaderId) {
            case ITEMS_LOADER_ID: {
                String[] projection = {
                        ItemEntry._ID,
                        ItemEntry.COLUMN_NAME,
                        ItemEntry.COLUMN_STOCK
                };

                return new CursorLoader(
                        this,
                        ItemEntry.CONTENT_URI,
                        projection,
                        null,
                        null,
                        null
                );
            }
            default:
                return null;
        }
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
        mItemAdapter.setItems(getItems(cursor));
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {

    }
}

ItemAdapter

public class ItemAdapter extends RecyclerView.Adapter<ItemAdapter.ItemViewHolder> {
    private ArrayList<Item> mItems;
    private OnItemClickListener mOnItemClickListener;
    private Context mContext;

    public ItemAdapter(Context context, OnItemClickListener onItemClickListener) {
        mOnItemClickListener = onItemClickListener;
        mContext = context;
    }

    public void setItems(ArrayList<Item> items) {
        if (items != null) {
            mItems = items;
            notifyDataSetChanged();
        }
    }

    public ArrayList<Item> getItems() {
        return mItems;
    }

    public interface OnItemClickListener {
        void OnClickItem(int position);
    }

    public class ItemViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
        @BindView(R.id.tv_item)
        TextView tv_item;

        @BindView(R.id.tv_stock)
        TextView tv_stock;

        public ItemViewHolder(@NonNull View itemView) {
            super(itemView);
            ButterKnife.bind(this, itemView);
            itemView.setOnClickListener(this);
        }

        @Override
        public void onClick(View view) {
            int position = getAdapterPosition();
            mOnItemClickListener.OnClickItem(position);
        }
    }

    @NonNull
    @Override
    public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int i) {
        View itemView = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_inventory, parent, false);
        return new ItemViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(@NonNull ItemViewHolder itemViewHolder, int position) {
        final Item item = mItems.get(position);

        itemViewHolder.tv_item.setText(item.getName());
        itemViewHolder.tv_stock.setText(mContext.getString(R.string.display_stock, item.getStock()));
    }

    @Override
    public int getItemCount() {
        if (mItems == null) {
            return 0;
        } else {
            return mItems.size();
        }
    }
}

I am not able to figure out the extact issue. Please help.


Solution

  • Briefly, the issue here is that, after rotation, you're being handed the same Cursor that you had previously looped over before the rotation, but you're not accounting for its current position.

    A Cursor tracks and maintains its own position within its set of records, as I'm sure you've gathered from the various move*() methods it contains. When first created, a Cursor's position will be set to right before the first record; i.e., its position will be set to -1.

    When you first start your app, the LoaderManager calls onCreateLoader(), where your CursorLoader is instantiated, and then causes it to load and deliver its Cursor, with the Cursor's position at -1. At this point, the while (cursor.moveToNext()) loop works just as expected, since the first moveToNext() call will move it to the first position (index 0), and then to each available position after that, until the end.

    Upon rotation, however, the LoaderManager determines that it already has the requested Loader (determined by ID), which itself sees that it already has the appropriate Cursor loaded, so it just immediately delivers that same Cursor object again. (This is a major feature of the Loader framework – it won't reload resources it already has, regardless of configuration changes.) This is the crux of the issue. That Cursor has been left at the last position to which it was moved before the rotation; i.e., at its end. Consequently, the Cursor cannot moveToNext(), so that while loop just never runs at all, after the initial onLoadFinished(), before rotation.

    The simplest fix, with the given setup, would be to manually reposition the Cursor yourself. For example, in getItems(), change the if to moveToFirst() if the Cursor is not null, and change the while to a do-while, so we don't inadvertently skip over the first record. That is:

    if (cursor != null && cursor.moveToFirst()) { 
        do {
            int columnIndexId = cursor.getColumnIndex(ItemEntry._ID);
            ...
        } while (cursor.moveToNext());
    }
    

    With this, when that same Cursor object is re-delivered, its position is kinda "reset" to position 0. Since that position is directly on the first record, rather than right before it (remember, initially -1), we change to a do-while, so that the first moveToNext() call doesn't skip the first record in the Cursor.


    Notes:

    • I would mention that it is possible to implement a RecyclerView.Adapter to take a Cursor directly, similar to the old CursorAdapter. In this, the Cursor would necessarily be moved in the onBindViewHolder() method to the correct position for each item, and the separate ArrayList would be unnecessary. It'd take a little effort, but translating CursorAdapter to a RecyclerView.Adapter isn't terribly difficult. Alternatively, there are certainly solutions already available. (For example, possibly, this one, though I cannot vouch for it, atm, I often see a trusted fellow user recommend it often.)

    • I would also mention that the native Loader framework has been deprecated, in favor of the newer ViewModel/LiveData architecture framework in support libraries. However, it appears that the newest androidx library has its own internal, improved Loader framework which is a simple wrapper around said ViewModel/LiveData setup. This seems to be a nice, easy way to utilize the known Loader constructs while still benefiting from the recent architecture refinements.