RecyclerView 中 ItemDecoration 使用细节及逻辑分析 (粘性头部)

这里写个简单的例子,给 RecyclerView 添加一个红色的分割线。上一章中,我们写了这么个简单的例子,现在就简单的分析一下。

public class ColorDividerItemDecoration extends RecyclerView.ItemDecoration {

    final static String TAG = "ColorDividerItem";

    private float mDividerHeight;

    private Paint mPaint;

    public ColorDividerItemDecoration() {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
//       第一个ItemView不需要在上面绘制分割线
        if (parent.getChildAdapterPosition(view) != 0){
            //这里直接硬编码为1px
            outRect.top = 1;
            mDividerHeight = 1;
        }
    }

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

        int childCount = parent.getChildCount();
        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);
            int index = parent.getChildAdapterPosition(view);
            //第一个ItemView不需要绘制
            if ( index == 0 ) {
                continue;
            }
            float dividerTop = view.getTop() - mDividerHeight;
            float dividerLeft = parent.getPaddingLeft();
            float dividerBottom = view.getTop();
            float dividerRight = parent.getWidth() - parent.getPaddingRight();
            c.drawRect(dividerLeft,dividerTop,dividerRight,dividerBottom,mPaint);
        }
    }

}

我们使用 LinearLayout 布局时,属性设置为垂直,往里面塞了三个view,那么三个view会依次从上到下排布;如果是 FrameLayout 布局,没有设置 gravity 属性,那么三个view会按顺序依次叠加在一起;使用 ListView 时,我们能看到 item 一条接一条,紧密挨在一起;使用 RecyclerView 时,设置垂直属性,效果与 ListView 雷同。刚开始时,这样的效果给我造成了一个错觉,一直觉得 item 在复用,item 之间就应该是紧挨在一起,没有空隙的,后来随着接触的东西多了,见识广了,才知道当初的无知有多么的可笑。ViewGroup 中的子view的大小与布局,都在父控件的掌控之中,换句话说,父控件决定子控件的位置,所以只要我们喜欢,完全可以让item之间隔个100像素,左边都不与父控件左边对齐。上一章中,我们看到了 RecyclerView 在布局item时,把从 ItemDecoration 中 getItemOffsets() 方法中获取的数据都用上了,这就导致 layout 的时候,如果 getItemOffsets() 中返回有值,那么子view也就是 item 之间就有间隙了。这个时候,item 会绘制出自己的布局,间隙就显示父控件的背景颜色,就像上面 ColorDividerItemDecoration 中,如果我们把 onDraw() 方法中的代码移除,同时在 xml 布局中把 RecyclerView 的背景色设置为红色,此时也能达到 item 之间添加红线的效果。


我们不通过xml这种方法,而是在 ItemDecoration 把它给画出来,并且还能实现更复杂的画面。我们看看 onDraw() 方法中,首先是 int childCount = parent.getChildCount() 获取到 RecyclerView 的子view 的个数,注意假如 childCount 为8,则说明 RecyclerView 中有8个子view,并不代表 Adapter 中只有8个item,因为子 view 是可以复用的,所以我们根据 View view = parent.getChildAt(i) 这里的 i 与 Adapter 中的 position 并不是一回事,因此才有了 getChildAdapterPosition(view) 方法,通过反查来查出了 position 的值,即当前 item 在 RecyclerView 中的位置。item之间的红色间隔线,是从0开始,倒数第二个结束,最后一个item是没有间隔线,我们有两种绘制方法,一是在item的底部绘制,最后一条不绘制;二是在item的顶部绘制,第0个item不绘制。这里我们用的是方法二,所以当 index 为 0 时,跳过当前。view.getTop() 获取的是 item 距离 RecyclerView 顶部的距离,暂时可以认为随着 RecyclerView 上下滑动,每个item的 top 值是不停变化的,由于我们要绘制间隔线,所以item的top就是间隔线的bottom,那间隔线的top就是在底部的基础上向上移动 mDividerHeight 的距离,由于屏幕左上角为坐标原点,所以间隔线的top就是在bottom的基础上减去 mDividerHeight;绘制间隔线,没能超过父容器的约束的距离,比如RecyclerView 设置了 padding 值,我们也要遵守,所以 left 就要从父容器的 paddingLeft 开始,right 则要父容器的宽度减去 paddingRight。计算出间隔线的四个顶点的坐标后,用 Canvas 和 Paint 把它画出来,就像在自定义view控件中一样,就是这样。


