Android空间——RecyclerView系列二(LayoutManager的自定义与使用)

1 LayoutManager

1.1 LayoutManager简单分析

布局管理器,通过设置不同的布局管理器,来控制这些Item的排列方式,其中包含了Item View的获取与回收。

RecyclerView提供的布局管理器:

  • LinearLayoutManager 以垂直或水平滚动列表方式显示项目
  • GridLayoutManager 在网格中显示项目。
  • StaggeredGridLayoutManager 在分散对齐网格中显示项目。

这里简单分析一下LinearLayoutManager的实现。

对于对于LinearLayoutManager来说,比较重要的几个方法有:

  • onLayoutChildren(): 对RecyclerView进行布局的入口方法。
  • fill(): 负责填充RecyclerView。
  • scrollVerticallyBy():根据手指的移动滑动一定距离,并调用fill()填充。
  • canScrollVertically()canScrollHorizontally(): 判断是否支持纵向滑动或横向滑动。

onLayoutChildren()的核心实现如下:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
     detachAndScrapAttachedViews(recycler); //将原来所有的Item View全部放到Recycler的Scrap Heap或Recycle Pool
     fill(recycler, mLayoutState, state, false); //填充现在所有的Item View
 }

RecyclerView的回收机制有个重要的概念,即将回收站分为Scrap HeapRecycle Pool,其中Scrap Heap的元素可以被直接复用,而不需要调用onBindViewHolder()。detachAndScrapAttachedViews()会根据情况,将原来的Item View放入Scrap Heap或Recycle Pool,从而在复用时提升效率。

fill()是对剩余空间不断地调用layoutChunk(),直到填充完为止。layoutChunk()的核心实现如下:

public void layoutChunk() {
     View view = layoutState.next(recycler); //调用了getViewForPosition()
     addView(view);  //加入View
     measureChildWithMargins(view, 0, 0); //计算View的大小
     layoutDecoratedWithMargins(view, left, top, right, bottom); //布局View
 }

其中next()调用了getViewForPosition(currentPosition),该方法是从RecyclerView的回收机制实现类Recycler中获取合适的View。

1.2 自定义LayoutManager核心步骤

  • Recycler

当你需要从一个可能再生的前子视图中 回收旧的 view 或者 获取新的 view 时, 你的 LayoutManager 可以访问一个 Recycler 实例。

Recycler 也免掉了直接访问 view 当前适配器方法的麻烦。当你的 LayoutManager 需要一个新的子视图时,只要调用 getViewForPosition() 这个方法,Recycler 会决定到底是从头创建一个新的视图 还是重用一个已存在的废弃视图。 你的 LayoutManager 需要及时将不再显示的视图传递给 Recycler, 避免 Recycler 创建不必要的 view 对象。

  • Detach vs Remove

布局更新时有两个方法处理已存在的子视图:detach 和 remove (分离和移除)。Detach 是一个轻量的记录 view 操作。 被 detach 的视图在你的代码返回前能够重新连接。可以通过 Recycler 在不 重新绑定/重新构建 子视图的情况下修改已连接子视图的索引。

Remove 意味着这个 view 已经不需要了。任何被永久移除的 view 都应该 放到 Recycler 中,方便以后重用,不过 API 并没有强制要求。 被 remove 的视图是否被回收取决于你。

  • Scrap vs Recycle

Recycler 有两级视图缓存系统: scrap heap 和 recycle pool (垃圾堆和回收池), Scrap heap 是一个轻量的集合,视图可以不经过适配器直接返回给 LayoutManager 。通常被 detach 但会在同一布局重新使用的视图会临时储存在这里。Recycle pool 存放的 是那些假定并没有得到正确数据(相应位置的数据)的视图, 因此它们都要经过适配器重新绑定后才能返回给 LayoutManager。

当要给 LayoutManager 提供一个新 view 时,Recycler 首先会 检查 scrap heap 有没有对应的 position/id;如果有对应的内容, 就直接返回数据不需要通过适配器重新绑定。如果没有的话, Recycler 就会从 recycle pool 里弄一个合适的视图出来, 然后用 adapter 给它绑定必要的数据 (就是调用 RecyclerView.Adapter.bindViewHolder()) 再返回。 如果 recycle pool 中也不存在有效 view ,就会在绑定数据前 创建新的 view (就是 RecyclerView.Adapter.createViewHolder()), 最后返回数据。

只要你原意,LayoutManager 的 API 允许你独立完成所有这些任务, 所以可能的组合有点多。通常来说, 如果你想要临时整理并且希望稍后在同一布局中重新使用某个 view 的话, 可以对它调用 detachAndScrapView() 。如果基于当前布局 你不再需要某个 view 的话,对其调用 removeAndRecycleView()

1.2.1 核心方法

LayoutManager 需要实时添加,测量和布局所有它需要的子视图。 当用户滚动屏幕时,布局管理器将来决定什么时候添加新的子视图, 什么时候可以 detach/scrap (分离/废弃)视图。

