RecyclerView源码分析(八)自定义LayoutManager的套路

1,993 阅读10分钟

通过前一篇对LayoutManager源码的分析,大家应该都对LayoutManager有了一定的认识。这篇我们主要来讲LayoutManager的自定义问题,实际需求中,我们遇到自定义LayoutManager的需求,概率还是非常大的。

首先我们先回答几个问题?自己也可以验证下自己的想法。

LayoutManger主要负责哪儿些RecyclerView的功能?

  1. 负责对子view的布局填充工作
    通过调用onLayoutChildren方法,计算最终的位置调用layout和addView进行第一次布局和填充。RecyclerView把布局操作完全委托给了LayoutManager进行处理,只依赖LayoutManager的onLayoutChildren方法。
  2. 滑动处理
    滑动进行时,会调用LayoutManager的scrollHorizontallyByscrollVerticallyBy方法进行滑动的处理,并对新进入屏幕view进行填充。
  3. 回收和复用入口
    因为LayoutManger负责对子View进行填充,并且负责滑动操作。这些都是对缓存的回收和复用的入口。滑动过程中不但要对新入屏幕的填充,还要对出屏幕的进行回收。实际的回收和复用逻辑实在RecyclerView#Recycler中的,LayoutManger只是一个入口。负责触发回收和复用的时机。
  4. 滚动到指定位置操作
    通过调用RecyclerView的scrollToPosition()会让列表定位到指定位置。这个操作也是LayoutManager负责的,为什要让LayoutManager也负责这个呢?这个操作其实也是滑动,所以一套逻辑肯定放在一起了。

所以一个自定义的LayoutManager要实现上面的所有功能才叫一个合格的LayoutManager。可能与我们理想中的自定义LayoutManager还是有差距的,我们可能感觉至少回收和复用的逻辑都是实现了LayoutManager就有的,不需要自己实现的。但是回收和复用的时机只有开发者自己决定,不是统一的规则。

自定义LayoutManager的套路

套路行天下,这里先介绍下自定义LayoutManager的套路,后面根据这个套路做即可

  1. 实现generateDefaultLayoutParams()
    它是一个抽象方法,必须实现。负责对每个item设置默认LayoutParams,LinearLayoutManager就是设置了宽高都是WRAP_CONTENT。
  2. 正确重写onMeasure()isAutoMeasureEnabled()方法
    看过LayoutManager源码的分析 那片文章应该都知道这两个方法的注意点。它们对应两种模式,只能选其一进行重写。isAutoMeasureEnabled()为true是自动测量模式,为了让RecyclerView支持wrap_content属性的,RecyclerView会内部在onMeasure时就进行各个item的测量和布局,提前获取大小。这时我们就不能重写onMeasure()了,因为测量的逻辑RecyclerView已经自己实现了。重写会破坏内部的实现。如果关闭自动测量,重写onMeasure()也可以,我们需要自己支持wrap_content,自由的定制measure的逻辑。
  3. 重写onLayoutChildren方法开始第一次布局填充
  4. 重写canScrollHorizontally()canScrollVertically()方法
    看支持哪儿方向的滑动,返回true才会调用到LayoutManager中。
  5. 重写scrollHorizontallyBy()scrollVerticallyBy()方法
    处理滑动和过程中item的填充和回收。
  6. 重写scrollToPosition()实现指定位置的跳转
    一步一步的从以上的步骤走来,我们就完成了自定义LayoutManger。看起来很简单,但是呢,细节害死人.....

自定义LayoutManger常用工具方法

我们可以使用LayoutManger内部的方法,帮助我们自定义,都是一些工具的方法。

  1. 获取View
    通过Recycler#getViewForPosition(int position)获取,getViewForPosition方法内部会从缓存中提取View,不能提取则走createViewHolder。不清楚的可以看上一篇文章。
  2. View的measure和layout
    measureChild(@NonNull View child, int widthUsed, int heightUsed) measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) layoutDecorated(@NonNull View child, int left, int top, int right, int bottom) layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right, int bottom)
    拿到View后,需要进行测量和布局,LayoutManger中给出了包装的实现,时机上还是调用ViewGroup的方法进行布局和测量。
  3. 获取View的测量数据
    int getDecoratedMeasuredWidth(@NonNull View child)
    int getDecoratedMeasuredHeight(@NonNull View child)
    int getDecoratedTop(@NonNull View child)
    int getDecoratedLeft(@NonNull View child)
    int getDecoratedRight(@NonNull View child)
    int getDecoratedBottom(@NonNull View child)
    应该对上面的方法很熟悉,但是他们都增加了对itemDecoration的支持,因为itemDecoration会增加item之间的边界,进而改变位置。
  4. 获取显示的位置
    getPosition(View view)获取View的positon
  5. 移动View
    offsetChildrenHorizontal(int dx)
    offsetChildrenVertical(int dy)
  6. OrientationHelper帮助类
    这个类分为水平和垂直两个方向的帮助类,分别封装了很多有用的测量模版代码。在我们做滚动填充操作时非常常用。
  7. 回收
    detachAndScrapViewAt(int index, @NonNull Recycler recycler) removeAndRecycleViewAt(int index, @NonNull Recycler recycler) 这里分为detach和remove操作,之间的不同我们在上一篇已经讲过了。这里不细说了。回收都有了,复用在哪儿里?其实第一点获取View调用Recycler的方法就是复用了。

