Android: how to copy a RecyclerView List for use by ListAdapter?

221 Views Asked by At

How do I copy the existing ListAdapter list so that it can be used to move items and then update the UI by comparing the new, copied list to the original list?

Perhaps the Android devs only thought of CRUD operations when they designed the source code for ListAdapter to be used for RecyclerView lists. If so, they neglected to account for other vital recyclerView operations like filtering, sorting and moving list items. The only thing that is changing for those operations is the sort order (sorting and moving items) or removing some items from the original list (for filtering) but that would now require a CustomAdapter? Also, the requirement to copy a list into memory, for a very large RecyclerView list so the ListAdapter "sees" a new list, seems to defeat the original purpose of the ListAdapter to provide efficiency gains on a background thread.

I embraced converting my existing project to ListAdapter with the thought that updates on a background thread would be very valuable for stability and UI efficiency. I have CRUD ops working fine but now the daunting task is to figure out how to do those other filtering, sorting and moving list items.

I tried to move items in the RecyclerView list but drag-and-drop does not work. Below are the different methods I've tried so far in various combinations. In the meantime, I read every other ListAdapter question on stackoverflow...and unfortunately I know zero Kotlin so I am looking for a Java solution. What am I missing here?

MainActivity approach #1:

ItemTouchHelper.SimpleCallback itemTouchHelperCallback1 = new ItemTouchHelper.SimpleCallback(
   (ItemTouchHelper.UP | ItemTouchHelper.DOWN),0) {

    int dragFrom = -1;
    int dragTo = -1;    

    @Override
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {

        int fromPos = viewHolder.getBindingAdapterPosition();
        int toPos = target.getBindingAdapterPosition();

        if (dragFrom == -1) {
            dragFrom = fromPos;
        }
        dragTo = toPos;

        cardsAdapter.moveItem(fromPos, toPos);                        
        return true;
    }  
};        
new ItemTouchHelper(itemTouchHelperCallback1).attachToRecyclerView(recyclerView); 

MainActivity approach #2, for the onMove() method:

List newList = adapter.getCurrentList();
Quickcard quickcardItem = newList.get(fromPos);
newList.remove(fromPos);
newList.add(toPos, quickcardItem);
cardsAdapter.moveItem(fromPos, toPos);
cardsAdapter.notifyItemMoved(fromPos, toPos);
cardsAdapter.submitList(newList);
return true;

ListAdapter approach #1:

public class CardsAdapter extends ListAdapter<Quickcard, CardsAdapter.ItemHolder> {

    private final LayoutInflater layoutInflater;

    protected CardsAdapter(@NonNull LayoutInflater layoutInflater, DiffUtil.ItemCallback<Quickcard> diffCallback) {
        super(diffCallback);
        this.layoutInflater = layoutInflater;
    }

    ... // ViewHolder code 
     
    public void moveItem(int fromOldPos, int toNewPos) {

        // Copy the ListAdapter's existing list
        ArrayList<Quickcard> currList = new ArrayList<>(getCurrentList()); 
    
        if (fromOldPos == toNewPos) {
            return;
        }
        Collections.swap(currList, toNewPos, fromOldPos);
        notifyItemMoved(fromOldPos, toNewPos);
        submitList(currList);
    }     
}

ListAdapter approach #2, for the moveItem() method:

List<Quickcard> currList = getCurrentList();
List<Quickcard> copiedList = new ArrayList<>(currList);

if (fromOldPos == toNewPos) {
    return;
}
if (fromOldPos < toNewPos) { // the q. is being moved down the q.list
    for (int i = fromOldPos; i < toNewPos; i++) {
    Collections.swap(copiedList, i,i + 1);
    }
} else { // the q. is being moved up the q.list
    for (int i = fromOldPos; i > toNewPos; i--) {
        Collections.swap(copiedList, i,i - 1);
    }
}
notifyItemMoved(fromOldPos, toNewPos);
submitList(copiedList);

// after adding a copy constructor to the Model, I also tried this

