QMUI hasn’t been updated much in the last week or two. Because I have been busy with wechat reading book interface. Indulge in bug writing and bug fixing.

The book-telling interface and functions of wechat reading are quite complicated. This time, I took out the functions of folding, expanding and loading separately and wrote a Demo to share with you.

Let’s talk about the features of this Demo:

  1. Section expands/folds to drive the drawing effect
  2. If you expand and scroll up, the header of the current section will be attached at the top
  3. Each section has up loading and down loading

The data structure

First of all, we need to define the data structure. This is relatively easy. Let’s start with the basic version of the data structure:

data class Section<H: Cloneable<H>, T: Cloneable<T>>(  
        val header: H, 
        val list: MutableList<T>,
        var hasBefore: Boolean,
        var hasAfter: Boolean,
        var isFold: Boolean,
        var isLocked: Boolean): Cloneable<Section<H, T>>{

    var isLoadBeforeError: Boolean = false
    var isLoadAfterError: Boolean = false

    fun count() = list.size

    override fun clone(): Section<H, T> {
        val newList = ArrayList<T>()
        list.forEach{ it: T ->
            newList.add(it.clone())
        }
        val section =  Section(header.clone(), newList, hasBefore, hasAfter, isFold, isLocked)
        section.isLoadBeforeError = isLoadBeforeError
        section.isLoadAfterError = isLoadAfterError
        return section
    }
}
Copy the code

Each section consists of a header and a list. IsFold indicates the state of the fold. HasBefore and hasAfter indicate whether loading up and loading down is required. There’s also isLocked, which we’ll talk about later, which is a very important state.

Currently the data structure is simple, but when we pass a List<Section> data structure to the Adapter, the problem arises: our current data is a two-dimensional data structure, but the Adapter prefers a one-dimensional data structure. We need to easily implement the following two finds:

  1. Given the position of the adpater, it is convenient to find information about the section and the corresponding information about the item under the section
  2. Given the information about an item of setcion, it is convenient to find its position in adapter

I’ll just give you my solution. Use two Sparsearrays for indexing:

  • A SparseArray (sectionIndex) is the KV store of adapterPosition: position in List
    ;
  • Another SparseArray (itemIndex) is the KV store of adapterPosition: Position in section.list

When we want to find the value of an item in the section from the adapterPosition, we need to do two things:

  1. Retrieve the section by finding the location of the section in sectionIndex
  2. Find the location of the item in section.list from itemIndex, and retrieve the item information based on the section retrieved in step 1

If you have an item in the section, and you’re going to get the Adaptive Position, you’re going to iterate over it, you’re not going to get rid of that.

How to determine the corresponding relationship between header/loadMore and adapterPosition? In the demo, if the value of itemIndex read is -1, it means header; if it is -2, it means loading; if it is -3, it means loading. In wechat reading, there are more types such as headerView, which can be easily extended by negative numbers.

Let’s look at the tool method for index generation:

fun <H, T> generateIndex(list: List<Section<H, T>>, sectionIndex: SparseArray<Int>, itemIndex: SparseArray<Int>){ sectionIndex.clear() itemIndex.clear() var i = 0 list.forEachIndexed { index, it -> if (! it.isLocked) { sectionIndex.append(i, index) itemIndex.append(i, ITEM_INDEX_SECTION_HEADER) i++ if (! it.isFold && it.count() > 0) { if (it.hasBefore) { sectionIndex.append(i, index) itemIndex.append(i, ITEM_INDEX_LOAD_BEFORE) i++ } for (j in 0 until it.count()) { sectionIndex.append(i, index) itemIndex.append(i, j) i++ } if (it.hasAfter) { sectionIndex.append(i, index) itemIndex.append(i, ITEM_INDEX_LOAD_AFTER) i++ } } } } }Copy the code

Each time the data is updated, I update two indexes. Then the Adapter only needs to implement each method according to the two indexes:

