美文网首页Android程序员Android
Android RecyclerView之粘性头部+点击事件

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

作者: time_fly | 来源:发表于2017-06-15 17:44 被阅读942次

    实现上图列表的粘性头部功能一般通过在布局页面额外写粘性头部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(),这三者是依次发生的。
     @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<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()方法实现
       @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的地方进行绘制。在获取绘制坐标的时候,主要在于确定纵坐标的起始位置距离顶部的大小。

    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<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 讲解及高级特性实践

    相关文章

      网友评论

      本文标题:Android RecyclerView之粘性头部+点击事件

      本文链接:https://www.haomeiwen.com/subject/aidifxtx.html