RecyclerView探索之通过ItemDecoration实现StickyHeader效果

Posted frank909

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了RecyclerView探索之通过ItemDecoration实现StickyHeader效果相关的知识,希望对你有一定的参考价值。

我在上一篇《小甜点,RecyclerView 之 ItemDecoration 讲解及高级特性实践 》 讲解了 ItemDecoration 的基本用法及它的一些实践,抱着学习研究的态度,这一篇作为实践篇主要目的是尝试通过 ItemDecoration 来实现 RecyclerView 中的 StickyHeader 功能。

关于 StickyHeader 想必大家已经很清楚了,如果不有不清楚的,看下图:
这里写图片描述

如果要实现 StickyHeader 的话,首先,我们得明白普通的 Header 是怎么实现的。

ItemDecoration 实现普通的 Header

这里写图片描述

上面这张图是我微信的通讯录界面,大家可以看到微信按拼音和英文名首字母给账号进行了分组,上面灰色的 B 和 C 就是 Header。

之前在 ListView 时代,实现头部功能就是通过 ItemView 的 layout 布局实现的。
这里写图片描述

一个 ItemView 分为两个部分,如果这个 ItemView 是小组的第一个,那么它的 Header 就应该显示出来,不然就得隐藏,所以只要好处理分组与 ItemView 的位置关系,这个 Header 功能就很容易实现了。
这里写图片描述

现在,用 ItemDecoration 来实现头部,就不需要在每个 ItemView 中设置这个隐藏的 Header 部分了,ItemView 只需要关心它自己真正要表现的界面效果就好了,像这种零碎的事情就专门交给 ItemDecoration 来处理。

这里写图片描述

但不管是 ItemView 还是 ItemDecoration 来实现 Header,正确的数据分组永远是第一步。

而数据的分组离不开 Adapter 的配合,所以数据的分组应该由外部来完成,而不是 ItemDecoration 本身,那好,创建 ItemDecoration 第一步就是定义一个接口,用来获取分组信息。

public class GroupInfo {
    //组号
    private int mGroupID;
    // Header 的 title
    private String mTitle;


    public GroupInfo(int groupId, String title) {
        this.mGroupID = groupId;
        this.mTitle = title;
    }

    public int getGroupID() {
        return mGroupID;
    }

    public void setGroupID(int groupID) {
        this.mGroupID = groupID;
    }

    public String getTitle() {
        return mTitle;
    }

    public void setTitle(String title) {
        this.mTitle = title;
    }
}

上面代码 Header 的相关信息。

public class SectionDecoration extends RecyclerView.ItemDecoration {


    public interface GroupInfoCallback {
        GroupInfo getGroupInfo(int position);
    }
}

有了 GroupInfoCallback 回调,SectionItemDecoration 就可以通过它的 getGroupInfo() 方法来获取每个 ItemView 对应的分组信息。

我们再回到 Header 话题上来,因为是通过 ItemDecoration 来完成它,所以肯定要借助于它的 getItemOffsets() 方法。我们组与组之间的间隔设置成为一个 Header 的高度,然后组内的 ItemView 之间的间距是指定的间距值,通常为 1 px 或者 2 px。大家看图就明白了。

这张图与上面的那张差不多,但是灰色区域都是通过 ItemDecoration 中 getItemOffsets 方法操纵 outRect 参数撑开的。我们绘制 Header 只要计算出对应的位置然后通过 Canvas 就能为所欲为了。关键的一点在于 Header 只绘制在组内第一个 ItemView 的上方,所以我们还需要一个途径来获知 ItemView 在组内的位置。我们可以升级 GroupInfo 类,添加一个域用来标记 ItemView 在组内的位置,还需要提供一个方法来判断它是不是组内的第一个。

public class GroupInfo {
    //组号
    private int mGroupID;
    // Header 的 title
    private String mTitle;
    //ItemView 在组内的位置
    private int position;

    //代码有精简
    ......

    public boolean isFirstViewInGroup () {
        return position == 0;
    }

    public void setPosition(int position) {
        this.position = position;
    }

}

