Android RecyclerView之粘性头部+点击事件

Android RecyclerView之粘性头部+点击事件_第1张图片

实现上图列表的粘性头部功能一般通过在布局页面额外写粘性头部View,然后通过监听列表的滑动来控制显示隐藏粘性头部View。而如果列表使用RecyclerView实现,那么就能通过自定义ItemDecoration达到目的。下面先简单介绍ItemDecoration

ItemDecoration

ItemDecorationRecyclerView的静态内部类,它包含三个方法:

  • getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
  • onDraw(Canvas c, RecyclerView parent, State state)
  • onDrawOver(Canvas c, RecyclerView parent, State state)

通过重写上述三个方法,RecyclerView可以实现添加分隔线,每个item添加标签/蒙层,分组粘性头部等其他更高级的功能。
#######getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)

这个方法可以通过给outRect的left、top、right、bottom,实现类似padding的效果。如下图所示:


Android RecyclerView之粘性头部+点击事件_第2张图片

#######onDraw(Canvas c, RecyclerView parent, State state)

这个方法可以实现类似绘制背景的效果,绘制的东西是显示在item的下层,一般配合getItemOffsets()方法使用。通过getItemOffsets()方法设置outRect,如果绘制在outRect设置的范围内,可见;超出设置的范围,由于是绘制在item的下面,所以并不可见。

#######onDrawOver(Canvas c, RecyclerView parent, State state)

这个方法是绘制在内容的上面,绘制区域不受限制

调用顺序
Android RecyclerView之粘性头部+点击事件_第3张图片

由上图可以得出以下几条信息:

  1. 上面上个方法的调用顺序依次为:getItemOffsets()onDraw()onDrawOver()
  2. getItemOffsets()针对每一个item,它调用的次数即为屏幕上绘制item的个数;
  3. onDraw()onDrawOver()方法针对 RecyclerView本身,初始化只会调用一次;

Android RecyclerView之粘性头部+点击事件_第4张图片

当滑动列表至第10条的过程中,可以看到 onDraw()onDrawOver()两个方法在反复的调用。我们先看下这两个方法在 RecyclerView中调用位置,从下面也可以看得出来decoration 的 onDraw(),child view 的 onDraw(),decoration 的 onDrawOver(),这三者是依次发生的。

 @Override
    public void draw(Canvas c) {
        super.draw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDrawOver(c, this, mState);
        }
      //以下代码省略
    }

    @Override
    public void onDraw(Canvas c) {
        super.onDraw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDraw(c, this, mState);
        }
    }

RecyclerView的滚动分为两个阶段,手指在屏幕上列表的scroll和手指离开屏幕列表的fling,这两个阶段最终都会执行下面这段代码:

   if (!mItemDecorations.isEmpty()) {
        invalidate();
   }

当绘制的ItemDecoration数量不为空时,RecyclerView会不断的重绘,这样就会调用RecyclerViewonDraw()onDrawOver()方法,因此ItemDecoration的这两个方法就在不断的调用。关于RecyclerView的滑动源码分析具体可参看 RecyclerView剖析

StickyHeader

关于开头gif图片的实现如下:

  • 列表数据有50条,每5条为一组,adapter的实现
public class RecyclerViewAdapter extends RecyclerView.Adapter {
    private Context mContext;
    private List datas;

    public RecyclerViewAdapter(Context context) {
        this.mContext = context;
    }

    public void setData(List datas) {
        this.datas = datas;
    }