如果我们想实现微信中通讯录模块,一个列表按照姓氏分类,拼音的首字母现在在同类型的最上端类似的功能,我们就可以像做分割线一样来实现它,还有一种思路,就是在item中显示拼音首字母,根据名字中计算哪个需要隐藏,哪个需要显示。我们先说说用item实现,再说用 ItemDecoration 实现。

    public List getData() {
        List list = new ArrayList<>();

        for (int index = 0; index < 50; index++) {
            if (index < 15) {
                list.add(new Bean(
                        "粘性文本1", "name" + index));
            } else if (index < 25) {
                list.add(new Bean(
                        "粘性文本2", "name" + index));
            } else if (index < 35) {
                list.add(new Bean(
                        "粘性文本3", "name" + index));
            } else {
                list.add(new Bean(
                        "粘性文本4", "name" + index));
            }
        }
        return list;
    }


    class StickyAdapter extends RecyclerView.Adapter {
        //第一个吸顶
        static final int FIRST_STICKY_VIEW = 1;
        //别的吸顶
        static final int OTHER_STICKY_VIEW = 2;
        //正常View
        static final int NORMAL_VIEW = 3;

        private final LayoutInflater mInflate;
        private final List datas;

        StickyAdapter(Context context, List datas){
            mInflate = LayoutInflater.from(context);
            this.datas = datas;
        }

        @Override
        public RecyclerViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {

            View inflate = mInflate.inflate(R.layout.item_ui, parent, false);
            return new RecyclerViewHolder(inflate);
        }

        @Override
        public void onBindViewHolder(RecyclerViewHolder holder, int position) {
            Bean stickyBean = datas.get(position);
            holder.tvName.setText(stickyBean.name);

            if (position == 0) {
                holder.tvStickyHeader.setVisibility(View.VISIBLE);
                holder.tvStickyHeader.setText(stickyBean.sticky);
                holder.itemView.setTag(FIRST_STICKY_VIEW);
            } else {
                if (!TextUtils.equals(stickyBean.sticky, datas.get(position - 1).sticky)) {
                    holder.tvStickyHeader.setVisibility(View.VISIBLE);
                    holder.tvStickyHeader.setText(stickyBean.sticky);
                    holder.itemView.setTag(OTHER_STICKY_VIEW);
                } else {
                    holder.tvStickyHeader.setVisibility(View.GONE);
                    holder.itemView.setTag(NORMAL_VIEW);
                }
            }
            //通过此处设置ContentDescription,作为内容描述,可以通过getContentDescription取出,功效跟setTag差不多。
            holder.itemView.setContentDescription(stickyBean.sticky);
        }

        @Override
        public int getItemCount() {
            return datas == null ? 0 : datas.size();
        }

        public class RecyclerViewHolder extends RecyclerView.ViewHolder{
            TextView tvStickyHeader;
            RelativeLayout rlContentWrapper;
            TextView tvName;
            RecyclerViewHolder(View itemView) {
                super(itemView);
                tvStickyHeader = (TextView) itemView.findViewById(R.id.tv_sticky_header_view);
                rlContentWrapper = (RelativeLayout) itemView.findViewById(R.id.rl_content_wrapper);
                tvName = (TextView) itemView.findViewById(R.id.name);
            }
        }
    }

    public class Bean {

        public String name;
        public String sticky;

        public Bean(String sticky, String name) {
            this.sticky = sticky;
            this.name = name;
        }
    }