并且 HeaderItemDecoration 只提供接口,实现逻辑交由外部。

public class SectionDecoration extends RecyclerView.ItemDecoration {

    //代码有精简
    ......

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);

        int position = parent.getChildAdapterPosition(view);

        if ( mCallback != null ) {
            GroupInfo groupInfo = mCallback.getGroupInfo(position);

            //如果是组内的第一个则将间距撑开为一个Header的高度,或者就是普通的分割线高度
            if ( groupInfo != null && groupInfo.isFirstViewInGroup() ) {
                outRect.top = mHeaderHeight;
            } else {
                outRect.top = mDividerHeight;
            }
        }
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);

        int childCount = parent.getChildCount();

        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);

            int index = parent.getChildAdapterPosition(view);

            if ( mCallback != null ) {
                GroupInfo groupinfo = mCallback.getGroupInfo(index);
                //只有组内的第一个ItemView之上才绘制
                if ( groupinfo.isFirstViewInGroup() ) {
                    int left = parent.getPaddingLeft();
                    int top = view.getTop() - mHeaderHeight;
                    int right = parent.getWidth() - parent.getPaddingRight();
                    int bottom = view.getTop();
                    //绘制Header
                    c.drawRect(left,top,right,bottom,mPaint);

                    float titleX =  left + mTextOffsetX;
                    float titleY =  bottom - mFontMetrics.descent;
                    //绘制Title
                    c.drawText(groupinfo.getTitle(),titleX,titleY,mTextPaint);
                }
            }
        }
    }


}

上面的代码,是实现 Header 的核心代码。
getItemOffsets 用来设置 ItemView 之间的间距,组内的第一个 View 之上会间隔出一个 Header 的高度,否则就是普通的分割线高度。

onDraw 用来遍历屏幕上的 ItemView,通过获取它们在 Adapter 中的位置,然后通过外部接口 GroupInfoCallback 得到它的组信息 GroupInfo。通过判断它是否是组内第一个 View 来决定是否在它之上绘制 Header。绘制的流程也非常简单。先确定 Header 的 Rect 范围,然后绘制,再在合适的位置上绘制上 Header 的 title。接下来要做的事情就是在 Activity 中去初始化 RecyclerView 相关。


    /**初始化测试数据*/
    private void initDatas() {
        data = new ArrayList<>();
        for (int i = 0; i < 56;i++) {
            data.add(i+" test ");
        }
    }



    initDatas();

    mAdapter = new TestAdapter(data);
    mRecyclerView.setAdapter(mAdapter);
    LinearLayoutManager layoutmanager = new LinearLayoutManager(this);
    layoutmanager.setOrientation(LinearLayoutManager.VERTICAL);
    mRecyclerView.setLayoutManager(layoutmanager);
    SectionDecoration.GroupInfoCallback callback = new SectionDecoration.GroupInfoCallback() {
        @Override
        public GroupInfo getGroupInfo(int position) {

            /**
             * 分组逻辑,这里为了测试每5个数据为一组。大家可以在实际开发中
             * 替换为真正的需求逻辑
            */
            int groupId = position / 5;
            int index = position % 5;
            GroupInfo groupInfo = new GroupInfo(groupId,groupId+"");
            groupInfo.setPosition(index);
            return groupInfo;
        }
    };
    mRecyclerView.addItemDecoration(new SectionDecoration(this,callback));

最终效果如下:
这里写图片描述

大家看到,代码很好的实现了效果。当然,这是测试程序,代码写的粗糙,实际开发,可以根据条件对 Header 部分进行精细绘制,将测试数据替换成真实的数据。

实现了 Header 之后,我们继续话题,接下来的任务是 StrickyHeader,它被称为粘性头部,或者悬停头部,它和普通的 Header 不同的一点就是在组内的成员 ItemView 没有彻底消失之前,它会悬停在顶部,像粘着不走的样子,直到它下面的 Header 将它推走。语言比较抽象,大家看一眼真实的场景就明白了。

ItemDecoration 实现的 StickyHeader

