Android开发之分组列表悬浮顶部栏(吸顶效果)

之前写过一篇文章《Android开发之仿微博详情页(滑动固定顶部栏效果)》,当时采用的解决方案是用一个ScrollView去包裹内容布局,通过监听滑动状态,在适当的时候,移入/移出所要固定的布局,这样虽然可以达到想要的视觉效果,但这种实现方式并不优雅,比如被包裹内容布局中带有滑动特性的View(ListView,RecyclerView等),这样做就需要我们额外的去处理滑动冲突,而且这种方式的包裹会使得它们的缓存机制失效,为了一个视觉效果去牺牲它们最具灵动的特性的一面,我不提倡这种做法。

这里介绍另外一种解决方案,可以更加优雅的实现这种视觉效果,而且不会有滑动冲突,也不需要牺牲缓存机制,在文章末尾会给出思路,需要你先看完文章哈~

先来看下今天要实现的效果图:


效果图

要实现这个效果很简单,只需要一个RecyclerView就可以实现了,不需要多余的布局控件,当然网上也有另外的一些实现方式,比如利用帧布局或者相对布局在RecyclerView上面再盖上要固定的ViewGroup,通过滑动去判断是否需要动态的将固定布局移入/移出,其实和上面提到的文章实现思路一样,这样做,很明显的会有几个缺点,比如增加了布局的深度或者在业务发生变化的时候需要同时去改动至少两处代码等,如果中间还耦合着一些业务操作,出错几率也会对应的增加。

列表的组成

这是一个带有分组的列表,我们可以把它拆分成3部分,头部数据+列表数据+分割线

列表数据:
RecyclerView的基本使用,这里我就不再重复阐述了。

分割线:
要实现分割线,如果是在以前的ListView,我们通过设置divider,dividerHeigh等属性就可以很轻松的达到目的,或者直接在布局文件中画上一个带有高度和背景色的View来实现。到了RecyclerView这里,我们可不再需要这样做了,官方给我们提供了一个强大的装饰器ItemDecoration,它可以帮助我们实现分割线的功能,但它可不仅仅只能实现分割线,一会下文会介绍。

头部数据:
要绘制这个头部,以前我们在ListView里,可能有些人会这样做,让每个Item布局都带上这个头部布局,然后根据是否是每组数据的第一个来动态控制头部布局是显示还是隐藏,当然这样做也可以实现我们想要的效果,但却会多余的去耗费一定的性能,因为明明每组数据只需要绘制一个头部,而你却每个Item都去绘制,最终每组却又只需要一个,所以这里我们依然可以采用官方提供的ItemDecoration来解决这个问题。

什么是ItemDecoration?

说了这么多ItemDecoration,我们来看下官方给出的介绍吧:

An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.

大概意思是ItemDecoration允许给特定的item视图添加特性的绘制以及布局间隔。它可以用来实现item之间的分割线,高亮,分组边界等。

public class ItemDecoration extends RecyclerView.ItemDecoration {

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

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

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

ItemDecoration是RecyclerView下的抽象方法,我们要使用它只需要继承它,并实现对应的方法即可,然后让RecyclerView去调用它

mRecyclerView.addItemDecoration(new ItemDecoration());

具体来看下这3个方法,顺便来一张图帮助理解:


方法介绍图

getItemOffsets:它是用来给Item设置间距的,可以这样理解在Item外还有一层布局,而这个方法是用来设置布局的Padding。

onDraw:它的绘制和Item之下,它绘制的东西会在Item的下面。

onDrawOver:它的绘制在Item之上,它绘制的东西会覆盖在Item的上面。

事实上并不是真的有层次之分,这里只是为了方便理解,最根本的原因是因为它们方法的调用方法的顺序,又因为都作用于同一个Canvas上,才出现这种覆盖的层次的效果。

知道了这些方法的作用后,我们配合RecyclerView给我们的一些API方法,要做其它事情容易多了,随意举2个例子:
1、如果我们想要绘制分割线,只需要先调用getItemOffsets,让Item空出一定的间隙,然后再调用onDraw在这个间隙上填充颜色即可。

2、我们经常会遇到一些节假日活动的需求,需要在列表上的边角处标记“活动”,“特价”等特殊符号,这时候我们只需要调用onDrawOver在Item上绘制即可。

言归正传,我们来看下今天我们要实现的效果,带有吸顶效果的分组列表,上文已经提及了可以分为3部分来看,头部数据+列表数据+分割线,其中列表数据是最基础的RecyclerView的使用,这个我们就不说了,我们来看下其它2部分。

为了测试方便,这里我建立了一些本地数据:

数据实体:

public class Bean {

