[译文] Building a RecyclerView LayoutManager

原文连接 Building a RecyclerView LayoutManager

创建一个RecyclerView的LayoutManager Part 1

这是这个系列的第一篇文章,还有第二篇和第三篇

现在,如果你是一个安卓开发者,你肯定听说过RecyclerView,一个通过支持视图重用,使得高性能视图的自定义实现更加容易的组件将会被添加到support库中。一些人已经对如何使用RecyclerView的基础知识(动画)做了非常详细的描述。所以这里不再深入讲解,以下的文章可以帮助你快速了解RecyclerView:

  • A First Glance at Android's RecyclerView
  • The new TwoWayView
  • RecyclerViewItemAnimators

在这一系列文章里,我们将关注如何花最少的代价去构建一个自己的LayoutManager,从而去实现一些比单纯的垂直或水平滚动列表更复杂地事情。

RecyclerView Playground

这系列文章展示地所有代码已经上传到GitHubRecyclerView Playground sample。这个例子包括RecyclerView的各方面内容,从建立一个简单的列表到自定义一个LayoutManager。
本文的代码例子是FixedGridLayoutManager:一个在水平和垂直方向都能滚动的二维的gridLayout。

                                                 Support Library Samples 
support 库也提供了自定义LayoutManager的例子,实际上是一个自定义的垂直线性列表,sdk路径为:
/extras/android/compatibility/samples/Support7Demos/src/com/example/android/supportv7/widget/RecyclerViewActivity.java
尽管许多的Android'L'和新的support库可能还不在AOSP里面 ,RecyclerView支持通过导入JAR文件使用,你可以在这里找到它:
/extras/android/m2repository/com/android/support/recyclerview-v7/21.0.0-rc1/recyclerview-v7-21.0.0-rc1-sources.jar

The Recycler

首先来了解一下RecyclerView的API组成。当你的RecyclerView需要回收旧的view或者从回收的view中获取一个新的view的时候,LayoutManager发挥着至关重要的作用。
当adapter需要的时候,RecyclerView也会直接移除view。当你的LayoutManager需要一个新的子view时,只需要调用getViewForPosition(),RecyclerView就会返回一个绑定好数据的view。RecyclerView会确定是否需要创建一个新的view,或者重用已有的废弃的view。同时确保不可见的view及时地回收。这样,RecyclerView就不会创建出多余的view。

解绑 vs 移除

当一个view变为不可见的时候,有两种方法去处理它们:解绑和移除。解绑 意味着view只需要少量的改变就可以重新显示到视图上。解绑的view会被用于重新绑定直接返回。这样,这些view不需要重新创建或者绑定数据,只要修改位置就能重新绑定到视图上。
移除就意味着这些view不再需要了。任何被永久移除地view都应该被放到回收池中重新使用,但也不是强制要求这样做。这取决于你移除的视图是否会被重用。

Scrap vs Recycle

RecyclerView有一个两级的缓存系统:scrap堆和recycle池。scrap堆是一个轻量集合,里面的view可以不经过adapter直接回到LayoutManager。当view暂时地解绑但可以直接重用的时候,它们通常都会被放到这里。
recycle池是由一些数据不正确的view组成的(就是显示的数据跟所在的位置对不上),所以当它们回到LayoutManager之前需要经过adapter重新绑定数据。
当LayoutManager需要一个新的view得时候,RecyclerView会先到scrap堆里面找一下有没有位置匹配的view,如果有,就直接返回。如果没有,RecyclerView会从recycle池中取一个合适的view并在adapter里面绑定必要的数据( RecyclerView.Adapter.bindViewHolder() 方法会被调用)然后返回。如果recycle池里没有合适的view,就会创建一个新的view( RecyclerView.Adapter.createViewHolder() 方法会被调用),绑定数据然后返回。

回收的规则

如果你希望的话,LayoutManager提供的API能让你自己去完成这些工作,所以组成的可能性有很多。一般来说,如果view只是暂时不可见并且会以相同的布局重新绑定到视图上的就调用detachAndScrapView()。如果不再需要当前这种布局的view就调用removeAndRecyclerView()

创建的核心