    @Override
    public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new MyViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item, parent, false));
    }

    @Override
    public void onBindViewHolder(MyViewHolder holder, int position) {
        holder.populate(datas.get(position));
    }

    @Override
    public int getItemCount() {
        return datas.size();
    }

  //是否存在分组的头部,每5个一组
    public boolean hasHeader(int pos) {
        if (pos % 5 == 0) {
            return true;
        } else {
            return false;
        }
    }

     //采用xml方式来实现ItemDecoration,可以更方便的定制ItemDecoration的内容,生成head布局
    public HeaderHolder onCreateHeaderViewHolder(ViewGroup parent) {
        return new HeaderHolder(LayoutInflater.from(mContext).inflate(R.layout.item_decoration, parent, false));
    }

    //绑定head的数据  
    public void onBindHeaderViewHolder(HeaderHolder viewholder, int position) {
        viewholder.group.setText("分组" + getHeaderId(position));
        viewholder.clickgroup.setText("点击分组" + getHeaderId(position));
    }
    
    //获取每条数据属于哪一分组
    public int getHeaderId(int position) {
        return position / 5;
    }


    public  class HeaderHolder extends RecyclerView.ViewHolder {
         TextView group;
         TextView clickgroup;

        public HeaderHolder(View itemView) {
            super(itemView);
            group = (TextView) itemView.findViewById(R.id.tv);
            clickgroup = (TextView) itemView.findViewById(R.id.tv1);
            clickgroup.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                   Toast.makeText(mContext,clickgroup.getText().toString(),0).show();
                }
            });
        }
    }

    public class MyViewHolder extends RecyclerView.ViewHolder {
        TextView tv_item_layout;
        String str;

        public MyViewHolder(View itemView) {
            super(itemView);
            tv_item_layout = (TextView) itemView.findViewById(R.id.tv_item_layout);
            tv_item_layout.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Toast.makeText(mContext,str,0).show();
                }
            });
        }

        public void populate(String str) {
            tv_item_layout.setText(str);
            this.str = str;
        }
    }
}
  • getItemOffsets()方法实现
   @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //得到该view在列表中的位置
        int position = parent.getChildAdapterPosition(view);
        int headerHeight = 0;
        //判断这个位置是否有分组的头部
        if (position != RecyclerView.NO_POSITION && hasHeader(position)) {
            //获取到header所需要的高度
            View header = getHeader(parent, position);
            headerHeight = header.getHeight();
        }
        outRect.set(0, headerHeight, 0, 0);
    }

此方法的目的很简单,就是判断当前加载的item是否需要header,需要就获取header高度,并设置给outRect。然后是判断是否需要header的方法hasHeader(position),调用adapter的hasHeader(position)方法,每组的第一个添加头部。

/**
     * 判断是否有header
     *
     * @param position
     * @return
     */
    private boolean hasHeader(int position) {
        return mAdapter.hasHeader(position);
    }

获取头部高度的方法:

 /**
     * 获得自定义的Header
     *
     * @param parent
     * @param position
     * @return
     */
    public View getHeader(RecyclerView parent, int position) {
        //根据位置获取每一组的头部id
        final int headerId = mAdapter.getHeaderId(position);
        //通过头部id,从保存的头部view数组中获取改组的头部view
        View header = mHeaderViews.get(headerId);
        //如果为空,就通过adapert创建
        if (header == null) {
            //创建HeaderViewHolder
            RecyclerViewAdapter.HeaderHolder holder = mAdapter.onCreateHeaderViewHolder(parent);
            header = holder.itemView;
            //绑定数据
            mAdapter.onBindHeaderViewHolder(holder, position);
            //测量View并且layout
            int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
            int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
            //根据父View的MeasureSpec和子view自身的LayoutParams以及padding来获取子View的MeasureSpec
            int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
                    parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width);
            int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
                    parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height);
            //进行测量
            header.measure(childWidth, childHeight);
            //根据测量后的宽高放置位置
            header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight());
            //将创建好的头部view保存在数组中,避免每次重复创建
            mHeaderViews.put(headerId, header);
        }
        return header;

    }

header的创建可以参看上面adapter的代码。

  • onDrawOver()方法实现
   @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        //mHeaderRects为存放屏幕上显示的header的点击区域,每次重新绘制头部的时候清空数据
        mHeaderRects.clear();
        final int count = parent.getChildCount();
        //遍历屏幕上加载的item
        for (int layoutPos = 0; layoutPos < count; layoutPos++) {
            final View child = parent.getChildAt(layoutPos);
            //获取该item在列表数据中的位置
            final int adapterPos = parent.getChildAdapterPosition(child);
            //只有在最上面一个item或者有header的item才绘制header
            if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(adapterPos))) {
                View header = getHeader(parent, adapterPos);
                c.save();
                //获取绘制header的起始位置(left,top)
                final int left = child.getLeft();
                final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos);
                //将画布移动到绘制的位置
                c.translate(left, top);
                //绘制header
                header.draw(c);
                c.restore();
                //保存绘制的header的区域
                mHeaderRects.put(adapterPos, new Rect(left, top, left+header.getWidth(), top+header.getHeight()));
            }
        }
    }