// getItemCount override fun getItemCount(): Int = mItemIndex.size() // getItemViewType override fun getItemViewType(position: Int): Int { val itemIndex = mItemIndex[position] return when (itemIndex) { DiffCallback.ITEM_INDEX_SECTION_HEADER -> ITEM_TYPE_SECTION_HEADER DiffCallback.ITEM_INDEX_LOAD_AFTER -> ITEM_TYPE_SECTION_LOADING DiffCallback.ITEM_INDEX_LOAD_BEFORE -> ITEM_TYPE_SECTION_LOADING else -> ITEM_TYPE_SECTION_ITEM } } // onCreateViewHolder override fun onCreateViewHolder(parent: ViewGroup? , viewType: Int): FoldViewHolder { val view = when (viewType) { ITEM_TYPE_SECTION_HEADER -> SectionHeaderView(context) ITEM_TYPE_SECTION_LOADING -> SectionLoadingView(context) else -> SectionItemView(context) } val viewHolder = FoldViewHolder(view) view.setOnClickListener { val position = viewHolder.adapterPosition if (position ! = RecyclerView.NO_POSITION) { onItemClick(viewHolder, position) } } return viewHolder } // onBindViewHolder override fun onBindViewHolder(holder: FoldViewHolder, position: Int) { val sectionIndex = mSectionIndex[position] val itemIndex = mItemIndex[position] val section = mData[sectionIndex]  when (itemIndex) { DiffCallback.ITEM_INDEX_SECTION_HEADER -> (holder.itemView as SectionHeaderView).render(section) DiffCallback.ITEM_INDEX_LOAD_BEFORE -> (holder.itemView as SectionLoadingView).render(true, section.isLoadBeforeError) DiffCallback.ITEM_INDEX_LOAD_AFTER -> (holder.itemView as SectionLoadingView).render(false, section.isLoadAfterError) else -> { val view = holder.itemView as SectionItemView val item = section.list[itemIndex] view.render(item) } } }Copy the code

Data expansion and collapse

The connection between our 2d data and the Adapter has been established, so how do we notify the Adapter when the data changes? NotifyDataSetChanged loses the RecyclerView animation, and notifyItemXXX is difficult to maintain. However, Android officially provides DiffUtil, which works with two indexes to make writing code very comfortable:

class DiffCallback<H: Cloneable<H>, T: Cloneable<T>>(private val oldList: List<Section<H, T>>, private val newList: List<Section<H, T>>) : DiffUtil.Callback() { private val mOldSectionIndex: SparseArray<Int> = SparseArray() private val mOldItemIndex: SparseArray<Int> = SparseArray() private val mNewSectionIndex: SparseArray<Int> = SparseArray() private val mNewItemIndex: SparseArray<Int> = SparseArray() init { generateIndex(oldList, mOldSectionIndex, mOldItemIndex) generateIndex(newList, mNewSectionIndex, mNewItemIndex) } override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldSectionIndex = mOldSectionIndex[oldItemPosition] val oldItemIndex = mOldItemIndex[oldItemPosition] val oldModel = oldList[oldSectionIndex] val newSectionIndex = mNewSectionIndex[newItemPosition] val newItemIndex = mNewItemIndex[newItemPosition] val newModel = newList[newSectionIndex] if (oldModel.header ! = newModel.header) { return false } if (oldItemIndex < 0 && oldItemIndex == newItemIndex) { return true } if (oldItemIndex < 0 || newItemIndex < 0) { return false } return oldModel.list[oldItemIndex] == newModel.list[newItemIndex] } override fun getOldListSize() = mOldSectionIndex.size() override fun getNewListSize() = mNewSectionIndex.size() override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldSectionIndex = mOldSectionIndex[oldItemPosition] val oldItemIndex = mOldItemIndex[oldItemPosition] val oldModel = oldList[oldSectionIndex] val newSectionIndex = mNewSectionIndex[newItemPosition] val newModel = newList[newSectionIndex] if (oldItemIndex == ITEM_INDEX_SECTION_HEADER) { return oldModel.isFold == newModel.isFold } if (oldItemIndex = = ITEM_INDEX_LOAD_BEFORE | | oldItemIndex = = ITEM_INDEX_LOAD_AFTER) {/ / the load more forced returns false, . So we can through FolderAdapter onViewAttachedToWindow trigger the load more return return true or false}}}Copy the code

Here you can also see the function of the two indexes in data comparison. The logic should be very clear. So whenever a data change or a collapse is expanded, I use DiffUtil to compare the change:

// Data update fun setData(list: MutableList<Section<Header, Item>>) {mdata.clear () mdata.addall (list) diff(true)} private fun toggleFold(pos: Int) { val section = mData[mSectionIndex[pos]] section.isFold = ! section.isFold lock(section) diff(false) if (! section.isFold) { for (i in 0 until mSectionIndex.size()) { val index = mSectionIndex[i] val inner = mItemIndex[i] if (inner == DiffCallback.ITEM_INDEX_SECTION_HEADER) { if (section.header == mData[index].header) { actionListener? .scrollToPosition(i, false, true) break } } } } }Copy the code

