RecyclerView进阶之层叠列表(上)

5,985 阅读6分钟

前言

上周五写了篇仿夸克浏览器底部工具栏,相信看过的同学还有印象吧。在文末我抛出了一个问题,夸克浏览器底部工具栏只是单层层叠的ViewGroup,如何实现类似Android系统通知栏的多级层叠列表呢?

不过当时仅仅有了初步的思路:recyclerView+自定义layoutManager,所以周末又把自定义layoutManager狠补了一遍。终于大致实现了这个效果(当然细节有待优化( ̄. ̄))。老样子,先来看看效果吧:

实际使用时可能不需要顶部层叠,所以还有单边效果,看起来更自然些:

怎么样,乍一看是不是非常形(神)似呢?以上的效果都是自定义layoutManager实现的,所以只要一行代码就能把普通的RecyclerView替换成这种层叠列表:

mRecyclerView.setLayoutManager(new OverFlyingLayoutManager());

好了废话不多说,直接来分析下怎么实现吧。以下的主要内容就是帮你从学会到熟悉自定义layoutManager

概述

先简单说下自定义layoutManager的步骤吧,其实很多文章都讲过,适合没接触的同学:

  • 实现generateDefaultLayoutParams()方法,生成自己所定义扩展的LayoutParams
  • onLayoutChildren()中实现初始列表中各个itemView的位置
  • scrollVerticallyBy()scrollHorizontallyBy()中处理横向和纵向滚动,还有view的回收复用。

个人理解就是:layoutManager就相当于自定义ViewGroup中把onMeasure()onlayout()scrollTo()等方法独立出来,单独交给它来做。实际表现也是类似:onLayoutChildren()作用就是测量放置itemView

初始化列表

我们先实现自己的布局参数:

  @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
    return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    }

也就是不实现,自带的RecyclerView.LayoutParams继承自ViewGroup.MarginLayoutParams,已经够用了。通过查看源码,最终这个方法返回的布局参数对象会设置给:

holder.itemView.setLayoutParams(rvLayoutParams);

然后实现onLayoutChildren(),在里面要把所有itemView没滑动前自身应该在的位置都记录并放置一遍: 定义两个集合:

  // 用于保存item的位置信息
    private SparseArray<Rect> allItemRects = new SparseArray<>();
    // 用于保存item是否处于可见状态的信息
    private SparseBooleanArray itemStates = new SparseBooleanArray();

把所有View虚拟地放置一遍,记录下每个view的位置信息,因为此时并没有把View真正到recyclerview中,也是不可见的:

   private void calculateChildrenSiteVertical(RecyclerView.Recycler recycler, RecyclerView.State state) {
    // 先把所有的View先从RecyclerView中detach掉,然后标记为"Scrap"状态,表示这些View处于可被重用状态(非显示中)。
     detachAndScrapAttachedViews(recycler);
        for (int i = 0; i < getItemCount(); i++) {
            View view = recycler.getViewForPosition(i);
            // 测量View的尺寸。
            measureChildWithMargins(view, 0, 0);
            //去除ItemDecoration部分
            calculateItemDecorationsForChild(view, new Rect());
            int width = getDecoratedMeasuredWidth(view);
            int height = getDecoratedMeasuredHeight(view);

            Rect mTmpRect = allItemRects.get(i);
            if (mTmpRect == null) {
                mTmpRect = new Rect();
            }
            mTmpRect.set(0, totalHeight, width, totalHeight + height);
            totalHeight += height;
            // 保存ItemView的位置信息
            allItemRects.put(i, mTmpRect);
            // 由于之前调用过detachAndScrapAttachedViews(recycler),所以此时item都是不可见的
            itemStates.put(i, false);
        }

        addAndLayoutViewVertical(recycler, state, 0);
    }

