luizgrp / SectionedRecyclerViewAdapter

An Adapter that allows a RecyclerView to be split into Sections with headers and/or footers. Each Section can have its state controlled individually.
MIT License
1.68k stars 372 forks source link

It is possible to write a guideline on how to use DiffUtil.Callback with SectionedRecyclerViewAdapter? #108

Closed yccheok closed 4 years ago

yccheok commented 6 years ago

Hi,

Now, many Android communities recommend to use DiffUtil when dealing with RecyclerView

https://medium.com/@iammert/using-diffutil-in-android-recyclerview-bdca8e4fbb00

Is it possible to write a guideline, on how to use DiffUtil with SectionedRecyclerViewAdapter? As, I still struggling to get DiffUtil to work with SectionedRecyclerViewAdapter.

Thank you.

yccheok commented 6 years ago

Can you also advice, on how to use AsyncListDiffer with SectionedRecyclerViewAdapter?

https://developer.android.com/reference/android/support/v7/recyclerview/extensions/AsyncListDiffer.html

DFreds commented 6 years ago

I would also like some advice on this.

yccheok commented 6 years ago

I would like to share my experience, on how to use SectionedRecyclerViewAdapter with DiffUtil.Callback.

One of the advantages of using DiffUtil.Callback, you can ensure your adapter will receive correct notification, and perform desired animation accordingly.

Note, I have tested together with LinearLayoutManager, GridLayoutManager. Both works fine. StaggeredGridLayoutManager do has problem in animation during move. But, it is not the problem of SectionedRecyclerViewAdapter or DiffUtil.Callback. I believe it is bug in StaggeredGridLayoutManager itself. Please refer to https://stackoverflow.com/questions/49962959/move-animation-for-staggeredgridlayoutmanager-is-broken-when-using-defaultiteman for more information.

The following is the animation effect, achieved by using SectionedRecyclerViewAdapter with DiffUtil.Callback.

Image of Yaktocat

As in screenshot, there are 2 Sections. PinnedNoteSection and NormalNoteSection.

One of the tricky part when implementing DiffUtil.Callback, is providing previous information (Previous size, previous item id and previous item content).

In ideal way, if Section can provide a clone function, then implementing DiffUtil.Callback will be an easier task. For instance.

NoteSection oldNoteSection = section.clone();

// Perform some mutate operations on Section's collection data structure.

NoteDiffUtilCallback noteDiffUtilCallback = new NoteDiffUtilCallback(
    oldSection,
    section
);

DiffUtil.calculateDiff(noteDiffUtilCallback).dispatchUpdatesTo(sectionedRecyclerViewAdapter);

But, it is rather difficult for Section to provide proper clone function, because its underlying collection data-structure, need to provide such clone function too.

End up, I need to manually keep track.

  1. Whether old/new sections are having header.
  2. What are the states of the old/new sections.
  3. What are the underlying collection data-structure of the old/new sections.
  4. ...

At the end, I came out with solution, which is very much specific to my application requirement. Note, this is not a generalized solution. But, it will give you an idea, how can you use DiffUtil together with SectionedRecyclerViewAdapter

The code is rather lengthy and verbose. I have tested with add/delete/move/update operation. They works well.


package com.yocto.noteplus.note;

import android.support.v7.util.DiffUtil;

import com.yocto.noteplus.Utils;
import com.yocto.noteplus.model.Note;
import com.yocto.noteplus.model.PlainNote;

import java.util.List;

import io.github.luizgrp.sectionedrecyclerviewadapter.Section;

public class NoteDiffUtilCallback extends DiffUtil.Callback {

    private static final int TYPE_PINNED_HEADER = 0;
    private static final int TYPE_NORMAL_HEADER = 1;
    private static final int TYPE_PINNED_EMPTY = 2;
    private static final int TYPE_NORMAL_EMPTY = 3;
    private static final int TYPE_PINNED_LOADING = 4;
    private static final int TYPE_NORMAL_LOADING = 5;
    private static final int TYPE_CONTENT = 6;