xml 布局


    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/color_10"
    >

            android:id="@+id/feed_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/white"
        android:scrollbars="vertical" />

            android:id="@+id/tv_suspensionbar"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="#EFFAE7"
        android:gravity="center"
        android:text="@string/hello_blank_fragment" />

注意看 onBindViewHolder() 中,我们根据 sticky 值不一样,说明到了不同的文案的交错点,所以要显示出头部文案。如果此时要加一个粘性头部,就像微信ios版通讯录的效果,此时添加滑动监听机制

Activity 中布局


    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/color_10"
    >

            android:id="@+id/feed_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/white"
        android:scrollbars="vertical" />

            android:id="@+id/tv_suspensionbar"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="#EFFAE7"
        android:gravity="center"
        android:text="@string/hello_blank_fragment" />

mSuspensionBar 是 @+id/tv_suspensionbar;  mRecyclerView 是 @+id/feed_list

        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                View stickview = recyclerView.findChildViewUnder(0, 0);
                if (stickview != null && stickview.getContentDescription() != null) {
                    if (!TextUtils.equals(mSuspensionBar.getText(), stickview.getContentDescription())) {
                        mSuspensionBar.setText(stickview.getContentDescription());
                    }
                }
                View transInfoView = recyclerView.findChildViewUnder(0, mSuspensionBar.getHeight() + 1);
                if (transInfoView.getTag() != null) {
                    int transViewStatus = (int) transInfoView.getTag();
                    int top = transInfoView.getTop();
                    if (transViewStatus == StickyAdapter.OTHER_STICKY_VIEW) {
                        if (top > 0) {
                            int dealtY = top - mSuspensionBar.getMeasuredHeight();
                            mSuspensionBar.setTranslationY(dealtY);
                        } else {
                            mSuspensionBar.setTranslationY(0);
                        }
                    } else {
                        mSuspensionBar.setTranslationY(0);
                    }
                }
            }
        });

View stickview = recyclerView.findChildViewUnder(0, 0) 找到的最上面的item, View transInfoView = recyclerView.findChildViewUnder(0, mSuspensionBar.getHeight() + 1)  找到的是在 mSuspensionBar 这个控件下面1像素的地方所在的item,所以就有了下面的逻辑:一、如果列表position在15之前,transViewStatus 为 FIRST_STICKY_VIEW 或 NORMAL_VIEW,此时 mSuspensionBar 待在原始位置即可;二、position到了15,transInfoView 对应的 position 为 15 时,说明此时两个头部布局要接壤了,此时需要下面的把上面的给顶上去,怎么顶?计算距离,让 mSuspensionBar 随着RecyclerView列表的滑动向上移动,所以有了 int top = transInfoView.getTop(),此时 int dealtY = top - mSuspensionBar.getMeasuredHeight()算出了位移;如果 top 小于0,说明该item已经向上滑出了 RecyclerView 的范围,所以就不用管了,把 mSuspensionBar 复原;三、接着又是类似一的逻辑,然后是二,就这样循环到结束。


上述如果使用 ItemDecoration 怎么实现呢?换一种思路和对象集合,我们先把对象在集合中分好组,添加属性来标识位置,

    class GroupInfo {
        private String content;
        private String mTitle;
        private boolean isFirstViewInGroup;
        private boolean isLastViewInGroup;


        public GroupInfo(String content, String title) {
            this.content = content;
            this.mTitle = title;
        }

        public String getContent() {
            return content;
        }

        public String getTitle() {
            return mTitle;
        }

        public void setFirstViewInGroup(boolean firstViewInGroup) {
            isFirstViewInGroup = firstViewInGroup;
        }

        public void setLastViewInGroup(boolean lastViewInGroup) {
            isLastViewInGroup = lastViewInGroup;
        }

        public boolean isFirstViewInGroup () {
            return isFirstViewInGroup;
        }

        public boolean isLastViewInGroup () {
            return isLastViewInGroup;
        }

    }

Activity 中代码 
    List list = getData();
    mRecyclerView.setAdapter(new TestAdapter(list));
    mRecyclerView.addItemDecoration(new SectionDecoration(context, list));