开始自定义LayoutManager

我们现实中进行自定义LayoutManager都是有的效果实现不了了,一般这个效果都比较复杂。但是在这篇文章中,我们不讲复杂效果的实现,因为偏离了中心。我们的中心是怎么自定义LayoutManager,而不是那些复杂的效果。这篇我们只从0开始实现一个纯水平布局的LayoutManager。类似下图。

123456789101112

类似这种,是不是很简单。

通过上面的LayoutManager的自定义基础和套路,我们一个一个的实现,一环扣一环。

实现generateDefaultLayoutParams

上面也说,这个方法是抽象方法必须实现,功能是给每个item设置默认的布局参数

@Override
public LayoutParams generateDefaultLayoutParams() {
  MarginLayoutParams params = new MarginLayoutParams(100,100);
  params.rightMargin=10;
  params.rightMargin=10;
  return new LayoutParams(params);
}

这里我们直接设置了固定的布局参数,并设置了左右边界。是实际开发中一般设置自定义的模式。 运行一下,啥也没有。。。因为填充的逻辑还没有。

重写onMeasure()isAutoMeasureEnabled()方法

我们需要自动测量,让RecyclerView自动支持wrap_content,所以我们只有重写isAutoMeasureEnabled()方法即可。

@Override
public boolean isAutoMeasureEnabled() {
  return true;
}

重写onLayoutChildren方法开始第一次布局填充

这里我们要实现第一次填充,也就是首次填充。以后的填充逻辑都在滑动时了。这个方法主要是在RecyclerView执行onMeasure时进行调用,这时我们需要自己measure layout这个view,并做addView操作关联到RecyclerView中。

public static final int LAYOUT_START = -1;
public static final int LAYOUT_END = 1;

@Override
public void onLayoutChildren(Recycler recycler, State state) {
  // 因为这里是水平布局,获取水平方向的可用填充宽度
  int available = getWidth() - getPaddingRight() - getPaddingLeft();
  fill(available, recycler, showPosition, getPaddingLeft(), LAYOUT_END);
}

/**
 * @param available 可用空间
 * @param recycler Recycler实例
 * @param position 要填充的起始postion
 * @param offset 填充的起始位置
 * @param direction 填充方向 1表示向右 -1表示向左
 * @return 实际消耗的空间
 */
private int fill(int available, Recycler recycler, int position, int offset, int direction) {
  int start = available;
  while (available > 0 && position >= 0 && position < getItemCount()) {
    View view = recycler.getViewForPosition(position);
    
    measureChildWithMargins(view, 0, 0);
    
    int left = 0;
    int right = 0;
    int consumed = mOrientationHelper.getDecoratedMeasurement(view);
    if (direction == LAYOUT_START) {
      left = offset - consumed;
      right = offset;
    } else {
      left = offset;
      right = offset + consumed;
    }
    
    layoutDecoratedWithMargins(view, left, getPaddingTop(), right, view.getMeasuredHeight());

    available -= consumed;
    offset += consumed * direction;

    if (direction == LAYOUT_START) {
      addView(view, 0);
    } else {
      addView(view);
    }
    position += direction;
  }
  return start - available;
}