因为onDrawOver()是针对RecyclerView的,所以需要循环绘制出来的item,在需要header的地方进行绘制。在获取绘制坐标的时候,主要在于确定纵坐标的起始位置距离顶部的大小。

Android RecyclerView之粘性头部+点击事件_第5张图片
offset表示的含义
/**
     * 计算距离顶部的高度
     *
     * @param parent
     * @param child
     * @param header
     * @param adapterPos
     * @param layoutPos
     * @return
     */
    private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
        int headerHeight = header.getHeight();
        int top = ((int) child.getY()) - headerHeight;
        //在绘制最顶部的header的时候,需要考虑处理两个分组的header交换时候的情况
        if (layoutPos == 0) {
            final int count = parent.getChildCount();
            final int currentId = mAdapter.getHeaderId(adapterPos);
            //从第二个屏幕上线上的第二个item开始遍历
            for (int i = 1; i < count; i++) {
                int nextpos = parent.getChildAdapterPosition(parent.getChildAt(i));
                if (nextpos != RecyclerView.NO_POSITION) {
                    int nextId = mAdapter.getHeaderId(nextpos);
                    //找到下一个不同组的view
                    if (currentId != nextId) {
                        final View next = parent.getChildAt(i);
                        //当不同组的第一个view距离顶部的位置减去两组header的高度,得到offset
                        final int offset = ((int) next.getY()) - (headerHeight + getHeader(parent, nextpos).getHeight());
                        //offset小于0即为两组开始交换,第一个header被挤出界面的距离
                        if (offset < 0) {
                            return offset;
                        } else {
                            break;
                        }
                    }
                }
            }
            top = Math.max(0, top);
        }
        return top;
    }

如果view不是屏幕上第一个item时,header距离顶部直接就是此view距离顶部距离减去header的高度即可,如果view是屏幕上第一个item时,然后找到和它不同组的第一个view,计算出offset的值,当这个距离大于0时,代表此view的header还全部显示出来,这时直接用上面的方式获取这个距离,当这个距离小于0时offset就是此view的header的绘制起点。

以上就是StickyHeader的全部代码,接下来是关于StickyHeader的点击事件处理

StickyHeader的点击事件

RecyclerView给我们提供了一个addOnItemTouchListener()方法用来监听每个item的点击事件,我们可以自定义一个RecyclerView.OnItemTouchListener进行相应的逻辑处理,达到header的点击目的。下面是自定义的RecyclerView.OnItemTouchListener的完整代码。

public class StickyRecyclerHeadersTouchListener implements RecyclerView.OnItemTouchListener {
    private final GestureDetector mTapDetector;
    private final RecyclerView mRecyclerView;
    private final TestDecoration mDecor;


    public StickyRecyclerHeadersTouchListener(final RecyclerView recyclerView,
                                              final TestDecoration decor) {
        mTapDetector = new GestureDetector(recyclerView.getContext(), new SingleTapDetector());
        mRecyclerView = recyclerView;
        mDecor = decor;
    }