你需要实现下面这些方法创建一个可行的 LayoutManager 最小系统。

  • generateDefaultLayoutParams()

事实上你只要重写这个方法你的 LayoutManager 就能编译通过了。 实现也很简单,返回一个你想要默认应用给所有从 Recycler 中获得的子视图做参数的 RecyclerView.LayoutParams 实例。 这些参数会在对应的 getViewForPosition() 返回前赋值给相应的子视图。

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

onLayoutChildren() 是 LayoutManager 的主入口。 它会在 view 需要初始化布局时调用, 当适配器的数据改变时(或者整个适配器被换掉时)会再次调用。 注意!这个方法不是在每次你对布局作出改变时调用的。 它是 初始化布局 或者 在数据改变时重置子视图布局的好位置。

示例

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //Scrap measure one child
    View scrap = recycler.getViewForPosition(0);
    addView(scrap);
    measureChildWithMargins(scrap, 0, 0);

    /*
     * We make some assumptions in this code based on every child
     * view being the same size (i.e. a uniform grid). This allows
     * us to compute the following values up front because they
     * won't change.
     */
    mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
    mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
    detachAndScrapView(scrap, recycler);
    
    updateWindowSizing();
    int childLeft;
    int childTop;

    /*
     * Reset the visible and scroll positions
     */
    mFirstVisiblePosition = 0;
    childLeft = childTop = 0;
    
    //Clear all attached views into the recycle bin
    detachAndScrapAttachedViews(recycler);
    //Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler);
}

通常来说,在这类方法之中你需要完成的主要步骤如下:

  • 在滚动事件结束后检查所有附加视图当前的偏移位置。
  • 判断是否需要添加新视图填充由滚动屏幕产生的空白部分。并从 Recycler 中获取视图。
  • 判断当前视图是否不再显示。移除它们并放置到 Recycler 中。
  • 判断剩余视图是否需要整理。发生上述变化后可能 需要你修改视图的子索引来更好地和它们的适配器位置校准。

这里将大部分工作抽象到fillGraid()中这个方法以便重用,注意我们放进 FixedGridLayoutManager.fillGrid() 里填充 RecyclerView 的主要步骤:

  1. 清点目前我们所有的视图。将他们 Detach 以便稍后重新连接
SparseArray<View> viewCache = new SparseArray<View>(getChildCount());
//...
if (getChildCount() != 0) {
	//...
	//Cache all views by their existing position, before updating counts
	for (int i=0; i < getChildCount(); i++) {
		int position = positionOfIndex(i);
		final View child = getChildAt(i);
		viewCache.put(position, child);
	}
	//Temporarily detach all views.
	// Views we still need will be added back at the proper index.
	for (int i=0; i < viewCache.size(); i++) {
		detachView(viewCache.valueAt(i));
	}
}
  1. 测量/布局每一个当前可见的子视图。重新连接已有的视图很简单; 新的视图是从 Recycler 之中获取的。
for (int i = 0; i < getVisibleChildCount(); i++) {
    //...

    //Layout this position
    View view = viewCache.get(nextPosition);
    if (view == null) {
        /*
         * The Recycler will give us either a newly constructed view,
         * or a recycled view it has on-hand. In either case, the
         * view will already be fully bound to the data by the
         * adapter for us.
         */
        view = recycler.getViewForPosition(nextPosition);
        addView(view);

        /*
         * It is prudent to measure/layout each new view we
         * receive from the Recycler. We don't have to do
         * this for views we are just re-arranging.
         */
        measureChildWithMargins(view, 0, 0);
        layoutDecorated(view, leftOffset, topOffset,
                leftOffset + mDecoratedChildWidth,
                topOffset + mDecoratedChildHeight);
    } else {
        //Re-attach the cached view at its new index
        attachView(view);
        viewCache.remove(nextPosition);
    }

    //...
}
  1. 最终,所有在第一步中 detach 并且没有被重新连接的视图都不可见。 将它们移入 Recycler 中,以备后用。
for (int i=0; i < viewCache.size(); i++) {
    recycler.recycleView(viewCache.valueAt(i));
}

备注
先将所有视图 detach 之后再将需要的视图重新连接是为了 保持每一个视图子索引的顺序 (就是 getChildAt() 的索引)。我们希望 可见视图从左上到右下的索引从 0 开始,到 getChildCount()-1 结束。 当我们上下滑动视图,新的子视图被添加,它的索引顺序会变得不可靠。 我们需要保留正确的索引来在任意点上定位每一个视图。在一个简单地 LayoutManager (比如 LinearLayoutManager)中,子视图可以轻松地插入 list 的两端, 记录层就没有存在的必要了。

参考文献:
Building a RecyclerView LayoutManager
RecyclerView系列之三自定义LayoutManager

1.2.2 添加交互

前述步骤只是完成了一个初始布局,但是它并不能动。 RecyclerView 的关键就在于当用户浏览一组数据时动态提供视图。 覆盖一些方法就能实现我们的目的。

  • canScrollHorizontally() & canScrollVertically()

