理解RecyclerView LayoutManager(二)
在上一篇文章中,我们简单介绍了实现一个自定LayoutManager几个核心的方法。在着接下来这篇文章中我们将会让这个layout Manager支持其他一些必要的功能。
Supporting Item Decorations 支持Item Decorations
对于在子View上绘制一些自定义的内容或者在不改变子View layout parameter的情况下修改子View的margin等操作,RecyclerView提供了一个非常简洁的类: RecyclerView.ItemDecoration,来实现这些功能。其中后一个功能规定了子View应该如何布局是 LayoutManager必须实现的功能。
LayoutManager给我们提供了一些辅助的方法来帮助我们支持decorations:
- 使用
getDecoratedLeft()代替child.getLeft()来获取子View的左边对应的值 - 使用
getDecoratedRight()代替child.getRight()来获取子View的右边对应的值 - 使用
getDecoratedTop()代替child.getTop()来获取子View的上边对应的值 - 使用
getDecoratedBottom()代替child.getBottom()来获取子View的下边对应的值 - 使用
measureChild()或者measureChildWithMargins()代替child.measure()来测量从Recycler中返回的新View。 - 使用
layoutDecorated()代替child.layout()来布局一个从Recycler中返回的新View - 使用
getDecoratedMeasuredWidth()和getDecoratedMeasuredHeight()代替child.getMeasuredWidth()和child.getMeasuredHeight()获得子View的测量值
只要采用了上面这些方法来获取View的属性和测量值,RecyclerView就会自动帮我们处理好Item Decorations。
Data Set Changes 数据集合发生改变
当已经attach的RecyclerView.Adapter通过notifyDataSetChanged()触发更新时,LayoutManager会负责更新这个View中的布局。在这种情况下onLayoutChildren()会被再次触发。为了支持这个功能,我们首先需要区分是完全创建一个新的布局还是由于Adapter更新导致的布局改变(怎样理解这句话?举个例子,创建一个新的布局就对应于填充一个还没有任何数据的RecyclerView,Adapter更新导致的布局改变对应于向已有数据的RecyclerView中填充数据。)。下面就是FixedGridLayoutManager中部分代码:
@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 overscroll when shifting to the bottom-right
// 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);
}
上面这段代码中,我们通过判断是否已经有子View被attach来确定这是完全创建一个新的布局还是由于Adapter更新导致的布局改变。在更新的情况下,第一个可见的位置和当前的x/y偏移量可以帮助我们添加新的View,同时保持同样的item仍然在top-left位置。
下面有两种情况是需要我们处理的:
- 如果放入Adapter中的数据太少,以至于不能够发生滚动,那么就会把在top-left的item 的position置为0。(换句话说就是item没有填充满RecyclerView,没法滚动)
- 如果新的数据集合小于原先的数据集合,那么仍然保留当前的位置就可能导致滚动超出边界。(可以这么理解:然如原先有100个Item,新的数据只有10个Item,如果上次停在地50个Item的位置,仍然保留50的话就超出了新数据的范围了。)所以在这里就把第一个位置调整到网格的右下角。(这句话感觉很奇怪,没有很好的理解,这里我放上原文:Here we adjust the first position so the layout aligns with the bottom-right of the grid.)
onAdapterChanged()
在整个Adapter被换掉(例如:setAdapter()被调用)时,可以重新设置layout。在这种情况下,我们必须假设新的Adapter返回的View和之前的Adapter返回的完全不同,以免造成不必要的麻烦。所以我们直接移除所有的View而不是像之前一样回收这些View,代码如下:
@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter,
RecyclerView.Adapter newAdapter) {
//Completely scrap the existing layout
removeAllViews();
}
移除这些View会触发一个新的layout传递过来,当onLayoutChildren() 方法被重新调用,将会产生一个新的layout,因为RecyclerView中没有其他任何的View。
Scroll to Position滚动到制定位置
另一个需要自定义LayoutManager提供的功能是支持滚动到指定的位置。这种滚动有支持和不支持动画效果两种方式,并且每种方式都有相应的回调。
scrollToPosition()
当这个方法被调用时,RecyclerView会立刻滚动到指定位置对应的Item。在一个垂直滚动的列表中,这个Item会被放置到顶部;在水平滚动的列表中,这个Item会被放置到左边。在我们grid的例子中,这个被选中的位置,会被放置到这个View的左上角。下面是代码:
@Override
public void scrollToPosition(int position) {
if (position >= getItemCount()) {
Log.e(TAG, "Cannot scroll to "+position+", item count "+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();
}
通过合理的实现onLayoutChildren(),可以完成更新目标位置触发填充功能。
smoothScrollToPosition()
和上面scrollToPosition()方法不同点在于,这个方法触发的滚动是有动画的。因此我们需要采取一些不同的措施。实现这个功能要求我们在LayoutManager中构建一个RecyclerView.SmoothScroller实例,通过调用startSmoothScroll()方法开始相应的动画。
RecyclerView.SmoothScroller是由4个必须实现的方法组成的抽象类:
-
onStart():在开始滚动的时候触发。 -
onStop():在结束滚动的时候触发。 -
onSeekTargetStep(): 在scroller查找目标View的时候被逐步调用。这个方法负责读取提供的滚动距离dx/dy并且负责确定在x/y这两个方向上的实际滚动距离。这个方法需要传入一个RecyclerView.SmoothScroller.Action对象。然后通过这个action对象的update()方法来通知RecyclerView在下一次滚动的dx/dy、时间(duration)、插值器(Interpolator)。
如果动画的时间过长(或者滚动的距离过小)framework就会发出警告。确保动画的时间符合framework的要求。
-
onTargetFound():在目标View被attach后,会被调用且只调用一次。这是在目标View在定位到确切位置之前最后一次展示动画的机会。在这个方法内部,使用LayoutManager的findViewByPosition()方法来确定View在什么时候被attach。如果自定义的LayoutManager需要在映射View时更加高效,就需要重载这个方法来改善性能。默认的实现会在所有需要的时候都遍历所有的子View。
如果你需要微调滚动动画,那么就需要提供自定义的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 "+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.
*/
final Context context = recyclerView.getContext();
LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
/*
* 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);
}
这个方法的实现和ListView非常相似,当所有的子View都可见时,就会停止滚动。
Now What?这样就够了?
现在基本已经可以满足我们对于一个LayoutManager所需要的一般功能了。下面一篇文章将会介绍在data set改变时触发更新动画。