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 的位置关系。

附录

完整源码地址

时间: 2024-11-05 03:56:55

RecyclerView探索之通过ItemDecoration实现StickyHeader效果的相关文章

Android基础控件——RecyclerView实现拖拽排序侧滑删除效果

RecyclerView实现拖拽排序侧滑删除效果 事先说明: RecyclerView是ListView的升级版,使用起来比ListView更规范,而且功能和动画可以自己添加,极容易扩展,同样也继承了ListView复用convertView和ViewHolder的优点.   思路分析: 1.导包.在布局中使用RecyclerView 2.需要一个JavaBean用来存储展示信息 3.需要一个填充RecyclerView的布局文件 4.在代码中找到RecyclerView,并为其绑定Adapte

深入理解 RecyclerView 系列之一:ItemDecoration

RecyclerView 已经推出了一年多了,日常开发中也已经彻底从 ListView 迁移到了 RecyclerView,但前两天有人在一个安卓群里面问了个关于最顶上的 item view 加蒙层的问题,被人用 ItemDecoration 完美解决.此时我发现自己对 RecyclerView 的使用一直太过基本,更深入更强大的功能完全没有涉及,像 ItemDecoration, ItemAnimator, SmoothScroller, OnItemTouchListener, Layout

RecyclerView 知识梳理(4) - ItemDecoration

一.概述 通过ItemDecoration,可以给RecyclerView或者RecyclerView中的每个Item添加额外的装饰效果,最常用的就是用来为Item之间添加分割线,今天,我们就来一起学习有关的知识: API DividerItemDecoration解析 自定义ItemDecoration 二.API介绍 当我们实现自己的ItemDecoration时,需要继承于ItemDecoration,并根据需要实现以下三个方法: 2.1 public void onDraw(Canvas

RecyclerView(二)—— ItemDecoration

Recycler没有直接提供设置item间距的功能,而是提供了一个更强大的基类ItemDecoration.类如其名,这个类是Item的装饰.它既可以作为Item的间距,也可以在item之间绘制分隔线,甚至可以对每个item的边缘都进行不同的绘制. ItemDecoration本身是一个虚类,我们在使用时,只能继承它. 先看一个简单版本,这个版本的ItemDecoration只提供一个Item的间距. import android.graphics.Rect; import android.su

多样化条目RecyclerView,以及多样化动画点击效果(附源码)

RecyclerView是support-v7包中的新组件, 是一个强大的滑动组件. 与经典的ListView相比, 同样拥有item回收复用的功能, 直接把viewholder的实现封装起来, 用户只要实现自己的viewholder就可以了, 该组件会自动帮你回收复用每一个item. 它不但变得更精简, 也变得更加容易使用, 而且更容易组合设计出自己需要的滑动布局. RecyclerView出世有段时间了, 我也把我的项目中的ListView替换成了RecyclerView, 只是, Recy

【Android 仿微信通讯录 导航分组列表-上】使用ItemDecoration为RecyclerView打造带悬停头部的分组列表

[Android 仿微信通讯录 导航分组列表-上]使用ItemDecoration为RecyclerView打造带悬停头部的分组列表 一 概述 本文是Android导航分组列表系列上,因时间和篇幅原因分上下,最终上下合璧,完整版效果如下: 上部残卷效果如下:两个ItemDecoration,一个实现悬停头部分组列表功能,一个实现分割线(官方demo) 网上关于实现带悬停分组头部的列表的方法有很多,像我看过有主席的自定义ExpandListView实现的,也看过有人用一个额外的父布局里面套 Rec

RecyclerView&amp;自定义View实现时光轴效果

在QQ空间,淘宝,京东,以及一些旅游类的app上经常可以看到时间轴的效果,这种时间轴的效果有多种实现方式,本文用RecyclerView和自定义View来实现这个效果. 实现的效果图如下: 分析 从上面的效果图可以看出,这个就是一个RecyclerView,并且他的LayoutManager为LinearLayoutManager,LinearLayoutManager的方向是垂直的.但是我们注意到他的item不是都是一致的,大概有这几种情况: 只有一个item 第一个item 普通item 最

一个Demo带你认识Design库,纯原生控件也能做出很漂亮的效果

欢迎转载,转载请注明出处http://blog.csdn.net/w804518214/article/details/51340984 不得不说开发者头条的APP真的是Material Design的典范,纯原生控件也能做出很漂亮的效果,并且不需要处理各种复杂的滑动冲突!!其主页基本把Design库的几个控件展示了一遍,今天就顺手借开发者头条主页的实现来简单介绍下官方Design扩展包里几个控件的使用.本文不会详细展开讲每个控件,仅仅针对demo效果的实现,想深入研究的推荐看官方指南! 先上效

你必须了解的RecyclerView的五大开源项目-解决上拉加载、下拉刷新和添加Header、Footer等问题

前段时间做项目由于采用的MD设计,所以必须要使用RecyclerView全面代替ListView.但是开发中遇到了需要实现RecyclerView上拉加载.下拉刷新和添加Header以及Footer等需求问题,现将问题解决中用到的五大开源项目总结下来,方便他人. 首先介绍下RecyclerView,RecyclerView相比ListView增加了很多新特性: ? Adapter中的ViewHolder模式 - 对于ListView来说,通过创建ViewHolder来提升性能并不是必须的.因为L