-
Notifications
You must be signed in to change notification settings - Fork 2.7k
QMUIStickySectionLayout
QMUIStickySectionLayout
用于解决两个需求场景:
- 可折叠展开的 section 列表(list/grid)
- 类似 iOS 一样可以在列表(list/grid)滚动过程中悬浮(sticky)当前的 section header
<com.qmuiteam.qmui.widget.section.QMUIStickySectionLayout
android:id="@+id/section_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
这个组件需要使用者提供两个数据 model: Section Header, Section Item。 它们都需要实现 QMUISection.Model
这个接口:
public interface Model<T> {
/**
* 组件会通过 [DiffUtil](https://developer.android.com/reference/android/support/v7/util/DiffUtil) 去驱动数据更新以保留 item 动画,那么组件就需要备份当前数据用于下一次数据 diff。
* 这不是必须的,如果调用者能够保证每次 QMUIStickySectionAdapter#setData 的数据的内存值不相同,那么内部就不需要备份这些数据了。
* 备份数据不会用于渲染,所以只需要 clone 用于 diff 的 字段
*
* @return T的新实例
*/
T cloneForDiff();
/**
* 用于 QMUIDiffCallback 判断两个 item 是不是代表同一个对象。
* 例如,只要 use id 相同,就可以判定为时同一个用户。
*
* @return 如果是同一个对象则返回 true, 否则返回 false。
*/
boolean isSameItem(T other);
/**
* 在两个 item 表示的是同一个对象的前提下,用于 QMUIDiffCallback 判断两个 item 的内容是否相同。
*
* @return 如果内容相同则返回 true, 否则返回 false。
*/
boolean isSameContent(T other);
}
QMUISection.Model
的接口 isSameItem
与 isSameContent
的概念都来源于 DiffUtil, 如果对 DiffUtil 还不了解的话, 可以去官网查看与学习。
在 QMUIDemo 中,提供了 SectionHeader
和 SectionItem
的示例,可点击查看。
在准备好这两个 model 后,我们就可以构建 adapter 需要用到的 QMUISection<H, T>
了, 其中 H、T 就是之前准备的 SectionHeader 和 SectionItem。
QMUIDemo 示例:
private QMUISection<SectionHeader, SectionItem> createSection(String headerText, boolean isFold) {
SectionHeader header = new SectionHeader(headerText);
ArrayList<SectionItem> contents = new ArrayList<>();
for (int i = 0; i < 20; i++) {
contents.add(new SectionItem("item " + i));
}
QMUISection<SectionHeader, SectionItem> section = new QMUISection<>(header, contents, isFold);
// 如果 section 的 item 存在加载更多的需求,可通过以下两个调用告诉 section 是否需要加载更多
// section.setExistAfterDataToLoad(true);
// section.setExistBeforeDataToLoad(true);
return section;
}
QMUIStickySectionLayout
的 adapter 需要 继承自 QMUIStickySectionAdapter
, 如果没有自定义 ViewHolder 的需求,那么可以直接继承自 QMUIDefaultStickySectionAdapter
以简化业务代码
public class QDGridSectionAdapter extends QMUIDefaultStickySectionAdapter<SectionHeader, SectionItem> {
@NonNull
@Override
protected VH onCreateSectionHeaderViewHolder(@NonNull ViewGroup viewGroup){
// onCreaterViewHolder for sectionHeader
}
@NonNull
@Override
protected VH onCreateSectionItemViewHolder(@NonNull ViewGroup viewGroup){
// onCreaterViewHolder for sectionItem
}
@NonNull
@Override
protected VH onCreateSectionLoadingViewHolder(@NonNull ViewGroup viewGroup){
// onCreaterViewHolder for sectionLoading
}
@NonNull
@Override
protected VH onCreateCustomItemViewHolder(@NonNull ViewGroup viewGroup, int type){
// 扩展用法, 详细内容请看自定义扩展相关的内容
}
@Override
protected void onBindSectionHeader(VH holder, int position, QMUISection<H, T> section) {
// onBindViewHolder for sectionItem
}
@Override
protected void onBindSectionItem(VH holder, int position, QMUISection<H, T> section, int itemIndex) {
// onBindViewHolder for sectionItem
}
@Override
protected void onBindSectionLoadingItem(VH holder, int position, QMUISection<H, T> section, boolean loadingBefore) {
// onBindViewHolder for sectionLoading
}
@Override
protected void onBindCustomItem(VH holder, int position, @Nullable QMUISection<H, T> section, int itemIndex) {
// 扩展用法, 详细内容请看自定义扩展相关的内容
}
}
准备好 Adapter 后, 我们通过QMUIStickySectionAdapter#setData(@Nullable List<QMUISection<H, T>> data, boolean onlyMutateState)
将数据传递给 adapter。 它需要提供两个参数, 第一个参数就是就是一个 QMUISection
的列表结构,前面我们已经准备好了, 第二个参数存在的原因在 QMUISection.Model 里也提及到了,这里再着重强调一下:
组件会通过 DiffUtil 去驱动数据更新以保留 item 动画,那么组件就需要备份当前数据用于下一次数据 diff。如果你能确保下一次 setData 时section 列表中的 SectionHeader 和 SectionItem 不会引用同一份数据,那么你可以传递 onlyMutateState 为 true 以优化性能
在组件内部,onlyMutateState 的作用是:
- 如果 onlyMutateState == true, 那么执行浅拷贝,只拷贝
QMUISection
及其状态,之所以要拷贝QMUISection
, 那是因为折叠/展开等内部操作也会影响到它的状态。 - 如果 onlyMutateState == false,那么执行深拷贝,这个时候就不仅会拷贝
QMUISection
, 还会调用QMUISection.Model#cloneForDiff
去拷贝 SectionHeader 以及 SectionItem。 如果下一次 setData 的数据不会引用同一份数据,那么这将是耗费性能和内存的行为。
如果在某些场景下,你想通过 notifyDataChanged 去做无动画刷新数据, 你可以调用 QMUIStickySectionAdapter#setDataWithoutDiff(@Nullable List<QMUISection<H, T>> data, boolean onlyMutateState)
去更新数据。
通过 QMUIStickySectionAdapter#setCallback
,使用者可以接收到 loadMore 事件以及 item 点击/长按事件。 并且通过 ViewHolder.isForStickyHeader
可以判断是否是 stickyHeader
mAdapter.setCallback(new QMUIStickySectionAdapter.Callback<SectionHeader, SectionItem>() {
@Override
public void loadMore(final QMUISection<SectionHeader, SectionItem> section, final boolean loadMoreBefore) {
// only for demo, ignore to handle repeat loadMore
mSectionLayout.postDelayed(new Runnable() {
@Override
public void run() {
if (isAttachedToActivity()) {
ArrayList<SectionItem> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(new SectionItem("load more item " + i));
}
mAdapter.finishLoadMore(section, list, loadMoreBefore, false);
}
}
}, 1000);
}
@Override
public void onItemClick(QMUIStickySectionAdapter.ViewHolder holder, int position) {
Toast.makeText(getContext(), "click item " + position, Toast.LENGTH_SHORT).show();
}
@Override
public boolean onItemLongClick(QMUIStickySectionAdapter.ViewHolder holder, int position) {
Toast.makeText(getContext(), "long click item " + position, Toast.LENGTH_SHORT).show();
return true;
}
});
通过 QMUIStickySectionLayout#setAdapter
来设置 adapter, 如果你不想要 stickyHeader 效果,可以将第二个参数设为 false:
// 带有 stickyHeader 效果
mSectionLayout.setAdapter(mAdapter);
// 无 stickyHeader 效果
mSectionLayout.setAdapter(mAdapter, false);
如果你使用 GridLayoutManager
, 那么你需要通过 GridLayoutManager#setSpanSizeLookup
设置 SectionHeader 等独占一行:
final GridLayoutManager layoutManager = new GridLayoutManager(getContext(), 3);
layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int i) {
// getItemIndex 相关信息请查看关于index相关的内容
return mAdapter.getItemIndex(i) < 0 ? layoutManager.getSpanCount() : 1;
}
});
return layoutManager;
在使用 QMUIStickySectionLayout
时,我们经常会遇到 index 相关术语,这与 QMUIStickySectionLayout
的实现相关,很多功能的实现都与 index 息息相关。
我们都知道,RecyclerView.Adapter 需要的数据是一维结构的,而我们传递的是二维结构的数据,那么组件内部就需要将其转换为一份一维数据结构。
QMUIStickySectionLayout
采用了两份 index 的方式来构建这个一维结构,并且可以便捷的访问到原本的二维结构。 两份 index 都是 SparseIntArray
的数据结构
我这里命名两份 index 分别为 sectionIndex 和 itemIndex:
- sectionIndex 存储了 position 到 section list 下标的映射
- itemIndex 存储了 position 到 item list 下标的映射
那么我们如何根据 position 找到 我们期望的 item 呢? 首先,组件根据 sectionIndex 找到它是 List 中的第几个 section, 然后根据 itemIndex 找到它是 section 中 itemList 的第几个,这样就完成了一个基本的一维到二维的映射。 但是如何找到 SectionHeader、SectionLoading 的位置呢? 数组下标永远是大于等于0的值,所以负数就成了我们的扩展空间,因此我们规定 SectionHeader 的 itemIndex 为 -2, loadingBefore 的 itemIndex 为 -3, loadingAfter 的 itemIndex 为 -4。
public static final int ITEM_INDEX_UNKNOWN = -1;
public static final int ITEM_INDEX_SECTION_HEADER = -2;
public static final int ITEM_INDEX_LOAD_BEFORE = -3;
public static final int ITEM_INDEX_LOAD_AFTER = -4;
后面的自定义扩展也是基于负数来做的。
知道了 index 这一概念,那我们来看看 QMUIStickySectionAdapter
提供的一些有用的接口:
// 根据 position 获取 sectionIndex
int getSectionIndex(int position);
// 根据 position 获取 itemIndex
int getItemIndex(int position);
// 根据 position 获取 QMUISection
QMUISection<H, T> getSection(int position);
// 根据 index 直接获取 QMUISection
QMUISection<H, T> getSectionDirectly(int index);
// 根据 position 获取 section item
T getSectionItem(int position);
// 通过 PositionFinder 来寻找感兴趣的 item 的 position。unFoldTargetSection 决定是否要展开目标 section
int findPosition(PositionFinder<H, T> positionFinder, boolean unFoldTargetSection)
// 通过 sectionIndex 和 itemIndex 来寻找 item 的 position
int findPosition(int sectionIndex, int itemIndex, boolean unFoldTargetSection)
// 这个方法仅仅用于自定义扩展的 item
int findCustomPosition(int sectionIndex, int customItemIndex, boolean unFoldTargetSection)
折叠展开是基础功能,而业务上也经常需要我们滚动特定的 section header 或 section item。 因此组件也提供了这几个功能的支持。
// 切换折叠状态,scrollToTop 决定是否要将展开的 section 滚动到顶部去
void toggleFold(int position, boolean scrollToTop)
// 滚动到指定的 section header
// scrollToTop 为 true, 则滚动到顶部,否则只是滚动到可见区域
void scrollToSectionHeader(@NonNull QMUISection<H, T> targetSection, boolean scrollToTop)
// 滚动到指定的 section 中指定的 item
// scrollToTop 为 true, 则滚动到顶部(不被 section header 遮挡),否则只是滚动到可见区域
void scrollToSectionItem(@Nullable QMUISection<H, T> targetSection, @NonNull T targetItem, boolean scrollToTop)
如果一个 section 的 item 数量特别多,很可能需要做加载更多的需求,因此组件也提供了内部支持。 使用者可通过以下步骤开启加载更多:
- 在
QMUISection
通过setExistBeforeDataToLoad
和setExistAfterDataToLoad
指示 section 是否存在加载更多; - 在 Adapter 中 重写
onCreateSectionLoadingViewHolder
提供 loading view; - 通过
QMUIStickySectionAdapter#setCallback
提供 loadMore 的处理逻辑; - 当数据回来时,通过
QMUIStickySectionAdapter#finishLoadMore
将数据添加到 Adapter 中,完成加载更多。
加载更多有一个附加行为:
加载更多会锁住之前或之后的内容。例如,如果是向后加载更多,那么加载期间,无法滚动到加载 section 之后的 section。 这样可以保证用户不会跳过加载中的内容。
需求总是复杂的,上面我们实现了一个折叠展开、悬浮section header、滚动到特定位置等功能,但是实际业务会经常会遇到一下场景:
- 在整个列表前添加一个 header;
- 在整个列表结束后加一个 footer;
- 在 section item 列表前加一个提示语;
- 在 section item 列表结束后加一个结束语。
QMUIStickySectionLayout
提供了对这些功能的支持,接下来我们看看如何实现这些功能:
@Override
protected QMUISectionDiffCallback<SectionHeader, SectionItem> createDiffCallback(
List<QMUISection<SectionHeader, SectionItem>> lastData,
List<QMUISection<SectionHeader, SectionItem>> currentData) {
return new QMUISectionDiffCallback<SectionHeader, SectionItem>(lastData, currentData) {
@Override
protected void onGenerateCustomIndexBeforeSectionList(IndexGenerationInfo generationInfo, List<QMUISection<SectionHeader, SectionItem>> list) {
// 在整个列表前添加自定义 item
generationInfo.appendWholeListCustomIndex(ITEM_INDEX_LIST_HEADER);
}
@Override
protected void onGenerateCustomIndexAfterSectionList(IndexGenerationInfo generationInfo, List<QMUISection<SectionHeader, SectionItem>> list) {
// 在整个列表结束后添加自定义 item
generationInfo.appendWholeListCustomIndex(ITEM_INDEX_LIST_FOOTER);
}
@Override
protected void onGenerateCustomIndexBeforeItemList(IndexGenerationInfo generationInfo,
QMUISection<SectionHeader, SectionItem> section,
int sectionIndex) {
// 在 section item 列表前添加自定义 item
if (!section.isExistBeforeDataToLoad()) {
generationInfo.appendCustomIndex(sectionIndex, ITEM_INDEX_SECTION_TIP_START);
}
}
@Override
protected void onGenerateCustomIndexAfterItemList(IndexGenerationInfo generationInfo,
QMUISection<SectionHeader, SectionItem> section,
int sectionIndex) {
// 在 section item 列表结束后添加自定义 item
if (!section.isExistAfterDataToLoad()) {
generationInfo.appendCustomIndex(sectionIndex, ITEM_INDEX_SECTION_TIP_END);
}
}
@Override
protected boolean areCustomContentsTheSame(@Nullable QMUISection<SectionHeader, SectionItem> oldSection, int oldItemIndex, @Nullable QMUISection<SectionHeader, SectionItem> newSection, int newItemIndex) {
// 判断自定义 item 的内容是否相同, 组件并没有去备份自定义 item 的内容,所以这些都需要使用者自己去处理
// 或者可以通过 adapter.findCustomPostion 去获取自定义 item 的 position,然后通过 notifyItemChanged 去更新内容
return true;
}
};
}
QMUISectionDiffCallback 提供了四个钩子函数,用于使用者来添加自定义的 item, 使用者为每一个自定义 item 定义一个无重复的 Item Index, 最好为负数。组件内部定义了一个偏移量 ITEM_INDEX_CUSTOM_OFFSET = -1000
以避免与内部使用的 index 发生冲突, 当然,如果在这个前提下依旧与内部使用的 item index 冲突, 那么组件就会抛出错误。
- 通过
IndexGenerationInfo#appendWholeListCustomIndex
添加无 section 信息的 custom index, 实际上是将 section index 设置为 -1。 - 通过
IndexGenerationInfo#appendCustomIndex
添加 section 内的 custom index。
每一个钩子函数里可多次调用 append 函数添加多个自定义 item,但是需要保证同一个section(或者整个无section信息)内的自定义 item 的 custom index 不要重复。
@Override
protected int getCustomItemViewType(int itemIndex, int position) {
if (itemIndex == ITEM_INDEX_LIST_HEADER) {
return ITEM_TYPE_LIST_HEADER;
} else if (itemIndex == ITEM_INDEX_LIST_FOOTER) {
return ITEM_TYPE_LIST_FOOTER;
} else if (itemIndex == ITEM_INDEX_SECTION_TIP_START) {
return ITEM_TYPE_SECTION_TIP_START;
} else if (itemIndex == ITEM_INDEX_SECTION_TIP_END) {
return ITEM_TYPE_SECTION_TIP_END;
}
return super.getCustomItemViewType(itemIndex, position);
}
这里与 getItemViewType
处理逻辑类似,不过这里更多的是依靠 itemIndex 来返回不同的 viewType。
在 adapter 里通过 onCreateCustomItemViewHolder
创建 ViewHolder, 通过 onBindCustomItem
绑定数据, 这与传统的多 viewType 处理大体相同。
在某些外界条件干预下,custom item 或许会出现增删改的变化,这个时候我们可以通过 QMUIStickySectionAdapter#refreshCustomData()
通知 adapter 刷新数据。
如果仅仅是某个 custom item 的内容发生改变, 我们也可以通过 QMUIStickySectionAdapter#findCustomPosition
找到 item 的 position, 然后通过 notifyItemChanged 通知 adapter 刷新数据,这样更加轻量。 这不能用于添加与删除,因为这两个操作我们必须同时更新两个 index 信息。