这些方法很简单,在你想要滚动方向对应的方法里返回 true , 不想要滚动方向对应的方法里返回 false。

@Override
public boolean canScrollVertically() {
    //We do allow scrolling
    return true;
}
  • scrollHorizontallyBy() & scrollVerticallyBy()

在这里你应该实现Content移动逻辑。
RecyclerView 已经处理了 scrolling 和 flinging (注:Fling: Gross gesture, no on-screen target) 触摸操作,不需要处理 MotionEvents 或者 GestureDetectors 这些麻烦事。 你只需要完成下面这三个任务:

  • 将所有的子视图移动适当的位置
  • 决定移动视图后 添加/移除 视图。
  • 返回滚动的实际距离。框架会根据它判断你是否触碰到边界。

简单示例:

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
        RecyclerView.State state) {

    if (getChildCount() == 0) {
        return 0;
    }

    //Take top measurements from the top-left child
    final View topView = getChildAt(0);
    //Take bottom measurements from the bottom-right child.
    final View bottomView = getChildAt(getChildCount()-1);

    //Optimize the case where the entire data set is too small to scroll
    int viewSpan = getDecoratedBottom(bottomView) - getDecoratedTop(topView);
    if (viewSpan <= getVerticalSpace()) {
        //We cannot scroll in either direction
        return 0;
    }

    int delta;
    int maxRowCount = getTotalRowCount();
    boolean topBoundReached = getFirstVisibleRow() == 0;
    boolean bottomBoundReached = getLastVisibleRow() >= maxRowCount;

    if (dy > 0) { // Contents are scrolling up
        //Check against bottom bound
        if (bottomBoundReached) {
            //If we've reached the last row, enforce limits
            int bottomOffset;
            if (rowOfIndex(getChildCount() - 1) >= (maxRowCount - 1)) {
                //We are truly at the bottom, determine how far
                bottomOffset = getVerticalSpace() - getDecoratedBottom(bottomView)
                        + getPaddingBottom();
            } else {
                /*
                 * Extra space added to account for allowing bottom space in the grid.
                 * This occurs when the overlap in the last row is not large enough to
                 * ensure that at least one element in that row isn't fully recycled.
                 */
                bottomOffset = getVerticalSpace() - (getDecoratedBottom(bottomView)
                        + mDecoratedChildHeight) + getPaddingBottom();
            }
            delta = Math.max(-dy, bottomOffset);
        } else {
            //No limits while the last row isn't visible
            delta = -dy;
        }
    } else { // Contents are scrolling down
        //Check against top bound
        if (topBoundReached) {
            int topOffset = -getDecoratedTop(topView) + getPaddingTop();
            delta = Math.min(-dy, topOffset);
        } else {
            delta = -dy;
        }
    }

    offsetChildrenVertical(delta);

    if (dy > 0) {
        if (getDecoratedBottom(topView) < 0 && !bottomBoundReached) {
            fillGrid(DIRECTION_DOWN, recycler);
         } else if (!bottomBoundReached) {
            fillGrid(DIRECTION_NONE, recycler);
         }
    } else {
        if (getDecoratedTop(topView) > 0 && !topBoundReached) {
            fillGrid(DIRECTION_UP, recycler);
        } else if (!topBoundReached) {
            fillGrid(DIRECTION_NONE, recycler);
        }
    }

    /*
     * Return value determines if a boundary has been reached
     * (for edge effects and flings). If returned value does not
     * match original delta (passed in), RecyclerView will draw
     * an edge effect.
     */
    return -delta;
}

我们获得了滚动距离(dx/dy)的增量来验证。方法的第一部分判断 按照所给的距离(标志给了滚动方向)滚动会不会超过边界。如果会, 我们需要计算出视图实际滚动的距离。

在这个方法里,我们需要自己手工移动这些视图。 offsetChildrenVertical()offsetChildrenHorizontal() 这两个方法 可以帮助我们处理匀速移动。 如果你不实现它,你的视图就不会滚动。 移动视图操作完成后,我们触发另一个填充操作, 根据滚动的距离替换视图。

最后,将实际位移距离应用给子视图。RecyclerView 根据这个值判断是否 绘制到达边界的效果。一般意义上,如果返回值不等于传入的值就意味着 需要绘制边缘的发光效果了。 如果你返回了一个带有错误方向的值,框架的函数会把这个当做一个大的变化 你将不能获得正确的边缘发光特效。

除了用来判断绘制边界特效外,返回值还被用来决定什么时候取消 flings。 返回错误的值会让你失去对 content fling 的控制。框架会认为你已经提前 触碰到边缘并取消了 fling。

1.3 自定义LayoutManager简单实现示例

前面基本上介绍了自定义一个LayoutManager的核心步骤,当然它还少了很多细节部分。在继续介绍细节部分之前,先简单实现一个自定义LayoutManager的示例,加深前述的理解。

1.3.1 自定义CustomLayoutManager

首先生成一个类CustomLayoutManager,派生自LayoutManager:

public class CustomLayoutManager extends LayoutManager {
    @Override
    public LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT,
                RecyclerView.LayoutParams.WRAP_CONTENT);
    }
}