    private final List<Note> newPinnedNotes;
    private final List<Note> newNormalNotes;
    private final List<Note> oldPinnedNotes;
    private final List<Note> oldNormalNotes;

    private final boolean newPinnedNoteHasHeader;
    private final boolean newNormalNoteHasHeader;
    private final boolean oldPinnedNoteHasHeader;
    private final boolean oldNormalNoteHasHeader;

    private final Section.State newPinnedState;
    private final Section.State newNormalState;
    private final Section.State oldPinnedState;
    private final Section.State oldNormalState;

    public NoteDiffUtilCallback(
            List<Note> newPinnedNotes,
            List<Note> newNormalNotes,
            List<Note> oldPinnedNotes,
            List<Note> oldNormalNotes,
            boolean newPinnedNoteHasHeader,
            boolean newNormalNoteHasHeader,
            boolean oldPinnedNoteHasHeader,
            boolean oldNormalNoteHasHeader,
            Section.State newPinnedState,
            Section.State newNormalState,
            Section.State oldPinnedState,
            Section.State oldNormalState
    ) {
        this.newPinnedNotes = newPinnedNotes;
        this.newNormalNotes = newNormalNotes;
        this.oldPinnedNotes = oldPinnedNotes;
        this.oldNormalNotes = oldNormalNotes;

        this.newPinnedNoteHasHeader = newPinnedNoteHasHeader;
        this.newNormalNoteHasHeader = newNormalNoteHasHeader;
        this.oldPinnedNoteHasHeader = oldPinnedNoteHasHeader;
        this.oldNormalNoteHasHeader = oldNormalNoteHasHeader;

        this.newPinnedState = newPinnedState;
        this.newNormalState = newNormalState;
        this.oldPinnedState = oldPinnedState;
        this.oldNormalState = oldNormalState;

        if (this.newPinnedNoteHasHeader == newPinnedNotes.isEmpty()) {
            throw new java.lang.IllegalArgumentException();
        }

        if (newNormalNotes.isEmpty()) {
            if (this.newNormalNoteHasHeader) {
                throw new java.lang.IllegalArgumentException();
            }
        } else {
            if (this.newNormalNoteHasHeader == newPinnedNotes.isEmpty()) {
                throw new java.lang.IllegalArgumentException();
            }
        }

        if (this.oldPinnedNoteHasHeader == oldPinnedNotes.isEmpty()) {
            throw new java.lang.IllegalArgumentException();
        }

        if (oldNormalNotes.isEmpty()) {
            if (this.oldNormalNoteHasHeader) {
                throw new java.lang.IllegalArgumentException();
            }
        } else {
            if (this.oldNormalNoteHasHeader == oldPinnedNotes.isEmpty()) {
                throw new java.lang.IllegalArgumentException();
            }
        }

        if (newPinnedNoteHasHeader && newPinnedState != Section.State.LOADED) {
            throw new java.lang.IllegalArgumentException();
        }

        if (newNormalNoteHasHeader && newNormalState != Section.State.LOADED) {
            throw new java.lang.IllegalArgumentException();
        }

        if (oldPinnedNoteHasHeader && oldPinnedState != Section.State.LOADED) {
            throw new java.lang.IllegalArgumentException();
        }

        if (oldNormalNoteHasHeader && oldNormalState != Section.State.LOADED) {
            throw new java.lang.IllegalArgumentException();
        }
    }

    private int getOldPinnedNoteSectionItemTotal() {
        if (oldPinnedState == Section.State.EMPTY) {
            return 1;
        }

        if (oldPinnedState == Section.State.LOADING) {
            return 1;
        }

        int size = 0;
        int oldPinnedNotesSize = oldPinnedNotes.size();
        size = size + oldPinnedNotesSize;
        if (oldPinnedNoteHasHeader) {
            size++;
        }
        return size;
    }

    private int getNewPinnedNoteSectionItemTotal() {
        if (newPinnedState == Section.State.EMPTY) {
            return 1;
        }

        if (newPinnedState == Section.State.LOADING) {
            return 1;
        }

        int size = 0;
        int newPinnedNotesSize = newPinnedNotes.size();
        size = size + newPinnedNotesSize;
        if (newPinnedNoteHasHeader) {
            size++;
        }
        return size;
    }