    private String text;
    private String groupName;

    public Bean(String text, String groupName) {
        this.text = text;
        this.groupName = groupName;
    }

    public String getText() {
        return text;
    }

    public void setText(String text) {
        this.text = text;
    }

    public String getGroupName() {
        return groupName;
    }

    public void setGroupName(String groupName) {
        this.groupName = groupName;
    }
}

数据集合:

       List<Bean> beanList = new ArrayList<>();
        for (int i = 0; i < 6; i++) {
            beanList.add(new Bean(String.format("第一组%d号", i + 1), "第一组"));
        }
        for (int i = 0; i < 6; i++) {
            beanList.add(new Bean(String.format("第二组%d号", i + 1), "第二组"));
        }
        for (int i = 0; i < 6; i++) {
            beanList.add(new Bean(String.format("第三组%d号", i + 1), "第三组"));
        }
        for (int i = 0; i < 6; i++) {
            beanList.add(new Bean(String.format("第四组%d号", i + 1), "第四组"));
        }

分割线的实现:

首先我们需要在getItemOffsets方法中让Item间空出空隙:

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        outRect.bottom = 1;
    }

然后我们在onDraw方法中去对这个空隙绘制颜色(绘制一个带有颜色矩形)

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        int count = parent.getChildCount();
        for (int i = 0; i < count; i++) {
            View view = parent.getChildAt(i);
            c.drawRect(0, view.getBottom(), parent.getWidth(), view.getBottom() + 1, mLinePaint);
        }
    }

这里需要注意的一个地方是RecyclerView的getChildCount方法只会拿到当前可视区域的Item项,然后我们对Item进行遍历绘制矩形(分割线)。

就这么简单,我们的分割线已经画好了,看下实现效果:

分割线实现效果

头部布局的实现:

头布局的实现和分割线是一样的,它一样需要让Item空出空隙,然后填充颜色,只是空出的空隙距离和颜色不一样罢了,所以我们需要知道什么时候空出的分割线的空隙,什么时候空出头部布局的空隙,这个就和我们数据源有关系了,我们写一个方法来判断当前position所对应的Item项是不是每组数据的第一项:

    /**
     * 判断position对应的Item是否是组的第一项
     *
     * @param position
     * @return
     */
    public boolean isItemHeader(int position) {
        if (position == 0) {
            return true;
        } else {
            String lastGroupName = mList.get(position - 1).getGroupName();
            String currentGroupName = mList.get(position).getGroupName();
            //判断上一个数据的组别和下一个数据的组别是否一致,如果不一致则是不同组,也就是为第一项(头部)
            if (lastGroupName.equals(currentGroupName)) {
                return false;
            } else {
                return true;
            }
        }
    }

然后来看下getItemOffsets方法,如果是每组第一项我们空出头部布局的高度,如果不是,我们则空出分割线的高度:

    /**
     * 设置Item的间距
     *
     * @param outRect
     * @param view
     * @param parent
     * @param state
     */
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        if (parent.getAdapter() instanceof RecyclerViewAdapter) {
            RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
            int position = parent.getChildLayoutPosition(view);
            boolean isHeader = adapter.isItemHeader(position);
            if (isHeader) {
                outRect.top = mItemHeaderHeight;
            } else {
                outRect.top = 1;
            }
        }
    }