我们派生自LayoutManager时,会强制让我们生成一个方法generateDefaultLayoutParams
这个方法就是RecyclerView Item的布局参数。一般没什么特殊需求,则可以直接让子Item自己决定自己的宽高即可。

1.3.2 onLayoutChild()

在LayoutManager中,所有的Item布局都是在onLayoutChildren()函数中处理的,所以需要在CustomLayoutItem中添加onLayoutChildren()函数。

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //定义竖直方向的偏移量
    int offsetY = 0;
    for (int i = 0; i < getItemCount(); i++) {
        View view = recycler.getViewForPosition(i);
        addView(view);
        measureChildWithMargins(view, 0, 0);
        int width = getDecoratedMeasuredWidth(view);
        int height = getDecoratedMeasuredHeight(view);
        layoutDecorated(view, 0, offsetY, width, offsetY + height);
        offsetY += height;
    }
}

这里主要做了两件事:

  • 把所有的item所对应的view加进来
  • 把所有Item摆在其应在的地方

首先,我们通过measureChildWithMargins(view, 0, 0);函数测量这个view,并且通过getDecoratedMeasuredWidth(view)得到测量出来的宽度,需要注意的是通过getDecoratedMeasuredWidth(view)得到的是item+decoration的总宽度。如果你只想得到view的测量宽度,通过view.getMeasuredWidth()就可以得到了

然后通过layoutDecorated();函数将每个item摆放在对应的位置,每个Item的左右位置都是相同的,从左侧x=0开始摆放,只是y的点需要计算。所以这里有一个变量offsetY,用以累加当前Item之前所有item的高度。

1.3.3 添加滚动效果

现在还不能滑动,现在我们给它加上滑动:

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

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    // 平移容器内的item
    offsetChildrenVertical(-dy);
    return dy;
}

首先,我们通过在canScrollVertically()中return true;使LayoutManager具有垂直滚动的功能。然后在scrollVerticallyBy中接收每次滚动的距离dy。

这里需要注意的是,在scrollVerticallyBy中,dy表示手指在屏幕上每次滑动的位移.

  • 当手指由下往上滑时,dy>0
  • 当手指由上往下滑时,dy<0
    很明显,当手指向上滑动时,我们需要让所有子Item向上移动,向上移动明显是需要减去dy的.所以,大家经过测试也可以发现,让容器内的item移动-dy距离,才符合生活习惯.在LayoutManager中,我们可以通过public void offsetChildrenVertical(int dy)函数来移动RecycerView中的所有item.

1.3.4 添加异常判断

前面虽然实现了滚动,但是存在两个问题:Item到顶和到底之后,仍可以滚动,我们需要在滚动时添加判断,如果到顶了或者到底了就不让它滚动

  1. 判断到顶了
    判断到顶相对比较容易,我们只需要把所有的dy相加,如果小于0,就表示已经到顶了。就不让它再移动就行:
private int mSumDy = 0;
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    int travel = dy;
    //如果滑动到最顶部
    if (mSumDy + dy < 0) {
        travel = -mSumDy;
    }
    mSumDy += travel;
    // 平移容器内的item
    offsetChildrenVertical(-travel);
    return dy;
}

通过变量mSumDy 保存所有移动过的dy,如果当前移动的距离<0,那么就不再累加dy,直接让它移动到y=0的位置,因为之前已经移动的距离是mSumdy;

  1. 判断到底了

判断到底的方法,其实就是我们需要知道所有item的总高度,用总高度减去最后一屏的高度,就是到底的时的偏移值,如果大于这个偏移值就说明超过底部了。

首先需要得到所有item的总高度,我们知道在onLayoutChildren中会测量所有的item并且对每一个item布局,所以我们只需要在onLayoutChildren中将所有item的高度相加就可以得到所有Item的总高度了。

private int mTotalHeight = 0;
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //定义竖直方向的偏移量
    int offsetY = 0;
    for (int i = 0; i < getItemCount(); i++) {
        View view = recycler.getViewForPosition(i);
        addView(view);
        measureChildWithMargins(view, 0, 0);
        int width = getDecoratedMeasuredWidth(view);
        int height = getDecoratedMeasuredHeight(view);
        layoutDecorated(view, 0, offsetY, width, offsetY + height);
        offsetY += height;
    }
    //如果所有子View的高度和没有填满RecyclerView的高度,
    // 则将高度设置为RecyclerView的高度
    mTotalHeight = Math.max(offsetY, getVerticalSpace());
}
private int getVerticalSpace() {
    return getHeight() - getPaddingBottom() - getPaddingTop();
}

getVerticalSpace()函数可以得到RecyclerView用于显示item的真实高度。而相比上面的onLayoutChildren,这里只添加了一句代码:mTotalHeight = Math.max(offsetY, getVerticalSpace());这里只所以取最offsetY和getVerticalSpace()的最大值是因为,offsetY是所有item的总高度,而当item填不满RecyclerView时,offsetY应该是比RecyclerView的真正高度小的,而此时的真正的高度应该是RecyclerView本身所设置的高度。

