原文连接 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:这是一篇比较旧的文章了,第一次翻译,有什么理解不对的,不通顺的地方,欢迎在下方留言指出,我会更改的。