上面的代码逻辑比较简单,主要的逻辑在fill方法内部。这里一定要注意,循环的变量available是可用的空间,每添加一个子View,可用空间就减少一点,这样没有可用空间了,也就完成了填充,说明这时RecyclerView只会填充可用空间的View。
我看好多博客直接对adapter的getItemCount进行了for操作,那还要什么RecycleView呀,直接ScrollerView不好吗!这个点自己定义时一定要注意下。
fill方法内部通过recycler.getViewForPosition(position)方法通过缓存工具拿到指定位置的view后,内部的实现逻辑在缓存那节已经讲到了。我们直接进行measure和layout,并直接addView。最后减下可用空间。整体逻辑如此,有几个地方需要详细讲下。

  1. 计算left和right的逻辑
    这里的计算是区分了是填充左侧还是右侧的,填充左侧View的话,那么这个View的right值直接设置最左侧View的left即可,因为是向左叠加的。而填充右侧View的话,则正好相反,向右叠加。比较好理解。
  2. addView操作
    首先这个addView并不是直接调用ViewGroup的addView方法,而是LayoutManager的addView方法,我们知道回收的过程不光有remove操作,还有detach操作,所以LayoutManager#addView内部也封装了对detach的处理过程,有兴趣可以看一下。
    其次在填充左侧时,我们调用了addView(view, 0)进行填充,因为这次要填充在最左侧,也就是最前面,为了获取最左侧view简单,这里直接插入到了ViewGroup的children列表第一位;
  3. fill方法的返回值
    这个返回值表示实际消耗的空间,可能是大于传入的available的,因为填充进入的view可能显示超出了总共的显示区域。
    运行一下,果然出来了效果,自定义也不过如此呀。这时我们滑动控件,毫无反应,不能滑动的。下面实现滑动的逻辑。

实现可滑动

滑动主要涉及了水平垂直两组方法,每组两个方法。分别是canScrollHorizontally() scrollHorizontallyBy()canScrollVertically() scrollVerticallyBy() 。这里我们以水平方向举例,垂直方向也是一样的。 我们先看下RecyclerView的scrollBy方法,可以看到如果重写了相应的can开头方法,滑动事件才会通向LayoutManger点scrollVerticallyBy()scrollHorizontallyBy()方法。

@Override
public void scrollBy(int x, int y) {
    。。。
    final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
    final boolean canScrollVertical = mLayout.canScrollVertically();
    if (canScrollHorizontal || canScrollVertical) {
        scrollByInternal(canScrollHorizontal ? x : 0, canScrollVertical ? y : 0, null);
    }
}

scrollByInternal方法内部调用了LayoutManager的scrollHorizontallyBy和scrollVerticallyBy,如果我们在自定义的LayoutManger中重写了两个can开头方法,所以scrollHorizontallyBy和scrollVerticallyBy都会被调用。

boolean scrollByInternal(int x, int y, MotionEvent ev) {
    int unconsumedX = 0, unconsumedY = 0;
    int consumedX = 0, consumedY = 0;

    consumePendingUpdateOperations();
    if (mAdapter != null) {
       。。。
        fillRemainingScrollValues(mState);
        if (x != 0) {
            consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
            unconsumedX = x - consumedX;
        }
        if (y != 0) {
            consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
            unconsumedY = y - consumedY;
        }
        。。。
    }
    。。。
  }

接下来就到了我们LayoutManger自己实现的scrollHorizontallyBy内了。实现滑动时比较简单,直接调用offsetChildrenHorizontal方法进行水平的滑动,注意这里传入的是负的dx。
返回值表示消耗了多少,因为假如滑动到了首尾部分,这时我们是滑动不了的,所以这种情况下内部是没有消耗滑动事件的,应该返回0。返回值可以用来处理嵌套滑动。

@Override
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
  offsetChildrenHorizontal(-dx);
  return dx;
}

offsetChildrenHorizontal内部直接调用了每个child的offsetLeftAndRight进行移动,实现滚动的效果。
运行看下效果,果然可以滑动了。但是呢,怎么滑动的时候没有填充新的View呢,因为我们根本没有实现滑动中填充的逻辑!!!

实现滑动填充

我们可以先想一下滑动填充的逻辑,应该和第一次填充是类似的。也是通过对可填充空间的判断,如果有位置进行填充那么就进行填充,并减小填充空间。循环往复的填充。
可填充空间怎么得到呢,第一次填充比较容易,直接是整个RecyclerView的可视空间,但是滑动填充就比较麻烦。如果首末位置在滑动后已经超出了RecyclerView的空间,那么肯定不能开天窗。需要进行填充了,如果这次滑动,首末位置还没有显示全,还有显示区域外的内容没有显示,那么就不能进行填充。逻辑就大致是这么一个逻辑。 我们具体看下代码。