接着在scrollVerticallyBy中判断到底与否并处理:

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    int travel = dy;
    //如果滑动到最顶部
    if (mSumDy + dy < 0) {
        travel = -mSumDy;
    } else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
        travel = mTotalHeight - getVerticalSpace() - mSumDy;
    }

    mSumDy += travel;
    // 平移容器内的item
    offsetChildrenVertical(-travel);
    return dy;
}

mSumDy + dy 表示当前的移动距离,mTotalHeight - getVerticalSpace()表示当滑动到底时滚动的总距离;

1.4 给普通基于适配器的View加入一些附加特性

1.4.1 Item Decorations支持:

RecyclerView 有一个很好的特性 RecyclerView.ItemDecoration,后面会详细介绍,它可以给 子视图添加自定义样式,还可以在不修改子视图布局参数的情况下插入 布局属性(margins)。后者就是 LayoutManager 必须提供的约束子视图布局方式。

LayoutManager 中提供了一些辅助方法操作 decorations ,不需要我们自己实现:

  • getDecoratedLeft()代替child.getLeft()获取子视图的 left 边缘。
  • getDecoratedTop()代替getTop()获取子视图的 top 边缘。
  • getDecoratedRight()代替getRight()获取子视图的 right 边缘。
  • getDecoratedBottom()代替getBottom()获取子视图的 bottom 边缘。
  • 使用 measureChild()measureChildWithMargins() 代替child.measure() 测量来自 Recycler 的新视图。
  • 使用layoutDecorated() 代替 child.layout() 布局来自 Recycler 的新视图。
  • 使用 getDecoratedMeasuredWidth()getDecoratedMeasuredHeight() 代替 child.getMeasuredWidth()或 child.getMeasuredHeight()获取 子视图的测量数据。

1.4.2 数据集改变

当使用 notifyDataSetChanged()触发 RecyclerView.Adapter 的更新操作时, LayoutManager 负责更新布局中的视图。这时,onLayoutChildren()会被再次调用。
实现这个功能需要我们调整代码,判断出当前状态是生成一个新的视图 还是 adapter 更新期间的视图改变。

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //We have nothing to show for an empty data set but clear any existing views
    if (getItemCount() == 0) {
        detachAndScrapAttachedViews(recycler);
        return;
    }

    //...on empty layout, update child size measurements
    if (getChildCount() == 0) {
        //Scrap measure one child
        View scrap = recycler.getViewForPosition(0);
        addView(scrap);
        measureChildWithMargins(scrap, 0, 0);

        /*
         * We make some assumptions in this code based on every child
         * view being the same size (i.e. a uniform grid). This allows
         * us to compute the following values up front because they
         * won't change.
         */
        mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
        mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);

        detachAndScrapView(scrap, recycler);
    }

    updateWindowSizing();

    int childLeft;
    int childTop;
    if (getChildCount() == 0) { //First or empty layout
        /*
         * Reset the visible and scroll positions
         */
        mFirstVisiblePosition = 0;
        childLeft = childTop = 0;
    } else if (getVisibleChildCount() > getItemCount()) {
        //Data set is too small to scroll fully, just reset position
        mFirstVisiblePosition = 0;
        childLeft = childTop = 0;
    } else { //Adapter data set changes
        /*
         * Keep the existing initial position, and save off
         * the current scrolled offset.
         */
        final View topChild = getChildAt(0);
        if (mForceClearOffsets) {
            childLeft = childTop = 0;
            mForceClearOffsets = false;
        } else {
            childLeft = getDecoratedLeft(topChild);
            childTop = getDecoratedTop(topChild);
        }

        /*
         * Adjust the visible position if out of bounds in the
         * new layout. This occurs when the new item count in an adapter
         * is much smaller than it was before, and you are scrolled to
         * a location where no items would exist.
         */
        int lastVisiblePosition = positionOfIndex(getVisibleChildCount() - 1);
        if (lastVisiblePosition >= getItemCount()) {
            lastVisiblePosition = (getItemCount() - 1);
            int lastColumn = mVisibleColumnCount - 1;
            int lastRow = mVisibleRowCount - 1;

            //Adjust to align the last position in the bottom-right
            mFirstVisiblePosition = Math.max(
                    lastVisiblePosition - lastColumn - (lastRow * getTotalColumnCount()), 0);

            childLeft = getHorizontalSpace() - (mDecoratedChildWidth * mVisibleColumnCount);
            childTop = getVerticalSpace() - (mDecoratedChildHeight * mVisibleRowCount);

            //Correct cases where shifting to the bottom-right overscrolls the top-left
            // This happens on data sets too small to scroll in a direction.
            if (getFirstVisibleRow() == 0) {
                childTop = Math.min(childTop, 0);
            }
            if (getFirstVisibleColumn() == 0) {
                childLeft = Math.min(childLeft, 0);
            }
        }
    }

    //Clear all attached views into the recycle bin
    detachAndScrapAttachedViews(recycler);

    //Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler);
}