LayoutManager负责实时绑定,测量和布局所有的子view。当用户滑动界面的时候,LayoutManager会决定什么时候添加新的子view,什么时候解绑和舍弃旧的子view。
你需要重写并实现下面的方法来创建一个简单可用的LayoutManager。

generateDefaultLayoutParams()

实际上这是唯一一个必须重写的方法。这个方法的实现非常简单,只需要返回一个你想要应用到RecyclerView的所有子view的LayoutParams实例即可。在getViewForPosition()这个方法执行完成前,这个返回值会应用到每一个子view。

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

这是LayoutManager的一个重要的方法。当你的view需要初始化布局,或者适配器数据发生改变(或者更换适配器)的时候,这个方法就会被调用。并不是每一个布局的改变都会调用这个方法。在这个方法里你可以重新布局子view的视图和改变数据。
接下来,我们会了解到当adapter刷新的时候,它是如何去刷新当前可见的item的布局的。现在,我们会简单地梳理一下子view的布局流程。以下是一个简单地FixedGridLayoutManager例子:

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler,
                             RecyclerView.State state) {
    //测量view
    View scrap = recycler.getViewForPosition(0);
    addView(scrap);
    measureChildWithMargins(scrap, 0, 0);

    /*
     * 我们假设所有的子view都是一样大的,这样就可以推算出接下来的大小了
     */
    mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
    mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
    detachAndScrapView(scrap, recycler);

    updateWindowSizing();
    int childLeft;
    int childTop;

    /*
     * 重置第一个可见的item的下标
     */
    mFirstVisiblePosition = 0;
    childLeft = childTop = 0;

    //清除所有已经绑定的view
    detachAndScrapAttachedViews(recycler);
    //填充recyclerView
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler);
}

这里我们做了一些设置(为了简单起见,layoutManager假设所有的子view都是一样大的)同时确保所有的view都在scrap堆里。为了可重用,我将大部分的工作都写在了fillGrid()方法里了。当RecyclerView滚动时,我们会看到这个方法会被调用去刷新可见的view。

就像ViewGroup的实现一样,你需要测量和布局所有从RecyclerView获得的view。这些工作都需要你自己去完成。

一般来说,你需要在这个方法完成的事有以下几样:

  • 在滑动事件触发后,检测当前绑定绑定视图的偏移位置
  • 确定滑动的距离是否有足够去添加从RecyclerView中获取的view
  • 确定是否存在不再需要显示的view,有的话就移除
  • 确定是否有应该被调整的view。为了跟适配器的位置信息对应,可能需要修改这些view的索引信息

填充RecyclerView的主要步骤我们已经写在FxiedGridLayoutManager.fillGrid(),layoutManager是将view按行从左到右排列:

  • 缓存所有现有的view,解绑它们便于后面重新绑定。
SparseArray viewCache = new SparseArray(getChildCount());
//...
if (getChildCount() != 0) {
    //...
    //缓存所有的view
    for (int i=0; i < getChildCount(); i++) {
        int position = positionOfIndex(i);
        final View child = getChildAt(i);
        viewCache.put(position, child);
    }

    //暂时解绑所有的view
    // Views we still need will be added back at the proper index.
    for (int i=0; i < viewCache.size(); i++) {
        detachView(viewCache.valueAt(i));
    }
}
  • 测量和布局当前可见的view。部分view是需要从缓存获取重新绑定的,还有部分是需要从RecyclerView中获取的。
for (int i = 0; i < getVisibleChildCount(); i++) {
    //...

    //Layout this position
    View view = viewCache.get(nextPosition);
    if (view == null) {
        /*
         * Recyclerview会创建一个新的view或者重用一个view,并且adapter会
         * 为我们绑定好数据
         */
        view = recycler.getViewForPosition(nextPosition);
        addView(view);

        /*
         *测量和布局新的view
         */
        measureChildWithMargins(view, 0, 0);
        layoutDecorated(view, leftOffset, topOffset,
        leftOffset + mDecoratedChildWidth,
        topOffset + mDecoratedChildHeight);
    } else {
        //重新绑定数据
        attachView(view);
        viewCache.remove(nextPosition);
    }

    //...
}
  • 最后,回收所有在第一步解绑的不再可见的view便于后面再使用