    @Override
    public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) {
        //将事件交给GestureDetector类进行处理,通过onSingleTapUp返回的值,判断是否要拦截事件
        boolean tapDetectorResponse = this.mTapDetector.onTouchEvent(e);
        if (tapDetectorResponse) {
            // Don't return false if a single tap is detected
            return true;
        }
        //如果是点击在header区域,则拦截事件
        if (e.getAction() == MotionEvent.ACTION_DOWN) {
            int position = mDecor.findHeaderPositionUnder((int) e.getX(), (int) e.getY());
            return position != -1;
        }
        return false;
    }

    @Override
    public void onTouchEvent(RecyclerView view, MotionEvent e) { /* do nothing? */ }

    @Override
    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        // do nothing
    }

    private class SingleTapDetector extends GestureDetector.SimpleOnGestureListener {
        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            //根据点击的坐标查找是不是点击在header的区域
            int position = mDecor.findHeaderPositionUnder((int) e.getX(), (int) e.getY());
            if (position != -1) {
                //如果position不等于-1,则表示点击在header区域,然后在判断是否在header需要响应的区域
                View headerView = mDecor.getHeader(mRecyclerView, position);
                View view1 = headerView.findViewById(R.id.tv1);
                if (mDecor.findHeaderClickView(view1, (int) e.getX(), (int) e.getY())) {
                    //如果在header需要响应的区域,该区域的view模拟点击
                    view1.performClick();
                }
                mRecyclerView.playSoundEffect(SoundEffectConstants.CLICK);
                headerView.onTouchEvent(e);
                return true;
            }
            return false;
        }

        @Override
        public boolean onDoubleTap(MotionEvent e) {
            return true;
        }
    }
}

StickyRecyclerHeadersTouchListener主要思路就是通过将item的触摸事件交给GestureDetector进行处理,然后判断点击的区域是否在屏幕上的某个header上,如果在就拦截事件,交给header响应该点击事件。下面是在ItemDecrotion中判断点击坐标是否在header的区域内的方法

    public int findHeaderPositionUnder(int x, int y) {
        //遍历屏幕上header的区域,判断点击的位置是否在某个header的区域内
        for (int i = 0; i < mHeaderRects.size(); i++) {
            Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
            if (rect.contains(x, y)) {
                return mHeaderRects.keyAt(i);
            }
        }
        return -1;
    }

判断是否在header需要响应点击事件的区域

 public boolean findHeaderClickView(View view, int x, int y) {
        if (view == null) return false;
        for (int i = 0; i < mHeaderRects.size(); i++) {
            Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
            if (rect.contains(x, y)) {
                Rect vRect = new Rect();
                // 需要响应点击事件的区域在屏幕上的坐标
                vRect.set(rect.left + view.getLeft(), rect.top + view.getTop(), rect.left + view.getLeft() + view.getWidth(), rect.top + view.getTop() + view.getHeight());
                return vRect.contains(x, y);
            }
        }
        return false;
    }

关于StickyHeader的点击事件的分析就告一段落了。最后贴上自定义的ItemDecrotion的完整代码。

public class TestDecoration extends RecyclerView.ItemDecoration {
    private RecyclerViewAdapter mAdapter;
    private final SparseArray mHeaderRects = new SparseArray<>();
    private final LongSparseArray mHeaderViews = new LongSparseArray<>();

