前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Android RecyclerView之粘性头部+点击事件

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

作者头像
用户3106371
发布2018-09-12 10:14:49
4.2K0
发布2018-09-12 10:14:49
举报
文章被收录于专栏:lzj_learn_notelzj_learn_note

实现上图列表的粘性头部功能一般通过在布局页面额外写粘性头部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的效果。如下图所示:

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

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

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

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

调用顺序

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

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

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

代码语言:javascript
复制
 @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,这两个阶段最终都会执行下面这段代码:

代码语言:javascript
复制
   if (!mItemDecorations.isEmpty()) {
        invalidate();
   }

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

StickyHeader

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

  • 列表数据有50条,每5条为一组,adapter的实现
代码语言:javascript
复制
public class RecyclerViewAdapter extends RecyclerView.Adapter<RecyclerViewAdapter.MyViewHolder> {
    private Context mContext;
    private List<String> datas;

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

    public void setData(List<String> 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()方法实现
代码语言:javascript
复制
   @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)方法,每组的第一个添加头部。

代码语言:javascript
复制
/**
     * 判断是否有header
     *
     * @param position
     * @return
     */
    private boolean hasHeader(int position) {
        return mAdapter.hasHeader(position);
    }

获取头部高度的方法:

代码语言:javascript
复制
 /**
     * 获得自定义的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()方法实现
代码语言:javascript
复制
   @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的地方进行绘制。在获取绘制坐标的时候,主要在于确定纵坐标的起始位置距离顶部的大小。

offset表示的含义

代码语言:javascript
复制
/**
     * 计算距离顶部的高度
     *
     * @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的完整代码。

代码语言:javascript
复制
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的区域内的方法

代码语言:javascript
复制
    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需要响应点击事件的区域

代码语言:javascript
复制
 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的完整代码。

代码语言:javascript
复制
public class TestDecoration extends RecyclerView.ItemDecoration {
    private RecyclerViewAdapter mAdapter;
    private final SparseArray<Rect> mHeaderRects = new SparseArray<>();
    private final LongSparseArray<View> 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 讲解及高级特性实践

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2017.06.15 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • ItemDecoration
  • 调用顺序
  • StickyHeader
  • StickyHeader的点击事件
  • 最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档