    @Override
    public int getOldListSize() {
        int size = 0;

        size = size + getOldPinnedNoteSectionItemTotal();

        if (oldNormalState == Section.State.EMPTY) {
            return 1 + size;
        }

        if (oldNormalState == Section.State.LOADING) {
            return 1 + size;
        }

        int oldNormalNoteSize = oldNormalNotes.size();
        size = size + oldNormalNoteSize;

        if (oldNormalNoteHasHeader) {
            size++;
        }

        return size;
    }

    @Override
    public int getNewListSize() {
        int size = 0;

        size = size + getNewPinnedNoteSectionItemTotal();

        if (newNormalState == Section.State.EMPTY) {
            return 1 + size;
        }

        if (newNormalState == Section.State.LOADING) {
            return 1 + size;
        }

        int newNormalNoteSize = newNormalNotes.size();
        size = size + newNormalNoteSize;

        if (newNormalNoteHasHeader) {
            size++;
        }

        return size;
    }

    private int getOldType(int oldItemPosition) {
        if (oldItemPosition == 0) {
            if (oldPinnedState == Section.State.EMPTY) {
                return TYPE_PINNED_EMPTY;
            }

            if (oldPinnedState == Section.State.LOADING) {
                return TYPE_PINNED_LOADING;
            }

            if (oldPinnedNoteHasHeader) {
                return TYPE_PINNED_HEADER;
            }
        }

        final int oldPinnedNoteSectionItemTotal = getOldPinnedNoteSectionItemTotal();

        if (oldItemPosition < oldPinnedNoteSectionItemTotal) {
            return TYPE_CONTENT;
        }

        if (oldItemPosition == oldPinnedNoteSectionItemTotal) {
            if (oldNormalState == Section.State.EMPTY) {
                return TYPE_NORMAL_EMPTY;
            }

            if (oldNormalState == Section.State.LOADING) {
                return TYPE_NORMAL_LOADING;
            }

            if (oldNormalNoteHasHeader) {
                return TYPE_NORMAL_HEADER;
            }
        }

        return TYPE_CONTENT;
    }

    private int getNewType(int newItemPosition) {
        if (newItemPosition == 0) {
            if (newPinnedState == Section.State.EMPTY) {
                return TYPE_PINNED_EMPTY;
            }

            if (newPinnedState == Section.State.LOADING) {
                return TYPE_PINNED_LOADING;
            }

            if (newPinnedNoteHasHeader) {
                return TYPE_PINNED_HEADER;
            }
        }

        final int newPinnedNoteSectionItemTotal = getNewPinnedNoteSectionItemTotal();

        if (newItemPosition < newPinnedNoteSectionItemTotal) {
            return TYPE_CONTENT;
        }

        if (newItemPosition == newPinnedNoteSectionItemTotal) {
            if (newNormalState == Section.State.EMPTY) {
                return TYPE_NORMAL_EMPTY;
            }

            if (newNormalState == Section.State.LOADING) {
                return TYPE_NORMAL_LOADING;
            }

            if (newNormalNoteHasHeader) {
                return TYPE_NORMAL_HEADER;
            }
        }

        return TYPE_CONTENT;
    }

    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
        int oldType = getOldType(oldItemPosition);
        if (oldType != TYPE_CONTENT) {
            return getNewType(newItemPosition) == oldType;
        }

        int newType = getNewType(newItemPosition);
        if (newType != TYPE_CONTENT) {
            return false;
        }

        Note oldNote;
        Note newNote;

        int oldPinnedNoteSectionItemTotal = getOldPinnedNoteSectionItemTotal();
        if (oldItemPosition < oldPinnedNoteSectionItemTotal) {
            int index = oldItemPosition;
            if (oldPinnedNoteHasHeader) {
                index--;
            }
            oldNote = oldPinnedNotes.get(index);
        } else {
            int index = oldItemPosition - oldPinnedNoteSectionItemTotal;
            if (oldNormalNoteHasHeader) {
                index--;
            }
            oldNote = oldNormalNotes.get(index);
        }