从 Header 到 StickyHeader 看起来没有改变多少,但开发难度却实实在在提高了很多。

  1. 首先,api 的改变,之前通过 onDraw() 方法,就完成了 Header 的绘制,但是现在 StickyHeader 有悬停效果,看起来像是浮在 ItemView 内容之上,所以 onDraw() 方法不再合适,得用 onDrawOver()。

  2. 算法逻辑不同。之前 Header 的绘制由组内第一个 ItemView 决定,但是 StickyHeader 由于悬停功能的添加,所以它是由屏幕上可见的组内的第一个 ItemView 来决定,每一个 ItemView 都有义务来绘制和维护StickyHeader 状态。

我想到了一个关键词:前赴后继

这里写图片描述

用一张图片来加深大家的印象。大家可以想像一下,一个组的所有 ItemView 排队去显示 StickyHeader。有两种情况需要考虑。

  1. 当前的 ItemView 不是屏幕上的第一个可见的 ItemView,但是它是组内的第一个 ItemView,所以这个时候按照绘制普通 Header 的逻辑绘制 StickyHeader 就可以了。
  2. 当前的 ItemView 不是屏幕上的第一个可见的 ItemView,同时它也不是组内的第一个 ItemView,所以它不需要做任何的事情。
  3. 当前的 ItemView 是屏幕上第一个可见的 ItemView,所以不管它是不是组内的第一个 ItemView,它都需要绘制 StickyHeader,因为它前面的兄弟阵亡了(滑动了视线外)。并且 StickyHeader 的起始位置应该依附在 RecyclerView 的内容起始位置,因为只有这样才会表现出 StickyHeader 粘性悬停的效果。

好了,有了这面的逻辑,我们就可以根据差异性信息来对前面的 Header 代码进行改造,在此基础上打造 StickyHeader。

public class StickySectionDecoration extends RecyclerView.ItemDecoration {

    //代码有精简
    ......

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);

        int childCount = parent.getChildCount();

        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);

            int index = parent.getChildAdapterPosition(view);


            if ( mCallback != null ) {

                GroupInfo groupinfo = mCallback.getGroupInfo(index);
                int left = parent.getPaddingLeft();
                int right = parent.getWidth() - parent.getPaddingRight();

                //屏幕上第一个可见的 ItemView 时,i == 0;
                if ( i != 0 ) {


                    //只有组内的第一个ItemView之上才绘制
                    if ( groupinfo.isFirstViewInGroup() ) {

                        int top = view.getTop() - mHeaderHeight;

                        int bottom = view.getTop();
                        drawHeaderRect(c, groupinfo, left, top, right, bottom);

                    }

                } else {

                    //当 ItemView 是屏幕上第一个可见的View 时,不管它是不是组内第一个View
                    //它都需要绘制它对应的 StickyHeader。

                    int top = parent.getPaddingTop();
                    int bottom = top + mHeaderHeight;

                    drawHeaderRect(c, groupinfo, left, top, right, bottom);
                }

            }
        }
    }

    private void drawHeaderRect(Canvas c, GroupInfo groupinfo, int left, int top, int right, int bottom) {
        //绘制Header
        c.drawRect(left,top,right,bottom,mPaint);

        float titleX =  left + mTextOffsetX;
        float titleY =  bottom - mFontMetrics.descent;
        //绘制Title
        c.drawText(groupinfo.getTitle(),titleX,titleY,mTextPaint);
    }

    ....

同样,我们在外面的 Activity 中初始化 RecyclerView。

StickySectionDecoration.GroupInfoCallback callback = new StickySectionDecoration.GroupInfoCallback() {
            @Override
            public GroupInfo getGroupInfo(int position) {

                /**
                 * 分组逻辑,这里为了测试每5个数据为一组。大家可以在实际开发中
                 * 替换为真正的需求逻辑
                */
                int groupId = position / 5;
                int index = position % 5;
                GroupInfo groupInfo = new GroupInfo(groupId,groupId+"");
                groupInfo.setPosition(index);
                return groupInfo;
            }
        };