然后一样的在onDraw方法里绘制背景颜色和文字即可,关于绘制的知识点这边就不说了,属于基础的自定义View需要掌握的知识:

    /**
     * 绘制Item的分割线和组头
     *
     * @param c
     * @param parent
     * @param state
     */
    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (parent.getAdapter() instanceof RecyclerViewAdapter) {
            RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
            int count = parent.getChildCount();
            for (int i = 0; i < count; i++) {
                View view = parent.getChildAt(i);
                int position = parent.getChildLayoutPosition(view);
                boolean isHeader = adapter.isItemHeader(position);
                if (isHeader) {
                    c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), view.getTop(), mItemHeaderPaint);
                    mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
                    c.drawText(adapter.getGroupName(position), mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
                } else {
                    c.drawRect(0, view.getTop() - 1, parent.getWidth(), view.getTop(), mLinePaint);
                }
            }
        }
    }

此时我们的头部布局也画好了,看下实现效果:


分组布局实现效果

吸顶效果的实现:

关于吸顶的效果,其实只要我们理清楚它的流程就会发现其实并不复杂,50行代码不到就可以把它完成。
首先我们需要知道以下几点:
1、当我们滑动列表的时候,第一个头布局是固定在我们的列表顶部的。
2、通过滑动列表,当下一个头布局和第一个头布局相碰的时候,会把第一个布局“顶出去”,当第一个头布局完全被“顶出去”后,第二个头布局并替代了第一个头布局固定在列表顶部。
知道了上面2点后,有时候我们所看到的视觉效果会把我们带入一个思维误区,比如这个吸顶效果,有的朋友可能会这样去考虑,是不是需要在滑动的时候,动态的去改变getItemOffsets的空隙大小和在onDraw的绘制高度。如果真的这样去做,你会发现实现起来十分困难。

我们换一种思维,既然顶部的布局是固定不动的,是不是可以利用onDrawOver在RecyclerView的上绘制一个和头部布局一模一样的布局呢,让它覆盖住了第一个头布局,在视觉上我们是不会有所察觉的,然后当列表滑动的时候,其实“原来的头布局”早已经滑动走了,留下的其实是我们绘制的固定布局而已,等到下一个头部布局“碰头”的时候,让它随着滑动的速度慢慢改变布局的高度,当布局高度为0的时候,也就是被顶出去的时候,然后再让高度改变回来,覆盖住第二个布局,然后不断重复以上步骤。

可能说的有点抽象,我们来一张图看一下,这次我故意把头布局颜色改成红色,不清楚的朋友多看几次就可以理解了。


吸顶效果实现

看下具体代码,我们先通过findFirstVisibleItemPosition拿到第一个可见的Item的position,那我们就可以根据position+1可以知道下一个Item是否是另一组的头布局(判断组名是否发生了变化),如果不是,我们的依旧绘制固定布局即可,如果是,我们根据第一个可见Item的getBottom值的变小,慢慢的改变固定布局的高度,直到被“顶出去”。

    /**
     * 绘制Item的顶部布局(吸顶效果)
     *
     * @param c
     * @param parent
     * @param state
     */
    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (parent.getAdapter() instanceof RecyclerViewAdapter) {
            RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
            int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
            View view = parent.findViewHolderForAdapterPosition(position).itemView;
            boolean isHeader = adapter.isItemHeader(position + 1);
            if (isHeader) {
                int bottom = Math.min(mItemHeaderHeight, view.getBottom());
                c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), bottom, mItemHeaderPaint);
                mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
                c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint);
            } else {
                c.drawRect(0, 0, parent.getWidth(), mItemHeaderHeight, mItemHeaderPaint);
                mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
                c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
            }
        }

    }

吸顶效果就这么简单的完成了,其实关键就在于onDrawOver这个方法。
这里附上完整的ItemDecoration代码(避免太多参数增加代码阅读难度,上面的讲解没有考虑RecyclerView存在Padding的情况,这边已给出补充):

package com.lcw.view.stickheaderview;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;