We need to maintain a copy of the old data and a copy of the new data. However, if the data is collapsed/expanded, only state changes are involved in the data. Therefore, diff will determine whether to change the state of the old data or copy the new data to the old data set based on the parameters:

private fun diff(reValue: Boolean) { val diffResult = DiffUtil.calculateDiff(DiffCallback(mLastData, mData), false) DiffCallback.generateIndex(mData, mSectionIndex, mItemIndex) diffResult.dispatchUpdatesTo(this) if (reValue) { mLastData.clear() mData.forEach { Mlastdata.add (it.clone())}} else {// Clone status avoid creating lots of objects mdata.foreachIndexed {index, it -> it.cloneStatusTo(mLastData[index]) } } }Copy the code

Auto load more up and down

Instead of just scrolling to the end of a list, you’re loading more for each section, up and down.

First, if a loadMore is triggered and there are sections below it, the user can continue scrolling down, which may disrupt the user’s current reading when the data comes back. Therefore, we introduce the concept of locking. If the current section needs to be loaded, the previous section will be locked and will not be displayed on the interface. If the current section needs to be loaded, the subsequent section will be locked and will not be displayed on the interface. This allows you to slide to the next section only when the current section is loaded. This is why isLocked is introduced into our data structure.

When is auto-loading triggered and when is auto-loading triggered? The answer is onViewAttachedToWindow. OnViewAttachedToWindow and onViewDetachedFromWindow fire when the view is visible and when the view is not, and they can do more interesting things, but we’ll talk about that later.

override fun onViewAttachedToWindow(holder: FoldViewHolder) { if (holder.itemView is SectionLoadingView) { val layout = holder.itemView if (! layout.isLoadError()) { val section = mData[mSectionIndex.get(holder.adapterPosition)] actionListener? .loadMore(section, layout.isLoadBefore()) } } }Copy the code

The code is simple, then wait for DB data or network data to come back:

fun successLoadMore(loadSection: Section<Header, Item>, data: List<Item>, loadBefore: Boolean, hasMore: Boolean){ if(loadBefore){ for(i in 0 until mSectionIndex.size()){ if(mItemIndex[i] == 0){ if(mData[mSectionIndex[i]] == loadSection){ val focusVH = actionListener? .findViewHolderForAdapterPosition(i) if (focusVH ! = null) { actionListener? .requestChildFocus(focusVH.itemView) break } } } } loadSection.list.addAll(0, data) loadSection.hasBefore = hasMore }else{ loadSection.list.addAll(data) loadSection.hasAfter = hasMore } lock(loadSection) diff(true) }Copy the code

If the data comes back, execute lock and diff after updating the data, only need to loadBefore more processing: When recyclerView performs an insert, the default is to keep the pre-insert item unchanged and move the post-insert item downward. But loadBefore, we expect the item after the insert to remain fixed and the item before the INSERT to move up.

The implementation is also simple. We focus on the item you want to keep fixed before the insert:

val focusVH = actionListener? .findViewHolderForAdapterPosition(i) if (focusVH ! = null) { actionListener? .requestChildFocus(focusVH.itemView) }Copy the code

Section headers are attached to the top

The remaining difficulty is to implement the section header at the top. There are several possible implementation schemes:

  1. Write a layoutManager
  2. Listen to RecyclerView onScroll
  3. Use RecyclerView ItemDecoration

The first method may be feasible and the most elegant, but it is a little difficult, so I will not consider it for the moment. The second scheme, listening onScroll events, is feasible in most cases, but it has two problems:

  1. OnScroll is triggered during the onLayout process, so some methods such as requestLayout will fail
  2. When scrollToPosition is called, onScroll will be called, but the information it gives is the information before scrollToPosition, which is not accurate for my calculation

In the previous version, onScroll was monitored. I changed the implementation of ItemDecoration to this version, which won’t have the previous two problems, but may waste more performance. Because it is triggered in onDraw, the number of calls will be much more than that of listening onScroll, and the advantage is precision.

There’s another question, do we build a real view and add it to the view hierarchy? Or draw in recyclerView? If you use the second solution, you need to deal with the part of the draw event interception and distribution, if the headerView is relatively simple, there will be no problem, if the headerView event is very complex, then it will increase a lot of work. So I choose to add a view to the view hierarchy.

In this part of the core code PinnedSectionItemDecoration words also is bad, or are interested in to see the code. (This feature was created by Chanthuang, I’m just a porter).

There’s also a way to scroll to a particular section or to scroll to a particular item in the Demo, which uses two indexes to do that, so I’m not going to go into too much detail here, but it’s interesting to look at the code.