我们根据有没有已经被 attach 的子视图来判断当前是一个新的布局还是一个更新操作。 如果是更新,我们根据第一个可见视图的 position(通过监测视图左上角是哪个子视图) 和当前 x/y 滑动的位移这些信息去执行新的 fillGrid(),同时保证左上角的 item 位置不变。

下面是一些需要特殊处理得情况:

  • 当新的数据集很小,不足以滑动时,布局会将左上角重置为 position 是 0 的item。
  • 如果新的数据集很小,保持当前位置会使滚动超出边界。 我们就应该调整第一个 item 的位置,以便和右下角对齐。

1.4.3 onAdapterChanged()

这个方法提供了另一个重置布局的场所,设置新的 adapter 会触发这个事件 (在这,setAdapter会被再次调用)。 这个阶段你可以安全的返回一个与之前 adapter 完全不同的视图。所以, 示例中我们移除了所有当前视图(并没有回收它们)。

@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) {
    //Completely scrap the existing layout
    removeAllViews();
}

移除视图会触发一个新的布局过程,当 onLayoutChildren() 被再次调用时, 我们的代码会执行创建新视图的布局过程,因为现在没有 attched 的子视图。

1.4.4 Scroll to Potion

其一个重要的特性就是给LayoutManager添加滚动到特定位置的功能。可以带有动画效果,也可以没有

  • scrollToPositon()
    当 layout 应该立即将所给位置设为第一个可见 item 时,调用 RecyclerView 的 scrollToPosition()。 在一个 vertical list 里,item 应该放在顶部;horizontal list 中,通常放在左边。
@Override
public void scrollToPosition(int position) {
    if (position >= getItemCount()) {
        Log.e(TAG, "Cannot scroll to "+position+", item count is "+getItemCount());
        return;
    }

    //Ignore current scroll offset, snap to top-left
    mForceClearOffsets = true;
    //Set requested position as first visible
    mFirstVisiblePosition = position;
    //Trigger a new view layout
    requestLayout();
}
  • smoothScrollToPosition()
    在带有动画的情况下,我们需要使用一些稍微不同的方法。 在这方法里我们需要创建一个 RecyclerView.SmoothScroller实例, 然后在方法返回前请求startSmoothScroll()启动动画。

RecyclerView.SmoothScroller 是提供 API 的抽象类,含有四个方法:

  • onStart():当滑动动画开始时被触发
  • onStop():当滑动动画停止时被触发
  • onSeekTargetStep():当 scroller 搜索目标 view 时被重复调用,这个方法负责读取提供的 dx/dy ,然后更新应该在这两个方向移动的距离。
    • 这个方法有一个RecyclerView.SmoothScroller.Action实例做参数。 通过向 action 的 update()方法传递新的 dx, dy, duration 和 Interpolator , 告诉 view 在下一个阶段应该执行怎样的动画。
    • NOTE: 如果动画耗时过长,框架会对你发出警告, 应该调整动画的步骤,尽量和框架标准的动画耗时相同。
  • onTargetFound():只在目标视图被 attach 后调用一次。 这是将目标视图要通过动画移动到准确位置最后的场所。
    • 在内部,当 view 被 attach 时使用 LayoutManager 的 findViewByPosition() 方法 查找对象。如果你的 LayoutManager 可以有效匹配 view 和 position , 可以覆写这个方法来优化性能。默认提供的实现是通过每次遍历所有子视图查找

你可以自己实现一个 scroller 达到你想要的效果。不过这里我们只使用系统提供的 LinearSmoothScroller 就好了。只需实现一个方法computeScrollVectorForPosition(), 然后告诉 scroller 初始方向还有从当前位置滚动到目标位置的大概距离。

@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, final int position) {
    if (position >= getItemCount()) {
        Log.e(TAG, "Cannot scroll to "+position+", item count is "+getItemCount());
        return;
    }

    /*
     * LinearSmoothScroller's default behavior is to scroll the contents until
     * the child is fully visible. It will snap to the top-left or bottom-right
     * of the parent depending on whether the direction of travel was positive
     * or negative.
     */
    LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) {
        /*
         * LinearSmoothScroller, at a minimum, just need to know the vector
         * (x/y distance) to travel in order to get from the current positioning
         * to the target.
         */
        @Override
        public PointF computeScrollVectorForPosition(int targetPosition) {
            final int rowOffset = getGlobalRowOfPosition(targetPosition)
                    - getGlobalRowOfPosition(mFirstVisiblePosition);
            final int columnOffset = getGlobalColumnOfPosition(targetPosition)
                    - getGlobalColumnOfPosition(mFirstVisiblePosition);

            return new PointF(columnOffset * mDecoratedChildWidth, rowOffset * mDecoratedChildHeight);
        }
    };
    scroller.setTargetPosition(position);
    startSmoothScroll(scroller);
}

1.5 给LayoutManager添加合适动画效果

