RecyclerView 粘性(悬浮)头部

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zgcqflqinhao/article/details/83013923

图片来自于网络

感谢

【Android】RecyclerView:打造悬浮效果

RecyclerView分组悬浮列表

上图来自于网络,上图的列表中有一个悬浮的粘性头部的效果,现在这种效果的需求比较常见了,像通讯录,展示城市列表,还有一些咨询类 App 分类时都会见到这种效果。如果用 ListView 来实现,可谓是十分麻烦,而且查了查相关资料并不多,如果用 RecyclerView 的话,实现这种效果简直是分分钟的事。

一、ItemDecoration

要实现这种效果,首先我们需要了解一个 RecyclerView 的内部类 ItemDecoration,之前我在学习 RecyclerView 有记录过,从零开始学习RecyclerView(三),重温一下。

ItemDecoration 是一个抽象类,字面意思是 Item 的装饰,我们可以通过内部的绘制方法绘制装饰,它有三个需要实现的抽象方法(过时的方法不管):

onDraw() :该方法在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之前绘制,也就是说,如果该 Decoration 没有设置偏移的话,Item 的内容会覆盖该 Decoration。

onDrawOver() :在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之后绘制 ,也就是说,如果该 Decoration 没有设置偏移的话,该 Decoration 会覆盖 Item 的内容。

getItemOffsets() :为 Decoration 设置偏移。

首先我们写一个 Decoration,只是一个简单的分隔线效果,分隔线中间有文字:

public class StickyDecoration extends RecyclerView.ItemDecoration {
    private int mHeight;
    private Paint mPaint;
    private TextPaint mTextPaint;
    private Rect mTextBounds;

    public StickyDecoration() {
        mHeight = 100;
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.GRAY);
        mTextPaint = new TextPaint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setColor(Color.parseColor("#FF000000"));
        mTextPaint.setTextSize(48f);
        mTextBounds = new Rect();
    }

    /**
     * Description:在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之前绘制
     * 也就是说,如果该 Decoration 没有设置偏移的话,Item 的内容会覆盖该 Decoration。
     * Date:2018/9/14
     */
    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);  //Decoration 的左边位置
    }

    /**
     * Description:在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之后绘制
     * 也就是说,如果该 Decoration 没有设置偏移的话,该 Decoration 会覆盖 Item 的内容。
     * Date:2018/9/14
     */
    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String stickyHeaderName = "我是分隔线";
        int left = parent.getLeft();
        //Decoration 的右边位置
        int right = parent.getRight();
        //获取 RecyclerView 的 Item 数量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //Decoration 的底边位置
            int bottom = childView.getTop();
            //Decoration 的顶边位置
            int top = bottom - mHeight;
            c.drawRect(left, top, right, bottom, mPaint);

            //绘制文字
            mTextPaint.getTextBounds(stickyHeaderName, 0, stickyHeaderName.length(), mTextBounds);
            c.drawText(stickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
        }
    }

    /**
     * Description:为 Decoration 设置偏移
     * Date:2018/9/14
     */
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //outRect 相当于 Item 的整体绘制区域,设置 left、top、right、bottom 相当于设置左上右下的内间距
        //如设置 outRect.top = 5 则相当于设置 paddingTop 为 5px。
        outRect.top = mHeight;
    }
}

效果是这样:

现在就将这个分隔线一步一步实现成粘性头部。

二、同一组显示只显示一个分隔线