mRecyclerView.addItemDecoration(new StickySectionDecoration(this,callback));

效果如下:
这里写图片描述

可以看到,到这里 StickyHeader 的悬停效果是完成了,但是大家仔细看,还有个细节是需要优化的。开篇说过,StickyHeader 悬停之后不消失,由下一个 StickyHeader 向上推走然后顶替它成为最顶层的 StickyHeader。
现在效果是:
这里写图片描述

理想的效果应该是这样:

这里写图片描述

那么,怎么改进呢?

首先我们观察一下现状。

这里写图片描述

我们可以看到,现在最上面一个 Header 消失时,它是由下面的 Header 慢慢覆盖的,我们理想的效果应该是下面的的 Header 快要到达顶部时,它向上推掉之前的 Header,然后取代它的位置。

那好,我们更进一步,思考下怎么实现这个“推”的过程?回顾一下之前的代码。

//当 ItemView 是屏幕上第一个可见的View 时,不管它是不是组内第一个View
                    //它都需要绘制它对应的 StickyHeader。

int top = parent.getPaddingTop();
int bottom = top + mHeaderHeight;

drawHeaderRect(c, groupinfo, left, top, right, bottom);

我们看到,之前绘制最顶层的 Header 时,它的 Rect 范围其实就已经固定了,紧贴着 parent 开始的地方,然后宽度为 parent 的宽度,高度为固定值。现在,我们要进行升级,实现一个“推”的动作。其实很简单,让 Header 跟随组内最后一个 ItemView 一起移出屏幕就可以了。

我们现在需要考虑组内最后一个 ItemView 对 Header 的影响。

这里写图片描述

上面就是“推”这个动作的状态分解。

  1. Section1 置于 RecyclerView 顶部,它的组内的 ItemView 由于向上滑动,从它的身下穿过,它的 top 值就是 parent.getPaddingTop()。
  2. Section1 置于 RecyclerView 顶部,由于Section2 的推挤,它组内最后一个 ItemView 的 bottom 已经和它的 bottom 一样了,注意这个是临界状态了,Section1的 top 值还是 parent.getPaddingTop()。
  3. Section1 置于 RecyclerView 顶部,现在 Section1 的 bottom 值 与 它组内最后一个 ItemView 的 bottom 值是同一个。但是,它的 top 值不再是 parent.getPaddingTop()。而是它的最后一个 ItemView 的 bottom - mHeaderHeight。
  4. Section1 已经被推出了屏幕外面,Section2 已经取代它了,然后进入下一轮这样的循环。

有了这些状态分解,我们就可以轻松地写代码了。关键一环就是如何确定某个 ItemView 是不是组内的最后一个 ItemView。所以,首先我们得升级我们的 GroupInfo 类。

public class GroupInfo {
    //组号
    private int mGroupID;
    // Header 的 title
    private String mTitle;
    //ItemView 在组内的位置
    private int position;
    // 组的成员个数
    private int mGroupLength;

    // 代码有精简
    ...... 


    public GroupInfo(int groupId, String title) {
        this.mGroupID = groupId;
        this.mTitle = title;
    }


    public boolean isFirstViewInGroup () {
        return position == 0;
    }

    public boolean isLastViewInGroup () {
        return position == mGroupLength - 1 && position >= 0;
    }



    public void setGroupLength(int groupLength) {
        this.mGroupLength = groupLength;
    }

    ......
}

isLastViewInGroup() 方法就是 GroupInfo 提供的判断是否是组内最后一个 ItemView 的依据。

我们再往下啃。看看最后一个组内的 ItemView 与 Header 之间的坐标关系。
1. 正常情况而言,第一个 Header 它的坐标值是固定的,所以它就表现出了悬浮的特性。
2. 当它组内最后一个 ItemView 的 bottom 值与 Header 的 bottom 一致时,也就是底部平齐的时候,view.getTop - mHeaderHeight 应该就是 Header 的 top 属性理论取值。我们暂且用 Header.top 指代它。如果它的值小于 parent.getPaddingTop 的话,那么 Header.top 就不能再为 parent.getPaddingTop 而应该是 view.getTop - mHeaderHeight 这个值,因为只有这样才会形成 Header 与它组内最后一个 ItemView 一起滑出屏幕的效果,而下面一个 Header 因为紧挨着前一个组的最后一个 ItemView 的底部,所以造就了是新的 Header 快要到顶时推着之前的 Header 走的视觉效果。

