最近这一两个周都没有怎么更新 QMUI。因为我一直在搞忙于搞微信读书的讲书界面。沉醉于写 bug 和改 bug 之中。
微信读书的讲书界面与功能都比较复杂,这次我把其中的折叠、展开、loading 的功能单独拿出来,写了一个 Demo,分享给大家。
先说说这个 Demo 所具有的功能:
- section 展开/折叠,带动画效果
- 如果展开,往上滚动,当前 section 的 header 会附着在顶部
- 每个 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:
- 已知 adpater 的 position, 能方便的 find 出 section 的信息 以及 section 下 item 对应的信息
- 已知 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 的值时,我们需要两步:
- 从 sectionIndex 中 找到 section 所在的位置, 从而获取 section
- 从 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 吸附在顶部的效果的实现了。可以想到的实现方案有一下几个:
- 写一个 layoutManager
- 监听 RecyclerView 的 onScroll
- 使用 RecyclerView 的 ItemDecoration
第一种方式也许可行并且最优雅,不过难度有点大,暂不考虑。第二种方案,监听 onScroll 事件,大多数情况下是可行的,不过其有两个问题:
- onScroll 是在 onLayout 过程中触发的,所以一些诸如 requestLayout 等方法会失效
- 当调用 scrollToPosition 时, onScroll 会调用,但是其给的信息是 scrollToPosition 前的信息,对于我的计算并不准确
之前一个版本是监听 onScroll,这个版本我换成了 ItemDecoration 的实现,不会有之前的那两个问题,但可能会浪费更多的性能,因为是在 onDraw 时触发的,所以调用次数会比监听 onScroll 多很多,好处就是精确。
还有另一个问题, 我们是构造一个真的 view 添加到视图层级中去? 还是 draw 在 recyclerView 上? 如果采用第二种方案,则需要自己去处理 被 draw 上去的 部分的事件拦截与分发,如果 headerView 比较简单,还不会有什么问题,如果 headerView 也的事件很复杂,那么就会增加很多工作了。因此我选择添加一个 view 到视图层级中。
这部分的核心代码在 PinnedSectionItemDecoration 中,言语也不好表述,有兴趣的还是去看代码。(这部分功能由 chanthuang 创造, 我只是个搬运工)。
Demo 中还有滚动到特定 section 或者 滚动到特定 item 的实现,都是利用两个 index 去做的,这里不做过多阐述,有兴趣的还是去看代码把。
今天的文章RecyclerView 折叠/展开功能的实现分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/19620.html