public static final int LAYOUT_START = -1;
public static final int LAYOUT_END = 1;
@Override
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {

  int direction = dx < 0 ? LAYOUT_START : LAYOUT_END;

  int absDx = Math.abs(dx);
  int position = 0;
  int bothEndOffset =0;
  int layoutOffset = 0;
  
  if (direction == LAYOUT_END) {
    //填充末尾的view
    View child = getChildAt(getChildCount() - 1);
    bothEndOffset = mOrientationHelper.getDecoratedEnd(child) - mOrientationHelper.getEndAfterPadding();
    layoutOffset = mOrientationHelper.getDecoratedEnd(child);
  } else {
    View child = getChildAt(0);
    bothEndOffset = -mOrientationHelper.getDecoratedStart(child) + mOrientationHelper
        .getStartAfterPadding();
    layoutOffset = mOrientationHelper.getDecoratedStart(child);
  }
  position = getPosition(child) + direction;

  int consumed = bothEndOffset + fill(absDx - bothEndOffset, recycler,
      position,
      layoutOffset,
      direction);

  int canScrollDistance = absDx > consumed ? consumed * direction : dx;

  offsetChildrenHorizontal(-canScrollDistance);
  return canScrollDistance;
}

首先通过要填充的方向拿到了首尾的View,如果填充左侧(首)就拿第一个View,如果是右侧(末)就拿最后一个View。我们计算了一下值供填充使用

计算值意义
position要进行填充的position,直接加上direction方向
layoutOffset首末View在RecyclerView里的left或right值,方便填充时,计算left和rigth。上面fill方法里对这个进行过讲解
bothEndOffset当前在可视区域外还没有显示的空间

bothEndOffset这个值可能不算直观,如果我们要填充左侧,那么最左面的View还没有显示的区域就是他的left值。填充右侧,那没有显示的区域就是他的right减整个recyclerView的长度。可以拿笔画一下。
计算完成这些值,直接调用fill方法进行填充,注意它可用的空间是absDx - bothEndOffset,也就是滑动距离减去了还没有显示的空间,因为只要滑动距离超出了还没有显示的空间,才有空间进行填充。如果还没有显示的空间都没有滑动出现,肯定是不能进行填充的。
最后对滑动的距离进行了矫正,因为填充的空间是除去bothEndOffset没有显示的空间的,所以最终产生的距离是要加上bothEndOffset的。
最后这个最终的距离不能超出用户产生的滑动距离的,进行max的处理。最终得到的值使用offsetChildrenHorizontal进行滑动。这就完成了滑动的填充。 运行一下,确实实现了这样效果。看看感觉也不缺什么了,效果都挺完整的了。但是我们并没有进行回收的操作,这时超出显示区域的view,我们没有对其回收。还是在RecyclerView的子View下方。随着滑动,RecyclerView的子View越来越到,直到add了所有的item。 回收是RecyclerView的重中之重。没有回收,缓存等于没有,下面我们实现下回收操作。

实现回收

回收主要分两部分

  1. 一级缓存的整体回收,是在onLayoutChildren里完成的。前面讲缓存的时候讲到一级缓存的生命周期就在测绘流程内。布局完成就回清空。
@Override
public void onLayoutChildren(Recycler recycler, State state) {
  detachAndScrapAttachedViews(recycler);
  int available = getWidth() - getPaddingRight() - getPaddingLeft();

  fill(available, recycler, showPosition, getPaddingLeft(), LAYOUT_END);

  offsetChildrenHorizontal(fixOffset);
}

我们直接调用detachAndScrapAttachedViews方法,直接把所有add的child方法一级缓存中。
2. 对二级和四级缓存的回收,在滑动过程中进行,主要交给Recycler类进行操作。具体的调用主要在scrollHorizontallyBy方法中。回收的逻辑也比较简单,如果这个View看不到了就回收呗。具体怎么计算是否可以看到呢。

@Override
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
  。。。

  int canScrollDistance = absDx > consumed ? consumed * direction : dx;

  recycleView(recycler, canScrollDistance);

  offsetChildrenHorizontal(-canScrollDistance);
  return canScrollDistance;
}

private void recycleView(Recycler recycler, int direction) {
  int canWatchArea = 0;
  View beRecycledView = null;
  if (direction == LAYOUT_END) {
    beRecycledView = getChildAt(getChildCount() - 1);
    canWatchArea = -mOrientationHelper.getDecoratedStart(beRecycledView) + mOrientationHelper
        .getEndAfterPadding();
  } else {
    beRecycledView = getChildAt(0);
    canWatchArea = mOrientationHelper.getDecoratedEnd(beRecycledView);
  }

  if (canWatchArea <= 0) {
    removeAndRecycleView(beRecycledView, recycler);
  }
}

这里我们省略了部分代码。主要的逻辑是在recycleView内部实现的。回收肯定是在首末位置,canWatchArea存储了可见的空间大小。如果不可见,为负值,直接进行回收即可。
canWatchArea的计算怎么理解呢。如果回收左侧的,对应LAYOUT_START,那么它的right如果小于0,说明整个View都在可视区域的左侧了,所以是不可见的。相反,如果回收右侧的,如果的他的left值大于整个可视区域的大小,那么也是不可见了。
removeAndRecycleView的内部实现也比较简单,先调用removeView移除View。再通过调用Recycler#recycleView方法放入二级或四级缓存中。