什么分隔线中的文字是写死的,所以需要提供给外部一个可以设置每一个分隔线文字的方法,然后在绘制分隔线的时候判断如果绘制的 position 的分割线的文字与上一个 position 的分隔线的文字一样的话就不绘制。

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String previousStickyHeaderName = null;
        String currentStickyHeaderName = null;
        int left = parent.getLeft();
        //Decoration 的右边位置
        int right = parent.getRight();
        //获取 RecyclerView 的 Item 数量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //判断上一个 position 粘性头部的文字与当前 position 的粘性头部文字是否相同,如果相同则跳过绘制
            int position = parent.getChildAdapterPosition(childView);
            currentStickyHeaderName = getStickyHeaderName(position);
            if (TextUtils.isEmpty(currentStickyHeaderName)) {
                continue;
            }
            if (position == 0) {
                //Decoration 的底边位置
                int bottom = childView.getTop();
                //Decoration 的顶边位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //绘制文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
                continue;
            }
            previousStickyHeaderName = getStickyHeaderName(position - 1);
            if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
                //Decoration 的底边位置
                int bottom = childView.getTop();
                //Decoration 的顶边位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //绘制文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
            }
        }
    }

    /**
     * Description:为 Decoration 设置偏移
     * Date:2018/9/14
     */
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //outRect 相当于 Item 的整体绘制区域,设置 left、top、right、bottom 相当于设置左上右下的内间距
        //如设置 outRect.top = 5 则相当于设置 paddingTop 为 5px。
        int position = parent.getChildAdapterPosition(view);
        String stickyHeaderName = getStickyHeaderName(position);
        if (TextUtils.isEmpty(stickyHeaderName)) {
            return;
        }
        if (position == 0) {
            outRect.top = mHeight;
            return;
        }
        String previousStickyHeaderName = getStickyHeaderName(position - 1);
        if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
            outRect.top = mHeight;
        }
    }

    /**
     * author:MrQinshou
     * Description:提供给外部设置每一个 position 的粘性头部的文字的方法
     * date:2018/10/14 22:14
     * param
     * return
     */
    public abstract String getStickyHeaderName(int position);

​​getItemOffsets() 与 onDrawOver() 中的判断差不多,都是如果当前位置的 stickyHeaderName 为空,则不预留粘性头部空间和不绘制粘性头部,然后如果是第 0 个位置,则直接给空间和绘制,在之后的 position 的 Item,需要拿到上一个 position 的粘性头部的文字与当前 position 的相比,如果不同才绘制。outRect.top 如果不给值的话,默认是 0。下面测试一下:

MainActivity 中就根据填充的数据来设置一下粘性头部的文字:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final List<String> list = getList(120);
        RecyclerView rvTest = (RecyclerView) findViewById(R.id.rv_test);
        TestAdapter testAdapter = new TestAdapter();
        rvTest.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
        rvTest.addItemDecoration(new StickyDecoration() {
            @Override
            public String getStickyHeaderName(int position) {
                return list.get(position);
            }
        });
        rvTest.setAdapter(testAdapter);
        testAdapter.setDataList(list);
    }

    private List<String> getList(int size) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 120; i++) {
            if (i < size / 3) {
                list.add("力量英雄");
            } else if (i < size / 3 * 2) {
                list.add("敏捷英雄");
            } else {
                list.add("智力英雄");
            }
        }
        return list;
    }
}

效果是这样的:

三、同一组的一直显示

接下来只需要将上面的同一组的头部一直显示在顶端,形成粘性效果,直到下一组的头部滑动上来时,才慢慢替换掉上一个头部,有一个推动效果。这个其实也很简单,因为 RecyclerView 在滑动时一直在回调 onDrawOver() 方法,所以我们该方法中绘制每一个 Item 的粘性头部时不断计算 Decoration 的位置,使其不会随着 Item 的滑动一起往上移动,即一直处于 RecyclerView 的位置。

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String previousStickyHeaderName = null;
        String currentStickyHeaderName = null;
        int left = parent.getLeft();
        //Decoration 的右边位置
        int right = parent.getRight();
        //获取 RecyclerView 的 Item 数量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //判断上一个 position 粘性头部的文字与当前 position 的粘性头部文字是否相同,如果相同则跳过绘制
            int position = parent.getChildAdapterPosition(childView);
            currentStickyHeaderName = getStickyHeaderName(position);
            if (TextUtils.isEmpty(currentStickyHeaderName)) {
                continue;
            }
            if (position == 0 || i == 0) {
                //Decoration 的底边位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
                //就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
                View nextChildView = parent.getChildAt(i + 1);
                String nextStickyHeaderName = getStickyHeaderName(position + 1);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的顶边位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //绘制文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
                continue;
            }
            previousStickyHeaderName = getStickyHeaderName(position - 1);
            if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
                //Decoration 的底边位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
                //就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
                View nextChildView = parent.getChildAt(i + 1);
                String nextStickyHeaderName = getStickyHeaderName(position + 1);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的顶边位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //绘制文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
            }
        }
    }