    public TestDecoration(RecyclerViewAdapter mAdapter) {
        super();
        this.mAdapter = mAdapter;
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        int position = parent.getChildAdapterPosition(view);
        int headerHeight = 0;
        //在使用adapterPosition时最好的加上这个判断
        if (position != RecyclerView.NO_POSITION && hasHeader(position)) {
            //获取到ItemDecoration所需要的高度
            View header = getHeader(parent, position);
            headerHeight = header.getHeight();
        }
        outRect.set(0, headerHeight, 0, 0);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
//        Log.e("TestDecoration", "onDraw()..........");
    }

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        //mHeaderRects为存放屏幕上显示的header的点击区域,每次重新绘制头部的时候清空数据
        mHeaderRects.clear();
        final int count = parent.getChildCount();
        //遍历屏幕上加载的item
        for (int layoutPos = 0; layoutPos < count; layoutPos++) {
            final View child = parent.getChildAt(layoutPos);
            //获取该item在列表数据中的位置
            final int adapterPos = parent.getChildAdapterPosition(child);
            //只有在最上面一个item或者有header的item才绘制header
            if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(adapterPos))) {
                View header = getHeader(parent, adapterPos);
                c.save();
                //获取绘制header的起始位置(left,top)
                final int left = child.getLeft();
                final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos);
                //将画布移动到绘制的位置
                c.translate(left, top);
                //绘制header
                header.draw(c);
                c.restore();
                //保存绘制的header的区域
                mHeaderRects.put(adapterPos, new Rect(left, top, left + header.getWidth(), top + header.getHeight()));
            }
        }
    }

    public int findHeaderPositionUnder(int x, int y) {
        for (int i = 0; i < mHeaderRects.size(); i++) {
            Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
            if (rect.contains(x, y)) {
                return mHeaderRects.keyAt(i);
            }
        }
        return -1;
    }

    public boolean findHeaderClickView(View view, int x, int y) {
        if (view == null) return false;
        for (int i = 0; i < mHeaderRects.size(); i++) {
            Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
            if (rect.contains(x, y)) {
                Rect vRect = new Rect();
                // 需要响应点击事件的区域在屏幕上的坐标
                vRect.set(rect.left + view.getLeft(), rect.top + view.getTop(), rect.left + view.getLeft() + view.getWidth(), rect.top + view.getTop() + view.getHeight());
                return vRect.contains(x, y);
            }
        }
        return false;
    }

    /**
     * 判断是否有header
     *
     * @param position
     * @return
     */
    private boolean hasHeader(int position) {
        return mAdapter.hasHeader(position);
    }

    /**
     * 获得自定义的Header
     *
     * @param parent
     * @param position
     * @return
     */
    public View getHeader(RecyclerView parent, int position) {
        //根据位置获取每一组的头部id
        final int headerId = mAdapter.getHeaderId(position);
        //通过头部id,从保存的头部view数组中获取改组的头部view
        View header = mHeaderViews.get(headerId);
        //如果为空,就通过adapert创建
        if (header == null) {
            //创建HeaderViewHolder
            RecyclerViewAdapter.HeaderHolder holder = mAdapter.onCreateHeaderViewHolder(parent);
            header = holder.itemView;
            //绑定数据
            mAdapter.onBindHeaderViewHolder(holder, position);
            //测量View并且layout
            int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
            int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
            //根据父View的MeasureSpec和子view自身的LayoutParams以及padding来获取子View的MeasureSpec
            int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
                    parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width);
            int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
                    parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height);
            //进行测量
            header.measure(childWidth, childHeight);
            //根据测量后的宽高放置位置
            header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight());
            //将创建好的头部view保存在数组中,避免每次重复创建
            mHeaderViews.put(headerId, header);
        }
        return header;

    }

    /**
     * 计算距离顶部的高度
     *
     * @param parent
     * @param child
     * @param header
     * @param adapterPos
     * @param layoutPos
     * @return
     */
    private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
        int headerHeight = header.getHeight();
        int top = ((int) child.getY()) - headerHeight;
        //在绘制最顶部的header的时候,需要考虑处理两个分组的header交换时候的情况
        if (layoutPos == 0) {
            final int count = parent.getChildCount();
            final int currentId = mAdapter.getHeaderId(adapterPos);
            //从第二个屏幕上线上的第二个item开始遍历
            for (int i = 1; i < count; i++) {
                int nextpos = parent.getChildAdapterPosition(parent.getChildAt(i));
                if (nextpos != RecyclerView.NO_POSITION) {
                    int nextId = mAdapter.getHeaderId(nextpos);
                    //找到下一个不同组的view
                    if (currentId != nextId) {
                        final View next = parent.getChildAt(i);
                        //当不同组的第一个view距离顶部的位置减去两组header的高度,得到offset
                        final int offset = ((int) next.getY()) - (headerHeight + getHeader(parent, nextpos).getHeight());
                        //offset小于0即为两组开始交换,第一个header被挤出界面的距离
                        if (offset < 0) {
                            return offset;
                        } else {
                            break;
                        }
                    }
                }
            }
            top = Math.max(0, top);
        }
        return top;
    }
}

最后

最后推荐关于几篇关于ItemDecoration使用和分析,本篇文章也参考了许多。
RecyclerView之ItemDecoration由浅入深
深入理解 RecyclerView 系列之一:ItemDecoration
StickHeaderItemDecoration--RecyclerView使用的固定头部装饰类
小甜点,RecyclerView 之 ItemDecoration 讲解及高级特性实践

你可能感兴趣的:(Android RecyclerView之粘性头部+点击事件)