for (int i=0; i < viewCache.size(); i++) {
    recycler.recycleView(viewCache.valueAt(i));
}

我们解绑所有的view然后重新绑定我们需要的是为了确保所有子view的索引(getChildAt())都是正确的。我们希望可见的view是从左上角第0个开始到右下角getChildCount() -1个结束。当我们在两个方向上滑动,子view重新绑定时,顺序就会变得不可靠。我们需要这个顺序去确保每一个子view的位置。在简单的LayoutManager(LinearLayoutManager),子view可以简单地插到列表尾部的,这种操作就不是必须的了。

添加交互动作

到这一步,我们已经有一个初始化好的布局,但是却无法移动。RecyclerView主要的功能点就是在用户滑动数据集的时候动态地提供view。我们需要重写几个方法让它能够滑动。

canScrollHorizontally() & canScrollVertically()

这两个方法很简单,如果你想支持该方向的滑动,就返回true,否则返回false。

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

这里你需要实现内容滚动的逻辑。滑动和惯性滑动的逻辑RecyclerView已经帮我们处理了,所以不需要处理手势监听和滑动事件。在这两个方法里你需要做下面三样事情:
1.为所有的子view移动适当的距离。
2.确定滑动时是否需要添加或者移除view去填充视图
3.返回实际的滑动距离,系统用这个值去判断什么时候碰到了边界。
在FixedGridLayoutManager里,这两个方法很相似,下面是垂直滑动的实现:

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

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

    //获取第一个view
    final View topView = getChildAt(0);
    //获取最后一个view
    final View bottomView = getChildAt(getChildCount()-1);

    //数据集太小不能滑动,直接返回
    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) { // 上滑
        //边界检测
        if (bottomBoundReached) {
            //已经在底部,限制滑动距离
            int bottomOffset;
            if (rowOfIndex(getChildCount() - 1) >= (maxRowCount - 1)) {
                //已经在底部,计算距离
                bottomOffset = getVerticalSpace()
                        - getDecoratedBottom(bottomView) + getPaddingBottom();
            } else {
                /*
                 * 一个view不是完全可见,计算实际的滑动距离
                 */
                bottomOffset = getVerticalSpace()
                        - (getDecoratedBottom(bottomView) + mDecoratedChildHeight)
                        + getPaddingBottom();
            }
            delta = Math.max(-dy, bottomOffset);
        } else {
            //不在底部,没有限制
            delta = -dy;
        }
    } else { // 下滑
        //是否到顶部
        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);
        }
    }

    /*
     * 返回值决定是否到边界
     * 如果返回值跟传过来的不一样
     * RecyclerView就会显示一个到达边界的效果
     */
    return -delta;
}

注意,这里的是距离(dx/dy)的增量,这个参数决定了滑动的距离(方向)是否会超过内容的长度,触碰到边界。如果会,我们需要计算出视图滑动的实际距离。
我们必须在这个方法里手动移动子views,offsetChildrenVertical()offsetChildrenHorizontal()帮助我们协调view的移动。如果你不这样做,你的view就不能移动。当移动完这些view后,根据滑动的方向会触发另一个操作去用view重新填充视图。
最后,我们返回实际使用的滑动距离。RecyclerView使用这个值去决定滚动时是否需要绘制到达底部或顶部的边界效果。一般来说,如果返回的值跟传递过来的dx/dy不相等,就需要绘制边界效果。如果你返回一个不正确的值,系统就会把它变成一个很大的值,就会错误地显示出边界效果。
除了绘制边界效果外,这个返回值还用于决定是否取消惯性滑动。返回错误的值,系统会认为你已经触碰到边界,惯性滑动就不会执行。

Just Getting Warmed Up

现在,我们已经实现了基础功能,虽然缺少了一些细节,但视图的滑动和view的回收都已经实现了。接下来将会讨论更多关于自定义LayoutManager的内容。下一篇文章我们会处理decorations,数据的改变和滑动到指定位置的实现。

ps:这是一篇比较旧的文章了,第一次翻译,有什么理解不对的,不通顺的地方,欢迎在下方留言指出,我会更改的。

你可能感兴趣的:([译文] Building a RecyclerView LayoutManager)