这里写图片描述

大家再看看上面这张图,细细体会一下。

我们可以接下来编写代码了。我们只需要改变少许代码

 public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);

        int childCount = parent.getChildCount();

        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);

            int index = parent.getChildAdapterPosition(view);


            if ( mCallback != null ) {

                GroupInfo groupinfo = mCallback.getGroupInfo(index);
                int left = parent.getPaddingLeft();
                int right = parent.getWidth() - parent.getPaddingRight();

                //屏幕上第一个可见的 ItemView 时,i == 0;
                if ( i != 0 ) {


                    //只有组内的第一个ItemView之上才绘制
                    if ( groupinfo.isFirstViewInGroup() ) {

                        int top = view.getTop() - mHeaderHeight;

                        int bottom = view.getTop();
                        drawHeaderRect(c, groupinfo, left, top, right, bottom);

                    }

                } else {

                    //当 ItemView 是屏幕上第一个可见的View 时,不管它是不是组内第一个View
                    //它都需要绘制它对应的 StickyHeader。

                    // 还要判断当前的 ItemView 是不是它组内的最后一个 View

                    int top = parent.getPaddingTop();


                    if ( groupinfo.isLastViewInGroup() ) {
                        int suggestTop = view.getBottom() - mHeaderHeight;
                        // 当 ItemView 与 Header 底部平齐的时候,判断 Header 的顶部是否小于
                        // parent 顶部内容开始的位置,如果小于则对 Header.top 进行位置更新,
                        //否则将继续保持吸附在 parent 的顶部
                        if ( suggestTop < top ) {
                            top = suggestTop;
                        }
                    }

                    int bottom = top + mHeaderHeight;

                    drawHeaderRect(c, groupinfo, left, top, right, bottom);
                }

            }
        }
    }

编写测试代码,然后查看效果。

StickySectionDecoration.GroupInfoCallback callback = new StickySectionDecoration.GroupInfoCallback() {
            @Override
            public GroupInfo getGroupInfo(int position) {

                /**
                 * 分组逻辑,这里为了测试每5个数据为一组。大家可以在实际开发中
                 * 替换为真正的需求逻辑
                */
                int groupId = position / 5;
                int index = position % 5;
                GroupInfo groupInfo = new GroupInfo(groupId,groupId+"");
                groupInfo.setGroupLength(5);
                groupInfo.setPosition(index);
                return groupInfo;
            }
        };
mRecyclerView.addItemDecoration(new StickySectionDecoration(this,callback));

这里写图片描述

总结

其实通过 ItemDecoration 来实现 StickyHeader 是比较容易的。主要有下面几个点:
1. 数据分组,分类。
2. 定义好数据分组的逻辑代码。
3. 编写自定义的 ItemDecoration,处理好 getItemOffsets 方法中 Header 的绘制范围。
4. 根据是否是第一个 Header 决定 Header 是否悬停。
5. 当 Header 是悬停效果时,要注意它与组内最后一个 ItemView 的位置关系。

附录

  1. CSDN完整源码地址

  2. 项目github地址 尽量以这个为准,更新了一些代码。

以上是关于RecyclerView探索之通过ItemDecoration实现StickyHeader效果的主要内容,如果未能解决你的问题,请参考以下文章

《Android开发艺术探索》之Android性能优化ListView和RecyclerView(十七)

Android开发之漫漫长途 XIV——RecyclerView

Android开发之漫漫长途 XVI——ListView与RecyclerView项目实战

RecyclerView系列之七:LayoutManager

RecyclerView的使用之HelloWorld

【译】RecyclerView专题之item动画实现原理(一)