最近这一两个周都没有怎么更新 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 去做的,这里不做过多阐述,有兴趣的还是去看代码把。