public void removeAndRecycleView(@NonNull View child, @NonNull Recycler recycler) {
    removeView(child);
    recycler.recycleView(child);
}

通过上面两个步骤,我们就完成了回收的工作。通过上面的几个步骤,一个LayoutManger保底的功能是有了。 接下来需要实现滚动到指定位置的方法。

实现滚动到指定位置

主要涉及到两个方法,scrollToPosition smoothScrollToPosition后者可以平滑滑动到指定位置,这里的操作仿照了LinearLayout的操作。

scrollToPosition

private int showPosition;
@Override
public void scrollToPosition(int position) {
  super.scrollToPosition(position);
  showPosition = position;
  requestLayout();
}

@Override
public void onLayoutChildren(Recycler recycler, State state) {
  if (showPosition < 0 || showPosition > state.getItemCount()) {
    showPosition = 0;
  }
  detachAndScrapAttachedViews(recycler);
  int available = getWidth() - getPaddingRight() - getPaddingLeft();
  fill(available, recycler, showPosition, getPaddingLeft(), LAYOUT_END);
}

@Override
public void onLayoutCompleted(State state) {
  super.onLayoutCompleted(state);
  showPosition = 0;
}

这里赋值了showPosition,并调用了requestLayout刷新布局。使用的地方在onLayoutChildren方法内部,我们直接对开始填充的位置赋值成showPosition,也就是通过showPosition开始布局,到指定位置的功能就实现了。

smoothScrollToPosition

相比上面的方法,这个方法内部主要实现了平滑的滚动,有了动画。仿照LinearLayoutManager内部的实现即可。

@Override
public void smoothScrollToPosition(RecyclerView recyclerView, State state, int position) {
  LinearSmoothScroller linearSmoothScroller =
      new LinearSmoothScroller(recyclerView.getContext());
  linearSmoothScroller.setTargetPosition(position);
  startSmoothScroll(linearSmoothScroller);
}

@Nullable
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
  if (getChildCount() == 0) {
    return null;
  }
  final int firstChildPos = getPosition(getChildAt(0));
  final int direction = targetPosition < firstChildPos ? -1 : 1;
  return new PointF(direction, 0);
}

经过上面的步骤,我们就完成了一个LayoutManger的自定义,整体下来,是不是也不复杂呢。其实还有一个操作我们应该实现下,如果我们界面上有一个键盘,吊起键盘,这时我们的RecyclerView会刷新,回到第一个位置。猜想可知,这时候重新调用了LayoutManager的onLayoutManger方法,当前默认定位的position是0,所以又重新布局道到了第一个位置,RecyclerView也有处理这方面的逻辑,在LayoutManager#updateAnchorFromChildren内。有兴趣可以看下。
修复这个问题的逻辑就是获取根据布局方向获取顶部或底部的position,从这个位置开始填充。因为这个位置的View可能已经偏离了一些位置,还需要滚动到指定位置。所以通过AnchorInfo#assignFromView设置相应的offset,起始的布局位置。 下面我们自己实现我们自定义的LayoutManager对键盘的处理。

处理键盘响应

@Override
public void onLayoutChildren(Recycler recycler, State state) {
  if (showPosition < 0 || showPosition > state.getItemCount()) {
    showPosition = 0;
  }
  int fixOffset = 0;
  if (getChildCount() > 0) {
    showPosition = getPosition(getChildAt(0));
    fixOffset = getDecoratedLeft(getChildAt(0));
  }
  detachAndScrapAttachedViews(recycler);
  int available = getWidth() - getPaddingRight() - getPaddingLeft() + Math.abs(fixOffset);

  fill(available, recycler, showPosition, getPaddingLeft() + fixOffset, LAYOUT_END);
}

内部只在getChildCount()大于0时才生效,也就是在布局之前还有显示的View。直接拿到最左侧第一个位置的view,获取它的positon,还有显示的偏移量。在进行填充调用fill方法时,我们直接传入两个参数即可。也就是从算出的position开始填充,并且起始的布局点要移出指定的偏移量。这里要注意填充的可用空间要加上偏移量,因为偏移量也会有待填充的view。如果不加可能会有空白出现。

通过上面的分析,我们应该可以自如的自定义LayoutManager了,可以自己动手实现一个简单的,加深理解。下一篇会分析RecyclerView的动画