然后我们开始真正地添加View到RecyclerView中。为什么不在记录位置的时候添加呢?因为后添加的view如果和前面添加的view重叠,那么后添加的view会覆盖前者,和我们想要实现的层叠的效果是相反的,所以需要正向记录位置信息,然后根据位置信息反向添加View:

   private void addAndLayoutViewVertical(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int displayHeight = getWidth() - getPaddingLeft() - getPaddingRight();//计算recyclerView可以放置view的高度
        //反向添加
        for (int i = getItemCount() - 1; i >= 0; i--) {
            // 遍历Recycler中保存的View取出来
            View view = recycler.getViewForPosition(i);
            //因为刚刚进行了detach操作,所以现在可以重新添加
            addView(view); 
            //测量view的尺寸
            measureChildWithMargins(view, 0, 0); 
            int width = getDecoratedMeasuredWidth(view); // 计算view实际大小,包括了ItemDecorator中设置的偏移量。
            int height = getDecoratedMeasuredHeight(view);
            //调用这个方法能够调整ItemView的大小,以除去ItemDecorator距离。
            calculateItemDecorationsForChild(view, new Rect());
             Rect mTmpRect = allItemRects.get(i);//取出我们之前记录的位置信息
            if (mTmpRect.bottom > displayHeight) {
                //排到底了,后面统一置底
                layoutDecoratedWithMargins(view, 0, displayHeight - height, width, displayHeight);
            } else {
                //按原位置放置
                 layoutDecoratedWithMargins(view, 0,  mTmpRect.top, width, mTmpRect.bottom);
            }
        Log.e(TAG, "itemCount = " + getChildCount());
    }

这样一来,编译运行,界面上已经能看到列表了,就是它还不能滚动,只能停留在顶部。

处理滚动

先设置允许纵向滚动:

 @Override
    public boolean canScrollVertically() {
        // 返回true表示可以纵向滑动
        return orientation == OrientationHelper.VERTICAL;
    }

处理滚动原理其实很简单:

  1. 手指在屏幕上滑动,系统告诉我们一个滑动的距离
  2. 我们根据这个距离判断我们列表内部各个view的实际变化,然后和onLayoutChildren()一样重新布局就行
  3. 返回告诉系统我们滑动了多少,如果返回0,就说明滑到边界了,就会有一个边缘的波纹效果。
 @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //列表向下滚动dy为正,列表向上滚动dy为负,这点与Android坐标系保持一致。
        //dy是系统告诉我们手指滑动的距离,我们根据这个距离来处理列表实际要滑动的距离
        int tempDy = dy;
        //最多滑到总距离减去列表距离的位置,即可滑动的总距离是列表内容多余的距离
        if (verticalScrollOffset <= totalHeight - getVerticalSpace()) {
            //将竖直方向的偏移量+dy
            verticalScrollOffset += dy;
        }
        if (verticalScrollOffset > totalHeight - getVerticalSpace()) {
            verticalScrollOffset = totalHeight - getVerticalSpace();
            tempDy = 0;//滑到底部了,就返回0,说明到边界了
        } else if (verticalScrollOffset < 0) {
            verticalScrollOffset = 0;
            tempDy = 0;//滑到顶部了,就返回0,说明到边界了
        }
        //重新布局位置、显示View
        addAndLayoutViewVertical(recycler, state, verticalScrollOffset); 
        return tempDy;
    }

上面说了,滚动其实就是根据滑动距离重新布局的过程,和onLayoutChildren()中的初始化布局没什么两样。我们扩展布局方法,传入偏移量,这样onLayoutChildren()调用时只要传0就行了:

  private void addAndLayoutViewVertical(RecyclerView.Recycler recycler, RecyclerView.State state, int offset) {
      
        int displayHeight = getVerticalSpace();
        for (int i = getItemCount() - 1; i >= 0; i--) {
            // 遍历Recycler中保存的View取出来
            View view = recycler.getViewForPosition(i);
            addView(view); // 因为刚刚进行了detach操作,所以现在可以重新添加
            measureChildWithMargins(view, 0, 0); // 通知测量view的margin值
            int width = getDecoratedMeasuredWidth(view); // 计算view实际大小,包括了ItemDecorator中设置的偏移量。
            int height = getDecoratedMeasuredHeight(view);

            Rect mTmpRect = allItemRects.get(i);
            //调用这个方法能够调整ItemView的大小,以除去ItemDecorator。
            calculateItemDecorationsForChild(view, new Rect());

            int bottomOffset = mTmpRect.bottom - offset;
            int topOffset = mTmpRect.top - offset;
            if (bottomOffset > displayHeight) {//滑到底了
                layoutDecoratedWithMargins(view, 0, displayHeight - height, width, displayHeight);
            } else {
                if (topOffset <= 0 ) {//滑到顶了
                    layoutDecoratedWithMargins(view, 0, 0, width, height);
                } else {//中间位置
                    layoutDecoratedWithMargins(view, 0, topOffset, width, bottomOffset);
                }
            }
        Log.e(TAG, "itemCount = " + getChildCount());
    }

好了,这样就能滚动了。

小结

因为自定义layoutManager内容比较多,所以我分成了上下篇来讲。到这里基础效果实现了,但是这个RecyclerView还没有实现回收复用(参看addAndLayoutViewVertical末尾打印),还有边缘的层叠嵌套动画和视觉处理也都留到下篇说了。看了上面的内容,实现横向滚动也是很简单的,感兴趣的自己去github上看下实现吧!

Github地址

PS:下篇内容请戳:

RecyclerView进阶之层叠列表(下)