Skip to content

QMUIStickySectionLayout

cgspine edited this page Apr 2, 2019 · 7 revisions

QMUIStickySectionLayout 用于解决两个需求场景:

  1. 可折叠展开的 section 列表(list/grid)
  2. 类似 iOS 一样可以在列表(list/grid)滚动过程中悬浮(sticky)当前的 section header

使用

1. 通过 XML 或 Java 代码构造 QMUIStickySectionLayout 实例:

<com.qmuiteam.qmui.widget.section.QMUIStickySectionLayout
  android:id="@+id/section_layout"
  android:layout_width="match_parent"
  android:layout_height="match_parent"/>

2. 准备好数据模型

这个组件需要使用者提供两个数据 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 的接口 isSameItemisSameContent 的概念都来源于 DiffUtil, 如果对 DiffUtil 还不了解的话, 可以去官网查看与学习。

在 QMUIDemo 中,提供了 SectionHeaderSectionItem 的示例,可点击查看。

在准备好这两个 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;
}

3. 实现 Adapter

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 的作用是:

  1. 如果 onlyMutateState == true, 那么执行浅拷贝,只拷贝 QMUISection 及其状态,之所以要拷贝 QMUISection, 那是因为折叠/展开等内部操作也会影响到它的状态。
  2. 如果 onlyMutateState == false,那么执行深拷贝,这个时候就不仅会拷贝QMUISection, 还会调用 QMUISection.Model#cloneForDiff 去拷贝 SectionHeader 以及 SectionItem。 如果下一次 setData 的数据不会引用同一份数据,那么这将是耗费性能和内存的行为。

如果在某些场景下,你想通过 notifyDataChanged 去做无动画刷新数据, 你可以调用 QMUIStickySectionAdapter#setDataWithoutDiff(@Nullable List<QMUISection<H, T>> data, boolean onlyMutateState) 去更新数据。

4. 关联 QMUIStickySectionLayout 与它的相关类

通过 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;

关于 index

在使用 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 数量特别多,很可能需要做加载更多的需求,因此组件也提供了内部支持。 使用者可通过以下步骤开启加载更多:

  1. QMUISection 通过 setExistBeforeDataToLoadsetExistAfterDataToLoad 指示 section 是否存在加载更多;
  2. 在 Adapter 中 重写 onCreateSectionLoadingViewHolder 提供 loading view;
  3. 通过 QMUIStickySectionAdapter#setCallback 提供 loadMore 的处理逻辑;
  4. 当数据回来时,通过 QMUIStickySectionAdapter#finishLoadMore 将数据添加到 Adapter 中,完成加载更多。

加载更多有一个附加行为:

加载更多会锁住之前或之后的内容。例如,如果是向后加载更多,那么加载期间,无法滚动到加载 section 之后的 section。 这样可以保证用户不会跳过加载中的内容。

自定义扩展

需求总是复杂的,上面我们实现了一个折叠展开、悬浮section header、滚动到特定位置等功能,但是实际业务会经常会遇到一下场景:

  1. 在整个列表前添加一个 header;
  2. 在整个列表结束后加一个 footer;
  3. 在 section item 列表前加一个提示语;
  4. 在 section item 列表结束后加一个结束语。

QMUIStickySectionLayout 提供了对这些功能的支持,接下来我们看看如何实现这些功能:

1. 在 Adapter 里重写 createDiffCallback 返回自定义的 QMUISectionDiffCallback:

@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 不要重复。

2. 在 Adapter 里重写 getCustomItemViewType 做多 viewType 处理

@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。

3. onCreateCustomItemViewHolder / onBindCustomItem

在 adapter 里通过 onCreateCustomItemViewHolder 创建 ViewHolder, 通过 onBindCustomItem 绑定数据, 这与传统的多 viewType 处理大体相同。

4. 数据更新

在某些外界条件干预下,custom item 或许会出现增删改的变化,这个时候我们可以通过 QMUIStickySectionAdapter#refreshCustomData() 通知 adapter 刷新数据。

如果仅仅是某个 custom item 的内容发生改变, 我们也可以通过 QMUIStickySectionAdapter#findCustomPosition 找到 item 的 position, 然后通过 notifyItemChanged 通知 adapter 刷新数据,这样更加轻量。 这不能用于添加与删除,因为这两个操作我们必须同时更新两个 index 信息。