// (see @Matt's answer stackoverflow #48575477),

ListAdapter approach #3, for the moveItem() method:

List<CustomObject> copyList = new ArrayList<>();
for(CustomObject obj : parentList) {
    copyList.add(new CustomObject(obj));
}

I also tried this approach, in the ViewModel:

// mTodos is the MutableLiveData<List> in the ViewModel
List<Todo> todos = mTodos.getValue();
ArrayList<Todo> clonedTodos = new ArrayList<Todo>(todos.size());
    for(int i = 0; i < todos.size(); i++){
        clonedTodos.add(new Todo(todos.get(i)));
    }        
    mTodos.postValue(clonedTodos);
1

There are 1 best solutions below

15
Tyler V On

Unfortunaly the ListAdapter by itself has trouble with drag/swipe operations, but most of the nice features of the submitList method can be duplicated using DiffUtil methods in a regular adapter. You can handle filtering by filtering the list elsewhere (e.g. in the ViewModel) and the posting it with submitList. By doing this, you have access to the displayed list and can modify it easily for drag/swipe events, while still getting some of the benefits of the ListAdapter when posting new lists or re-ordered lists.

Here's an example of how to do that and get a working drag/swipe behavior with "ListAdapter-like" behavior for submitList that uses DiffUtil tools. You do lose the background thread for the diff callback. That could be added back in to submitList with a bit more difficulty (or you could use Kotlin and coroutines to add it more easily).

The adapter

Since the adapter has access to the displayed list, it can move items around in it, or remove them, when items are moved/swiped away. When a new list is posted with submitList, it uses the DiffUtil methods to compare the new list to the currently displayed list, updates the displayed list, and dispatches changes to the recycler view to be animated.

If you need to propagate order changes/swiping back to a ViewModel or other layer, pass an interface to the adapter and call it in onItemMove and onItemDismiss as appropriate to let the backend know that item position changed or items were removed. Such changes should not cause the backend to submit a new list to submitList since the displayed list is already updated.

public class RecyclerViewAdapter 
    extends RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder> 
    implements ItemTouchHelperAdapter 
{
    // Define a diff callback, similar to what is used in ListAdapter,
    // but with a couple other methods.
    private static class DiffCallback extends DiffUtil.Callback {
        DiffCallback(List<RecyclerViewData> old) {
            oldList = old;
        }

        @Override
        public int getOldListSize() {
            return oldList.size();
        }

        @Override
        public int getNewListSize() {
            return newList.size();
        }

        // Each entry in the example has a UUID, this compares only
        // the UUID, not the name or birthday, to determine if an 
        // item moved
        @Override
        public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
            return oldList.get(oldItemPosition).isSameEntry(newList.get(newItemPosition));
        }

        // Compare the displayed data (name and birthday) to decide
        // if it should be redrawn/rebound
        @Override
        public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
            return oldList.get(oldItemPosition).isSameAs(newList.get(newItemPosition));
        }

        List<RecyclerViewData> newList = new ArrayList<>();
        private final List<RecyclerViewData> oldList;
    }

    // Basic ViewHolder
    public static class ViewHolder extends RecyclerView.ViewHolder {

        TextView name;
        TextView birthday;

        public ViewHolder(@NonNull View itemView) {
            super(itemView);
            name = itemView.findViewById(R.id.name);
            birthday = itemView.findViewById(R.id.birthday);
        }
    }

    private final LayoutInflater inflater;
    private final ArrayList<RecyclerViewData> displayedList = new ArrayList<>();
    private final DiffCallback diffCallback = new DiffCallback(displayedList);

    public RecyclerViewAdapter (Context ctx) {
        this.inflater = LayoutInflater.from(ctx);
    }

    // Implement methods from ItemTouchHelperAdapter interface, to be called
    // from the ItemTouchHelper.SimpleCallback
    @Override
    public boolean onItemMove(RecyclerView.ViewHolder from, RecyclerView.ViewHolder to) {
        int fromP = from.getAdapterPosition();
        int toP = to.getAdapterPosition();

        if( fromP == toP ) {
            return false;
        }
        else {
            Collections.swap(displayedList, fromP, toP);
            notifyItemMoved(fromP, toP);
            return true;
        }
    }

    @Override
    public void onItemDismiss(int position, int direction) {
        displayedList.remove(position);
        notifyItemRemoved(position);
    }

    // Mimic the ListAdapter "submitList" method, but without the benefit
    // of a background thread. Could be added if needed, but adds complexity.
    public void submitList(List<RecyclerViewData> newList) {
        diffCallback.newList = newList;
        DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(diffCallback);
        displayedList.clear();
        displayedList.addAll(newList);
        diffResult.dispatchUpdatesTo(this);
    }

    // Basic RecyclerView stuff
    @NonNull
    @Override
    public RecyclerViewAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = inflater.inflate(R.layout.profile_row,parent,false);
        return new RecyclerViewAdapter.ViewHolder(view);
    }

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public void onBindViewHolder(@NonNull RecyclerViewAdapter.ViewHolder holder, int position) {
        RecyclerViewData data = displayedList.get(position);
        holder.name.setText(data.getName());
        holder.birthday.setText(data.getBirthday());
    }

    @Override
    public int getItemCount() {
        return displayedList.size();
    }
}