适配器
    class TestAdapter extends RecyclerView.Adapter {
        private final List datas;


        TestAdapter(List datas){
            this.datas = datas;
        }

        @Override
        public TestHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            TextView tv = new TextView(parent.getContext());
            tv.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 120));
            tv.setGravity(Gravity.CENTER_VERTICAL);
            TestHolder holder = new TestHolder(tv);
            return holder;
        }

        @Override
        public void onBindViewHolder(TestHolder holder, int position) {
            if (datas != null && datas.size() > 0 ) {
                String text = datas.get(position).getContent();
                holder.tvName.setText(text);
            }
        }

        @Override
        public int getItemCount() {
            return datas == null ? 0 : datas.size();
        }

        public class TestHolder extends RecyclerView.ViewHolder{
            TextView tvName;
            TestHolder(View itemView) {
                super(itemView);
                tvName = (TextView) itemView;
            }
        }
    }

重点来了,

    class SectionDecoration extends RecyclerView.ItemDecoration {

        private List mList;
        private int mHeaderHeight;
        private int mDividerHeight;

        //用来绘制Header上的文字
        private TextPaint mTextPaint;
        private Paint mPaint;
        private float mTextSize;
        private Paint.FontMetrics mFontMetrics;

        public SectionDecoration(Context context, List list) {
            this.mList = list;
            mDividerHeight = context.getResources().getDimensionPixelOffset(R.dimen.header_divider_height);
            mHeaderHeight = context.getResources().getDimensionPixelOffset(R.dimen.header_height);
            mTextSize = context.getResources().getDimensionPixelOffset(R.dimen.header_textsize);

            mHeaderHeight = (int) Math.max(mHeaderHeight,mTextSize);

            mTextPaint = new TextPaint();
            mTextPaint.setColor(Color.BLACK);
            mTextPaint.setTextSize(mTextSize);
            mFontMetrics = mTextPaint.getFontMetrics();

            mPaint = new Paint();
            mPaint.setAntiAlias(true);
            mPaint.setColor(Color.YELLOW);

        }

        @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 ( mList != null ) {
                GroupInfo groupInfo = mList.get(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 ( mList != null ) {
                    GroupInfo groupinfo = mList.get(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;
                        float titleY =  bottom - mFontMetrics.descent;
                        //绘制Title
                        c.drawText(groupinfo.getTitle(),titleX,titleY,mTextPaint);
                    }
                }
            }
        }

    }

这样,我们在 getItemOffsets() 中计算出item之间的间隙,然后在 onDraw() 中把头部画出来。如果想画出粘性头部布局呢?我们此时重写 onDrawOver() 方法,

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

            View view = parent.getChildAt(0);
            int index = parent.getChildAdapterPosition(view);
            if (mList != null) {
                GroupInfo groupinfo = mList.get(index);
                int left = parent.getPaddingLeft();
                int right = parent.getWidth() - parent.getPaddingRight();
                int top = parent.getPaddingTop();
                if (groupinfo.isLastViewInGroup()) {
                    int suggestTop = view.getBottom() - mHeaderHeight;
                    if (suggestTop < top) {
                        top = suggestTop;
                    }
                }
                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);
        }

这个里面的原理与方法一种的滑动监听的原理相似,这里是直接获取RecyclerView中首个item的view,然后获取到位置position。注意,此时不同组的item是有间隔的,正常情况下,我们绘制最上层的粘性头部时,把它固定在顶部即可,即 top 为  parent.getPaddingTop(),bottom 为 top + mHeaderHeight,然后通过 Canvas 把它画出来。最关键的一点就是两个粘性头部接壤了,此时需要把上面一个顶上,如果RecyclerView中首个item的底部到父容器的距离小于了 mHeaderHeight,说明两个头部接壤了,同理,算出它们的距离,然后算出 top 的值,理论上和方法一同样的原理。但此时这里需要注意一点,就是 Adapter 中的item的高度,要大于粘性头部布局的高度。
 

你可能感兴趣的:(Android,知识)