/**
 * 自定义装饰器(实现分组+吸顶效果)
 * Create by: chenWei.li
 * Date: 2018/11/2
 * Time: 上午1:14
 * Email: lichenwei.me@foxmail.com
 */
public class StickHeaderDecoration extends RecyclerView.ItemDecoration {

    //头部的高
    private int mItemHeaderHeight;
    private int mTextPaddingLeft;

    //画笔,绘制头部和分割线
    private Paint mItemHeaderPaint;
    private Paint mTextPaint;
    private Paint mLinePaint;

    private Rect mTextRect;


    public StickHeaderDecoration(Context context) {
        mItemHeaderHeight = dp2px(context, 40);
        mTextPaddingLeft = dp2px(context, 6);

        mTextRect = new Rect();

        mItemHeaderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mItemHeaderPaint.setColor(Color.BLUE);

        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setTextSize(46);
        mTextPaint.setColor(Color.WHITE);

        mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mLinePaint.setColor(Color.GRAY);

    }

    /**
     * 绘制Item的分割线和组头
     *
     * @param c
     * @param parent
     * @param state
     */
    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (parent.getAdapter() instanceof RecyclerViewAdapter) {
            RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
            int count = parent.getChildCount();//获取可见范围内Item的总数
            for (int i = 0; i < count; i++) {
                View view = parent.getChildAt(i);
                int position = parent.getChildLayoutPosition(view);
                boolean isHeader = adapter.isItemHeader(position);
                int left = parent.getPaddingLeft();
                int right = parent.getWidth() - parent.getPaddingRight();
                if (isHeader) {
                    c.drawRect(left, view.getTop() - mItemHeaderHeight, right, view.getTop(), mItemHeaderPaint);
                    mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
                    c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
                } else {
                    c.drawRect(left, view.getTop() - 1, right, view.getTop(), mLinePaint);
                }
            }
        }
    }


    /**
     * 绘制Item的顶部布局(吸顶效果)
     *
     * @param c
     * @param parent
     * @param state
     */
    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (parent.getAdapter() instanceof RecyclerViewAdapter) {
            RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
            int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
            View view = parent.findViewHolderForAdapterPosition(position).itemView;
            boolean isHeader = adapter.isItemHeader(position + 1);
            int top = parent.getPaddingTop();
            int left = parent.getPaddingLeft();
            int right = parent.getWidth() - parent.getPaddingRight();
            if (isHeader) {
                int bottom = Math.min(mItemHeaderHeight, view.getBottom());
                c.drawRect(left, top + view.getTop() - mItemHeaderHeight, right, top + bottom, mItemHeaderPaint);
                mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
                c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint);
            } else {
                c.drawRect(left, top, right, top + mItemHeaderHeight, mItemHeaderPaint);
                mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
                c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
            }
            c.save();
        }

    }

    /**
     * 设置Item的间距
     *
     * @param outRect
     * @param view
     * @param parent
     * @param state
     */
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        if (parent.getAdapter() instanceof RecyclerViewAdapter) {
            RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
            int position = parent.getChildLayoutPosition(view);
            boolean isHeader = adapter.isItemHeader(position);
            if (isHeader) {
                outRect.top = mItemHeaderHeight;
            } else {
                outRect.top = 1;
            }
        }
    }


    /**
     * dp转换成px
     */
    private int dp2px(Context context, float dpValue) {
        float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f);
    }
}

额外补充:

对于文章开头说的那个仿微博固定顶部栏的效果:


仿微博固定顶部栏

我相信可以理解这篇文章的朋友应该都可以很轻松的用一个RecyclerView做出来了,简单的说下思路,首先是微博内容,我们把它当成是RecyclerView的HeaderView即可,也是Item的一项,然后下面的评论列表就是基础的RecyclerView使用了,然后中间固定的布局,就可以这篇文章所讲的ItemDecoration里的getItemOffsets和onDrawOver来配合实现了。

好了,到这里内容就结束了,有什么疑问,欢迎大家在评论给我留言~

源码下载:

这里附上源码地址(欢迎Star,欢迎Fork):StickHeaderView

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容