RecyclerView 折叠/展开功能的实现

7,863 阅读8分钟
原文链接: blog.cgsdream.org

最近这一两个周都没有怎么更新 QMUI。因为我一直在搞忙于搞微信读书的讲书界面。沉醉于写 bug 和改 bug 之中。

微信读书的讲书界面与功能都比较复杂,这次我把其中的折叠、展开、loading 的功能单独拿出来,写了一个 Demo,分享给大家。

先说说这个 Demo 所具有的功能:

  1. section 展开/折叠,带动画效果
  2. 如果展开,往上滚动,当前 section 的 header 会附着在顶部
  3. 每个 section 都有上 loading 和 下 loading

数据结构

首先我们需要定义数据结构,这块比较简单,先上一个基础版的数据结构:

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
    }
}

基本不需要太多的解释,每一个 section 由 一个 header 和 一个 list 组成,isFold 指示折叠状态,hasBefore、hasAfter 指示是否需要上加载、下加载。 另外还有一个 isLocked, 这个我们后续再说,是一个很重要的状态。

目前数据结构很简单,但当我们把一个 List<Section> 的数据结构传递给 Adapter 时, 问题就出现了: 我们目前的数据是一个二维的数据结构,但 Adapter 喜欢的是一维数据结构。我们需要方便的实现下列两个 find:

  1. 已知 adpater 的 position, 能方便的 find 出 section 的信息 以及 section 下 item 对应的信息
  2. 已知 setcion 的某个 item 的信息,能方便的 find 出其在 adapter 中的 position

我直接给出我的解决方案。使用两个 SparseArray 来做索引:

  • 一个 SparseArray(sectionIndex) 是 adapterPosition: position in List<Section> 的 kv 存储;
  • 另一个 SparseArray(itemIndex) 是adapterPosition: position in section.list 的 kv 存储

当我们想从 adapterPosition 找到 section 中某个 item 的值时,我们需要两步:

  1. 从 sectionIndex 中 找到 section 所在的位置, 从而获取 section
  2. 从 itemIndex 中 找到 item 在 section.list 的位置, 根据第一步获取的 section, 从而拿到 item 信息

如果已知 section 中某个 item, 去获取 adapterPosition 时,就通过遍历,这个是省不掉的。

那如果是 header/loadMore 这些数据,如何确定其与 adapterPosition 的对应关系呢? 很简单,在 itemIndex 中引入负数,在 demo 中, 如果读取到 itemIndex 的 value 为 -1, 则表示 header, 如果为 -2 则表示 上 loading,如果为 -3,则为下 loading。 在微信读书中,还有 headerView 等更多类型,可以通过负数方便的扩展。

接下来看看 index 生成的工具方法:

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++
                }
            }
        }
    }
}

每次数据更新时,我都去更新两份 index,接下来 adapter 就只需要根据 两份 index 去实现各个方法了:

// 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)
        }
    }
}

数据展开与折叠

我们的二维数据与 Adapter 之间的连接已经建立了,那么数据更改时,我们如何通知 Adapter 呢?如果直接 notifyDataSetChanged, 则丢失了 RecyclerView 的动画, 如果用 notifyItemXXX,则维护起来又很困难。还好,Android 官方为我们提供了 DiffUtil,配合两个 index,写起代码来非常舒心:

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) {
            // load more 强制返回 false,这样可以通过 FolderAdapter.onViewAttachedToWindow 触发 load more
            return false
        }

        return true
    }
}

这里也可以看到那两份 index 在数据对比时发挥的作用,逻辑应该非常清晰。因此在数据变更或者折叠展开时,我都通过 DiffUtil 来对比更改:

// 数据更新
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
                }
            }
        }
    }
}

接下来看一下 diff 方法, 由于要做数据对比, 我们需要维护一份新数据以及一份旧数据,但如果是折叠/展开时, 数据涉及的只是状态的改变,因此 diff 会根据参数判断是更改旧数据的状态,还是将新数据集全盘 copy 给旧数据集:

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 避免大量创建对象
        mData.forEachIndexed { index, it ->
            it.cloneStatusTo(mLastData[index])
        }
    }
}

上下自动加载更多

对比以前简单 list 滚动到末尾时自动加载更多,这里就是每个 section 都需要加载更多了,而且是上下都需要。

首先,如果触发下loadMore时,如果下边还有 section,那么使用者就可以继续往下滚动,当数据回来时,可能会扰乱使用者的当前阅读。因此我们引入锁的概念,如果当前 section 需要上 loading, 那么前面的 section 会被锁住,不会被展示在界面上,如果当前 section 需要下 loading,那么后面的 section 会被锁住,不会被展示在界面上,这样只有当当前 section 加载完才能滑动进入下一个 section。这是我们的数据结构引入 isLocked 这个字段的原因了。

自动加载更多在什么时机触发何时呢?答案是 onViewAttachedToWindow 这个时机。 onViewAttachedToWindow 和 onViewDetachedFromWindow 分别在 view 可见和不可见时触发,其还可以做更多有趣的事情,以后有时间可以聊聊。

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())
        }
    }
}

代码很简单,然后等待DB数据或者网络数据回来:

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)
}

如果数据回来,则更新数据后执行 lock 和 diff,唯一需要多 loadBefore 做更多处理:当 recyclerView 执行 insert 时,默认都会保持 insert 前的 item 不动,insert 之后的 item 向下移动。 但是 loadBefore 时,我们期望的是 insert 之后的 item 保持不动, insert 之前的 item 向上移动。

实现方法也很简单,我们在 insert 前 focus 住你想保持不动的 item:

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

section header 吸附在顶部

剩下一个难点就是实现 section header 吸附在顶部的效果的实现了。可以想到的实现方案有一下几个:

  1. 写一个 layoutManager
  2. 监听 RecyclerView 的 onScroll
  3. 使用 RecyclerView 的 ItemDecoration

第一种方式也许可行并且最优雅,不过难度有点大,暂不考虑。第二种方案,监听 onScroll 事件,大多数情况下是可行的,不过其有两个问题:

  1. onScroll 是在 onLayout 过程中触发的,所以一些诸如 requestLayout 等方法会失效
  2. 当调用 scrollToPosition 时, onScroll 会调用,但是其给的信息是 scrollToPosition 前的信息,对于我的计算并不准确

之前一个版本是监听 onScroll,这个版本我换成了 ItemDecoration 的实现,不会有之前的那两个问题,但可能会浪费更多的性能,因为是在 onDraw 时触发的,所以调用次数会比监听 onScroll 多很多,好处就是精确。

还有另一个问题, 我们是构造一个真的 view 添加到视图层级中去? 还是 draw 在 recyclerView 上? 如果采用第二种方案,则需要自己去处理 被 draw 上去的 部分的事件拦截与分发,如果 headerView 比较简单,还不会有什么问题,如果 headerView 也的事件很复杂,那么就会增加很多工作了。因此我选择添加一个 view 到视图层级中。

这部分的核心代码在 PinnedSectionItemDecoration 中,言语也不好表述,有兴趣的还是去看代码。(这部分功能由 chanthuang 创造, 我只是个搬运工)。

Demo 中还有滚动到特定 section 或者 滚动到特定 item 的实现,都是利用两个 index 去做的,这里不做过多阐述,有兴趣的还是去看代码把。