前面提到了notifyDataSetChanged(),但是使用这个方法 不会有数据改变的动画效果。
RecyclerView 提供了新的 API 让我们可以通知 adapter 做出带有动画效果的改变。它们是:

  • notifyItemInserted()notifyItemRangeInserted(): 在给定位置/范围插入新item(s)。
  • notifyItemChanged()notifyItemRangeChanged(): 使给定 位置/范围 的 item(s) 无效,数据集并没有结构上的改变。
  • notifyItemRemoved()notifyItemRangeRemoved(): 移除给定 位置/范围 的 item(s)。
  • notifyItemMoved(): 将数据集中的一个 item 重定位到一个新的位置。

使用这些方法你的 LayoutManager 会得到一个很简单的默认 item 动画。 这些动画是根据当前每一个 view 在改变后是否还存在于 layout 之中生成的。 新的 view 渐入,被移除的 view 淡出,其他 view 移动到新的位置。

1.5.1 Predictive Item Animations

下面的图片展示了我们期望的移除 item 动画效果:

[外链图片转存失败(img-dlATBv2b-1562586666239)(https://camo.githubusercontent.com/a0e7c34c67f516b9e91521fd7882264b4e9ccac3/687474703a2f2f692e656d6265642e6c792f312f646973706c61792f726573697a653f75726c3d6874747025334125324625324677697265736172656f62736f6c6574652e636f6d25324677702d636f6e74656e7425324675706c6f61647325324632303135253246303225324652656379636c65436f6e63657074536d616c6c2e6769662667726f773d74727565266b65793d3932623331313032353238353131653161326563343034306433646335633037266865696768743d343030)]
列 items 滑到右侧填补空白部分的动画很引人注意。 和这个差不多,你可以脑补出在这个位置添加一个 item 时的动画效果。
onLayoutChildren() 通常只会 在父控件 RecyclerView 初始化布局 或者 数据集的大小(比如 item 的数量)改变时调用一次。 Predictive Item Animations 这个特性允许我们给 view (基于数据改变产生)的过渡动画 提供更多有用的信息。想要使用这个特性,就要告诉 框架我们的 LayoutManager 提供了这个附加数据:

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

有了这个改动,onLayoutChildren() 会在每次数据集改变后被调用两次, 一次是"预布局"(pre-layout)阶段,一次是真实布局(real layout)。

  • 在 pre-layout 阶段该做些什么

在onLayoutChildren()的 pre-layout 阶段, 你应该运行你的布局逻辑设置动画的初始状态。 这需要你在动画执行前布局所有 当前可见的 view 和 在动画后会可见的 view (被称为 APPEARING view)。Appearing views 应该被布局在 屏幕之外,用户期望它进入的位置。框架会捕获他们的位置, 籍此创建更合适的动画效果。

我们可以使用 RecyclerView.State.isPreLayout()来检测当前处于哪一阶段

我们根据数据集的改变 使用 pre-layout 决定哪些 view 被移除。被移除的 view 在 pre-layout 的 Recycler 中仍然会被返回,所以你不用担心 会出现空白位置。如果你想要判断视图是否会被移除, 可以使用LayoutParams.isViewRemoved() 这个方法 。

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    …

    SparseIntArray removedCache = null;
    /*
     * During pre-layout, we need to take note of any views that are
     * being removed in order to handle predictive animations
     */
    if (state.isPreLayout()) {
        removedCache = new SparseIntArray(getChildCount());
        for (int i=0; i < getChildCount(); i++) {
            final View view = getChildAt(i);
            LayoutParams lp = (LayoutParams) view.getLayoutParams();

            if (lp.isItemRemoved()) {
                //Track these view removals as visible
                removedCache.put(lp.getViewPosition(), REMOVE_VISIBLE);
            }
        }}//Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler, state.isPreLayout(), removedCache);}

示例代码中最后的变动是对fillGrid()进行修改,在这里给 N 个 appearing views 布局,N 是被移除的可见视图个数。 这些 view 永远是从右侧进入的,所以他们被安排在最后一列 可见 view 的后面。

private void fillGrid(int direction, int emptyLeft, int emptyTop, RecyclerView.Recycler recycler,
        boolean preLayout, SparseIntArray removedPositions) {for (int i = 0; i < getVisibleChildCount(); i++) {
        int nextPosition = positionOfIndex(i);if (i % mVisibleColumnCount == (mVisibleColumnCount - 1)) {
            leftOffset = startLeftOffset;
            topOffset += mDecoratedChildHeight;

            //During pre-layout, on each column end, apply any additional appearing views
            if (preLayout) {
                layoutAppearingViews(recycler, view, nextPosition, removedPositions.size(),);
            }
        } else {
            leftOffset += mDecoratedChildWidth;
        }
    }}

private void layoutAppearingViews(RecyclerView.Recycler recycler, View referenceView,
        int referencePosition, int extraCount, int offset) {
    //Nothing to do...
    if (extraCount < 1) return;

    for (int extra = 1; extra <= extraCount; extra++) {
        //Grab the next position after the reference
        final int extraPosition = referencePosition + extra;
        if (extraPosition < 0 || extraPosition >= getItemCount()) {
            //Can't do anything with this
            continue;
        }

        /*
         * Obtain additional position views that we expect to appear
         * as part of the animation.
         */
        View appearing = recycler.getViewForPosition(extraPosition);
        addView(appearing);

        //Find layout delta from reference position
        final int newRow = getGlobalRowOfPosition(extraPosition + offset);
        final int rowDelta = newRow - getGlobalRowOfPosition(referencePosition + offset);
        final int newCol = getGlobalColumnOfPosition(extraPosition + offset);
        final int colDelta = newCol - getGlobalColumnOfPosition(referencePosition + offset);

        layoutTempChildView(appearing, rowDelta, colDelta, referenceView);
    }
}

layoutAppearingViews()这个方法里,每一个 appearing view 被布局到它的"全局"位置(就是它在这个网格中占据的行/列)。 虽然位置在屏幕之外,但是为框架创建滑入 view 动画的起始点提供了 必要的数据。

  • Changes for the “Real” Layout

前面已经讨论布局期间的工作,然而要想为我们的动画提供支持还要做一些修改。 其中之一就是判断有没有 disappearing views。通过运行一个普通的布局过程,然后检查 Recycler 的 scrap heap 之中有没有剩下的 view。

仍在 scrap 中没有被移除的视图就是 disappearing views。 我们需要把它们放置到屏幕之外的位置,以便动画系统 将它们滑出视图(用来取代淡出动画)。

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {if (!state.isPreLayout() && !recycler.getScrapList().isEmpty()) {
        final List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
        final HashSet<View> disappearingViews = new HashSet<View>(scrapList.size());

        for (RecyclerView.ViewHolder holder : scrapList) {
            final View child = holder.itemView;
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            if (!lp.isItemRemoved()) {
                disappearingViews.add(child);
            }
        }

        for (View child : disappearingViews) {
            layoutDisappearingView(child);
        }
    }
}

private void layoutDisappearingView(View disappearingChild) {
    /*
     * LayoutManager has a special method for attaching views that
     * will only be around long enough to animate.
     */
    addDisappearingView(disappearingChild);

    //Adjust each disappearing view to its proper place
    final LayoutParams lp = (LayoutParams) disappearingChild.getLayoutParams();

    final int newRow = getGlobalRowOfPosition(lp.getViewPosition());
    final int rowDelta = newRow - lp.row;
    final int newCol = getGlobalColumnOfPosition(lp.getViewPosition());
    final int colDelta = newCol - lp.column;

    layoutTempChildView(disappearingChild, rowDelta, colDelta, disappearingChild);
}

布局视图(然后将它们加入container)把它们从 scrap 列表中移除。 在开始变化前,小心处理你需要从 scrap 中获取的视图,否则你可能会 在这个集合上出现并发修改的问题结束运行。

外链图片转存中…(img-3mEH0IPm-1562587148902)]

  • 黑框是 RecyclerView 的可视边界。
  • Red View:数据集中被移除的 item。
  • Green View (Appearing View):开始时没有,在 pre-layout 过程中被布局到屏幕外的item。
  • Purple Views (Disappearing views):pre-layout 时期放置在他们的原始位置 , real-layout 时期被布局到屏幕之外的位置。

1.5.2 响应屏幕外的变动

你或许注意到在上一节中我们可以判断可视 views 的移除操作。 如果变化出现在可视边界之外会怎样?这取决于你的布局结构, 像这样的变化可能需要你调整布局来达到更好的动画效果。

Adapter 会将这个变化 post 给你的 LayoutManager。你可以覆写 onItemsRemoved(), onItemsMoved(), onItemsAdded() 或者 onItemsChanged() 响应 item 的这些事件,无论 item 在当前布局中是否可见。

如果被移除的范围在可视边界之外, 调用 pre-layout 之前会调用 onItemRemoved()。我们可以利用它收集和这个变化有关的数据,为 这个事件可能触发的 appearing view 改变提供更好的支持。

示例中,我们像之前一样收集被移除的 view,但是将它们标记成不同的类型。

@Override
public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int itemCount) {
    mFirstChangedPosition = positionStart;
    mChangedPositionCount = itemCount;
}

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    …

    SparseIntArray removedCache = null;
    /*
     * During pre-layout, we need to take note of any views that are
     * being removed in order to handle predictive animations
     */
    if (state.isPreLayout()) {//Track view removals that happened out of bounds (i.e. off-screen)
        if (removedCache.size() == 0 && mChangedPositionCount > 0) {
            for (int i = mFirstChangedPosition; i < (mFirstChangedPosition + mChangedPositionCount); i++) {
                removedCache.put(i, REMOVE_INVISIBLE);
            }
        }
    }//Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler, state.isPreLayout(), removedCache);}

TIP:如果被移除的 item 是可见的,这个方法在 pre-layout 之后还会被调用。这也就是为什么 当被移除的可见 views 出现时我们仍要从它们获取数据。

你可能感兴趣的:(Android视图动效,Android基础)