        int newPinnedNoteSectionItemTotal = getNewPinnedNoteSectionItemTotal();
        if (newItemPosition < newPinnedNoteSectionItemTotal) {
            int index = newItemPosition;
            if (newPinnedNoteHasHeader) {
                index--;
            }
            newNote = newPinnedNotes.get(index);
        } else {
            int index = newItemPosition - newPinnedNoteSectionItemTotal;
            if (newNormalNoteHasHeader) {
                index--;
            }
            newNote = newNormalNotes.get(index);
        }

        final PlainNote oldPlainNote = oldNote.getPlainNote();
        final PlainNote newPlainNote = newNote.getPlainNote();
        final long oldId = oldPlainNote.getId();
        final long newId = newPlainNote.getId();

        if (Utils.isValidId(oldId) && Utils.isValidId(newId)) {
            return oldId == newId;
        }

        final String oldUuid = oldPlainNote.getUuid();
        final String newUuid = newPlainNote.getUuid();

        return Utils.equals(oldUuid, newUuid);
    }

    @Override
    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
        int oldType = getOldType(oldItemPosition);
        if (oldType != TYPE_CONTENT) {
            return getNewType(newItemPosition) == oldType;
        }

        int newType = getNewType(newItemPosition);
        if (newType != TYPE_CONTENT) {
            return false;
        }

        Note oldNote;
        Note newNote;

        int oldPinnedNoteSectionItemTotal = getOldPinnedNoteSectionItemTotal();
        if (oldItemPosition < oldPinnedNoteSectionItemTotal) {
            int index = oldItemPosition;
            if (oldPinnedNoteHasHeader) {
                index--;
            }
            oldNote = oldPinnedNotes.get(index);
        } else {
            int index = oldItemPosition - oldPinnedNoteSectionItemTotal;
            if (oldNormalNoteHasHeader) {
                index--;
            }
            oldNote = oldNormalNotes.get(index);
        }

        int newPinnedNoteSectionItemTotal = getNewPinnedNoteSectionItemTotal();
        if (newItemPosition < newPinnedNoteSectionItemTotal) {
            int index = newItemPosition;
            if (newPinnedNoteHasHeader) {
                index--;
            }
            newNote = newPinnedNotes.get(index);
        } else {
            int index = newItemPosition - newPinnedNoteSectionItemTotal;
            if (newNormalNoteHasHeader) {
                index--;
            }
            newNote = newNormalNotes.get(index);
        }

        return oldNote.getPlainNote().equals(newNote.getPlainNote());
    }
}
DFreds commented 6 years ago

Hm, I suppose this works when there are a static number of sections. In my use case, I don't know how many sections there will be because it is all dynamic and based on the data. I think I would need to generalize this somehow.

yccheok commented 6 years ago

@DFreds Yes. So far, my cases are dealing with only 2 Sections. I think you may need a more generalized case, to represent the old data of your Sections collections, and the new data of your Sections collections. My experience is that, it is pain to deal with them at first place. However, the final outcome pretty impressive.

luizgrp commented 4 years ago

@yccheok many thanks for sharing your experience and full code here.

Please have a look at https://github.com/luizgrp/SectionedRecyclerViewAdapter/pull/188.

I managed to achieve it implementing a custom ListUpdateCallback and dispatching the DiffUtil.Results to it instead of dispatching to the RecyclerView.Adapter.

luizgrp commented 4 years ago

If you haven't upgraded to 3.0.0 yet, you can implement a similar custom ListUpdateCallback but calling notifyItemRangeInsertedInSection / notifyItemRangeRemovedInSection / notifyItemMovedInSection / notifyItemRangeChangedInSection from SectionedRecyclerViewAdapter.

luizgrp commented 4 years ago

Closing this issue as it's been provided in milestone 3.1.0, but please reopen if there is still any questions / anything wrong.

yccheok commented 4 years ago

@luizgrp

Thanks for concerning on this issue. I will try to look into it over the weekend and provide feedback.