RecyclerView 折叠/展开功能的实现

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

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

85048dec515b8d07956b7735c4dec3f.png

微信读书的讲书界面与功能都比较复杂,这次我把其中的折叠、展开、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 去做的,这里不做过多阐述,有兴趣的还是去看代码把。

目录
相关文章
不用涉及到各种冲突常规打造酷炫下拉视差效果SmartRefreshLayout+ViewPager+RecyclerView
不用涉及到各种冲突常规打造酷炫下拉视差效果SmartRefreshLayout+ViewPager+RecyclerView
256 0
|
4月前
Flutter-自定义折叠流布局实现
Flutter-自定义折叠流布局实现
89 0
|
4月前
|
移动开发 UED 容器
Flutter-自定义可展开文本控件
Flutter-自定义可展开文本控件
90 0
|
6月前
【sgCollapseBtn】自定义组件:底部折叠/展开按钮
【sgCollapseBtn】自定义组件:底部折叠/展开按钮
展开&收起,使用SwiftUI搭建一个侧滑展开页面交互
展开&收起,使用SwiftUI搭建一个侧滑展开页面交互
249 0
15EasyUI 布局- 创建折叠面板
15EasyUI 布局- 创建折叠面板
59 0
Flutter 74: 图解基本 DropdownButton 下拉选项框按钮
0 基础学习 Flutter,第七十四步:学习一下下拉选项框的基本用法!
3328 0
Flutter 74: 图解基本 DropdownButton 下拉选项框按钮
|
存储 缓存 索引
更好的 RecyclerView 表项子控件点击监听器
上篇介绍了一种新的监听 RecyclerView 表项点击事件的方法。实现了将点击事件和RecyclerView.Adapter解耦。这一篇介绍如何监听 RecyclerView 表项子控件点击事件。
590 0
|
Android开发
Android魔术系列:一步步实现滑动折叠列表
这个效果是多年前做电商的时候的一个需求,当时是模仿一个叫喵街的app(也不知道现在还在不在了)
383 0