主要是 bottom 的计算,这样就完成了粘性头部的效果,而且下一组不同的头部到来时,也会有一个推动的过渡效果,不会很生硬:

四、GridLayoutManager

上面的粘性头部 Decoration 只适用 LinearLayoutManager,而且是垂直方向的 LinearLayoutManager,不过考虑到一般水平方向的 LayoutManager 对粘性头部的需求很少,所以暂时没去实现它。如果将上面的粘性头部 Decoration 用在 GridLayoutManager 上会怎样呢?

MainActivity 中将 LayoutManager 改为 spanCount 为 4 的 GridLayoutManager:

rvTest.setLayoutManager(new GridLayoutManager(this, 4));

可以看到有分隔线的那一行,除了第一个,其他的都往上”移“了。其实并不是 Item 往上移了,只是在 getItemOffsets() 中只给第一个 Item 设置了偏移,所以我们设置偏移的时候不是只给 position==0 的 Item 设置了,而要给第一行,即 position<spanCount 的 Item 都设置偏移。在其他比较粘性头部的文字是否相等的地方也不是和上一个 Item 比较了,而是和上一行的比较,说穿了也就是 position-spanCount 的那一个比较。

在 StickyDecoration 中给一个变量 spanCount,默认为 1,当外部使用的是 GridLayoutManager 时可以传入 GridLayoutManager 的 spanCount 供我们计算 Decoration 的绘制,修改 onDrawOver() 和 getItemOffsets() 方法:

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String previousStickyHeaderName = null;
        String currentStickyHeaderName = null;
        int left = parent.getLeft();
        //Decoration 的右边位置
        int right = parent.getRight();
        //获取 RecyclerView 的 Item 数量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //判断上一个 position 粘性头部的文字与当前 position 的粘性头部文字是否相同,如果相同则跳过绘制
            int position = parent.getChildAdapterPosition(childView);
            currentStickyHeaderName = getStickyHeaderName(position);
            if (TextUtils.isEmpty(currentStickyHeaderName)) {
                continue;
            }
            if (position < mSpanCount || i < mSpanCount) {
                //Decoration 的底边位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
                //就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
                View nextChildView = parent.getChildAt(i + mSpanCount);
                String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的顶边位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //绘制文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
                continue;
            }
            previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
            if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
                //Decoration 的底边位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
                //就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
                View nextChildView = parent.getChildAt(i +  mSpanCount);
                String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的顶边位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //绘制文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
            }
        }
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //outRect 相当于 Item 的整体绘制区域,设置 left、top、right、bottom 相当于设置左上右下的内间距
        //如设置 outRect.top = 5 则相当于设置 paddingTop 为 5px。
        int position = parent.getChildAdapterPosition(view);
        String stickyHeaderName = getStickyHeaderName(position);
        if (TextUtils.isEmpty(stickyHeaderName)) {
            return;
        }
        if (position < mSpanCount) {
            outRect.top = mHeight;
            return;
        }
        String previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
        if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
            outRect.top = mHeight;
        }
    }

OK,这样就可以适用于 GridLayoutManager 了,MainActivity 中修改一下测试代码:

        rvTest.setLayoutManager(new GridLayoutManager(this, 4));
        rvTest.addItemDecoration(new StickyDecoration(4) {
            @Override
            public String getStickyHeaderName(int position) {
                return list.get(position);
            }
        });

效果如下:

五、遗留问题

这样其实还有一个问题,在 GridLayoutManager 中上面的测试数据都是每一组数据的个数都是 spanCount 的整数倍,如果不是整数倍的时候就会出现分隔线错乱,这个情况暂时还没有想到好的办法解决,如有好的思路还望不吝赐教。

六、github 传送门

完整 Demo

猜你喜欢

转载自blog.csdn.net/zgcqflqinhao/article/details/83013923