Activity

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    RecyclerView recyclerView = findViewById(R.id.profile_list);

    RecyclerViewAdapter adapter = new RecyclerViewAdapter(this);
    recyclerView.setAdapter(adapter);
    recyclerView.setLayoutManager(new LinearLayoutManager(this));

    ItemTouchHelper ith = new ItemTouchHelper(new SimpleItemTouchHelper(adapter));
    ith.attachToRecyclerView(recyclerView);
    
    // this should come from a ViewModel, just here for simplicity
    ArrayList<RecyclerViewData> data = new ArrayList<>();
    data.add(new RecyclerViewData("Bob", "Jan 12"));
    data.add(new RecyclerViewData("Frank", "Feb 12"));
    data.add(new RecyclerViewData("Herbert", "Mar 22"));
    data.add(new RecyclerViewData("Joe", "Apr 15"));
    data.add(new RecyclerViewData("Bob", "Jan 12"));
    
    // when the ViewModel gets data, or filters the list to show it in a new order,
    // it calls "submitList" and the adapter figures out what to show/move
    adapter.submitList(data);
}

The item touch interface

(not strictly necessary, but nice to decouple the adapter from the item touch helper)

public interface ItemTouchHelperAdapter {
    boolean onItemMove(RecyclerView.ViewHolder from, RecyclerView.ViewHolder to);
    void onItemDismiss(int position, int direction);
}

The item touch callback

Simple callback that just calls onItemMove and onItemDismiss on the adapter.

public class SimpleItemTouchHelper extends ItemTouchHelper.SimpleCallback {

    private final ItemTouchHelperAdapter adapter;
    SimpleItemTouchHelper(ItemTouchHelperAdapter adapter) {
        super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.RIGHT);
        this.adapter = adapter;
    }
    
    @Override
    public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
        return adapter.onItemMove(viewHolder, target);
    }

    @Override
    public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
        adapter.onItemDismiss(viewHolder.getAdapterPosition(), direction);
    }
}

The data class

public class RecyclerViewData {
    private final String uuid;
    private final String name;
    private final String birthday;

    public RecyclerViewData(String name, String birthday) {
        this.uuid = UUID.randomUUID().toString();
        this.name = name;
        this.birthday = birthday;
    }

    public boolean isSameEntry(RecyclerViewData other) {
        return this.uuid.equals(other.uuid);
    }

    public boolean isSameAs(RecyclerViewData other) {
        return this.name.equals(other.name) && 
               this.birthday.equals(other.birthday);
    }

    public String getName() {
        return name;
    }

    public String getBirthday () {
        return birthday;
    }
}