本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
哈哈哈,起这个标题并非标题党,这次真的是飞龙在天。至于为什么是第十一式呢,因为这刚好是第十一篇文章。
好,先来几张效果图:
是不是感觉很好玩的样子呢?上面的每一个Item(图片)都是可以能够像普通列表那样接受触摸事件的哦,因为这本质上也只是一个RecyclerView而已。
其实这三张图都是有含义的:
所以,大家趁年轻努力学习,将来为实现中华民族伟大复兴的中国梦添砖加瓦!
好,下面我们开始进入正题
所谓知己知彼,方能百战百胜。在自定义LayoutManager之前,先来对它作个初步的了解:
我们知道,在使用RecyclerView的时候,必须要set一个LayoutManager才能正常显示数据,因为RecyclerView把Item都交给它来layout了,没有layout,肯定是看不到了。
既然自定义LayoutManager也需要layout,那它跟我们平时熟悉的自定义ViewGroup又有什么不同之处呢?
首先,我们平时在自定义ViewGroup的时候,测量子View是在onMeasure方法中统一测量的;
而在自定义LayoutManager中,子View是当需要layout的时候才测量,LayoutManager已经提供了两个方法给我们直接调用了:
measureChild(View child, int widthUsed, int heightUsed)
measureChildWithMargins(View child, int widthUsed, int heightUsed)
这两个方法都可以测量子View,不同的是第二个方法会把Item设置的Margin也考虑进去,所以如果我们的LayoutManager需要支持Margin属性的话,就用第二个了。
在Item测量完之后,我们就可以获取到Item的尺寸了,但这里并不推荐直接用getMeasuredWidth或getMeasuredHeight方法来获取,而是建议使用这两个:
getDecoratedMeasuredWidth(View child)
getDecoratedMeasuredHeight(View child)
这两个方法是LayoutManager提供的,其实它们内部也是会调用child的getMeasuredWidth或getMeasuredHeight的,只是在返回的时候,会考虑到Decorations的大小,并根据Decorations的尺寸对应的放大一点,所以如果我们有设置ItemDecorations的话,用这两个方法得到的尺寸往往会比直接调用getMeasuredWidth或getMeasuredHeight方法大就是这个原因了。看下源码:
public int getDecoratedMeasuredWidth(View child) {
final Rect insets = ((RecyclerView.LayoutParams) child.getLayoutParams()).mDecorInsets;
return child.getMeasuredWidth() + insets.left + insets.right;
}
public int getDecoratedMeasuredHeight(View child) {
final Rect insets = ((RecyclerView.LayoutParams) child.getLayoutParams()).mDecorInsets;
return child.getMeasuredHeight() + insets.top + insets.bottom;
}
可以看到,它们在返回的时候,还加上了Decoration对应方向的值。
在自定义ViewGroup的时候,我们会重写onLayout方法,并在里面去遍历子View,然后调用子View的layout方法来进行布局,
但在LayoutManager里对Item进行布局时,也是不推荐直接使用layout方法,建议使用:
layoutDecorated(View child, int left, int top, int right, int bottom)
layoutDecoratedWithMargins(View child, int left, int top, int right, int bottom)
这两个方法也是LayoutManager提供的,我们使用layoutDecorated方法的话,它会给ItemDecorations腾出位置,来看下源码就明白了:
public void layoutDecorated(View child, int left, int top, int right, int bottom) {
final Rect insets = ((LayoutParams) child.getLayoutParams()).mDecorInsets;
child.layout(left + insets.left, top + insets.top, right - insets.right,
bottom - insets.bottom);
}
emmm,在layout的时候,的确是考虑到Decoration的大小,并把child的尺寸对应地缩小了一下。
而下面layoutDecoratedWithMargins方法,相信同学们看方法名就已经知道了,没错,这个方法就是在layoutDecorated的基础上,把Item设置的Margin也应用进去:
public void layoutDecoratedWithMargins(View child, int left, int top, int right, int bottom) {
final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams();
final Rect insets = lp.mDecorInsets;
child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
right - insets.right - lp.rightMargin,
bottom - insets.bottom - lp.bottomMargin);
}
哈哈,太方便了,不用我们自己去计算加加减减。
不止这些,LayoutManager还提供了getDecoratedXXX等一系列方法,有了这些方法,我们就可以跟ItemDecorations无缝配合,打造出我们想要的任何效果。
我们在自定义ViewGroup中,想要显示子View,无非就三件事:
其实在自定义LayoutManager中,在流程上也是差不多的,我们需要重写onLayoutChildren方法,这个方法会在初始化或者Adapter数据集更新时回调,在这方法里面,需要做以下事情:
说到RecyclerView的回收机制,相信也有不少同学了解过了,RecyclerView的回收任务是交给一个内部类: Recycler 来负责的,一般情况下(忽略ViewCacheExtension,因为这个需要自己实现),它有4个存放回收Holder的集合,分别是:
为什么说前面两个是临时缓存呢?
因为每当RecyclerView的dispatchLayout方法结束之前(当调用RecyclerView的reuqestLayout方法或者调用Adapter的一系列notify方法会回调这个dispatchLayout),它们里面的Holder都会移动到mCachedViews或mRecyclerPool.mScrap中。
那为什么有两个呢?它们之间有什么区别吗?
它们之间的区别就是:mChangedScrap只能在预布局状态下重用,因为它里面装的都是即将要放到mRecyclerPool中的Holder,而mAttachedScrap则可以在非预布局状态下重用。
什么是预布局(PreLayout)?
顾名思义,就是在真正布局之前,事先布局一次。但在预布局状态下,应该把已经remove掉的Item也layout出来,我们可以通过ViewHolder的LayoutParams.isViewRemoved()方法来判断这个ViewHolder是否已经被remove掉。
只有在Adapter的数据集更新时,并且调用的是除notifyDataSetChanged以外的一系列notify方法,预布局才会生效。这也是为什么调用notifyDataSetChanged方法不会播放Item动画的原因了。
这个其实有点像我们加载Bitmap的操作:先设置只读边,等获取到图片尺寸后设置好缩放比例再真正把图片加载进来。
要开启预布局的话,需要重写LayoutManager中的supportsPredictiveItemAnimations方法并return true; 这样就能生效了(当然,自带的那三个LayoutManager已经是开启了这个效果的),当Adapter的数据集更新时,onLayoutChildren方法就会回调两次,第一次是预布局,第二次是真实的布局,我们也可以通过state.isPreLayout() 来判断当前是否为预布局状态,并根据这个状态来决定要layout的Item。
LayoutManager提供了各种回收方法,我们可以在需要的时候直接调用就行了,先来看这三个方法:
detachAndScrapView(View child, Recycler recycler)
detachAndScrapViewAt(int index, Recycler recycler)
detachAndScrapAttachedViews(Recycler recycler)
前面两个方法都是回收指定的View,而第三个方法会把RecyclerView中全部未分离的子View都回收,我们看源码可以发现,这三个方法最终调用scrapOrRecycleView方法,来看看它里面做了什么:
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
......
if (viewHolder.isInvalid() && !viewHolder.isRemoved()
&& !mRecyclerView.mAdapter.hasStableIds()) {
removeViewAt(index);
recycler.recycleViewHolderInternal(viewHolder);
} else {
detachViewAt(index);
recycler.scrapView(view);
mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}
}
emmm,果然就跟方法名字一样,它会根据viewHolder的状态来决定放哪里,如果这个viewHolder已经被标记无效,并且还没有移除,又没有设置StableId的话,就会把它从RecyclerView中移除并尝试放到mRecyclerPool.mScrap中,如果没有满足以上条件的话,就会先把它分离,然后放进临时缓存(mAttachedScrap或mChangedScrap),以便稍后直接重用。
刚刚说到了StableId,什么是StableId?
其实就是这个Item的唯一标识。
这个是需要我们自己调用Adapter的setHasStableIds(true) 来开启,还需要在Adapter中重写getItemId(int position) 方法,根据position返回一个对应的唯一id
这样一来,当LayoutManager调用上面三个回收方法时,那些Holder就永远不会被放到mRecyclerPool.mScrap中,等到LayoutManager调用getViewForPosition方法时,如果没能根据position在mAttachedScrap和mCachedViews中找到合适的Holder的话,就会根据Adapter的getItemId方法返回的id来再次从上面两个集合中找(匹配id),如果能匹配到的话,就表示能直接重用了,所以,如果我们做了这个StableId的话,理论上是会提高滑动的流畅度的。
再来看看这三个方法:
removeAndRecycleView(View child, Recycler recycler)
removeAndRecycleViewAt(int index, Recycler recycler)
removeAndRecycleAllViews(Recycler recycler)
通过看名字可以大概知道,这几个方法会把holder放进mRecyclerPool.mScrap中,但不一定每次都直接放进去的,如果这个holder未被标记为无效的话,会经过我们上面说的mCachedViews缓冲一下(它默认能装2个,当然我们也可以根据需求来设置合适的大小),这个mCachedViews就好像一个队列,当有新的holder要被添加进来,而这个时候它又装满了的话,就会把最先存进去的holder拿出来,扔进mRecyclerPool.mScrap里面,这样新的holder就有空间放进来了。
所以,在mCachedViews中取出来的holder,也是能直接重用而不需重新绑定数据的。
好了,现在相信大家对RecyclerView的回收机制都有比较深入的理解了,我们在自定义LayoutManager的过程中,想要做出流畅的滑动效果,就必须要重视并认真对待回收这个环节。
好,现在到了基本流程中最后一步了,我们来看看如何使LayoutManager的Item能够跟随手指滚动。
当RecyclerView接收到触摸事件时,会根据:
boolean canScrollHorizontally()
boolean canScrollVertically()
这两个方法的返回值来判断是否可以接受水平或垂直触摸事件,如果返回的是true的话,就会回调:
int scrollHorizontallyBy(int dx, Recycler recycler, State state)
int scrollVerticallyBy(int dy, Recycler recycler, State state)
这两个方法,一个是水平滑动时的回调,一个是垂直滑动。
我们来看看参数:
可以看到这两个方法还需要返回一个int,就是要告诉RecyclerView,本次我们实际消费(偏移)的距离,比如说当滚动到最底部时,不能继续往下滚动,这时候就应该返回0了。
我们在重写这两个方法时,就要根据当前偏移量来对Items做出相应的偏移,这样列表就能随手指滚动起来了,当然了,别忘了回收这一重要环节。
好了,学习了一堆理论知识,是时候将它应用起来,做出属于自己的LayoutManager了,这次我们要做一个很炫酷的效果,就是让Item跟着路径走,哈哈哈。
先给它起个比较接地气的名字,就叫做PathLayoutManager吧,github上搜了一下,果然还没有人用这个名字,赶紧新建一个仓库!
先来两张基本的效果图:
可以看到,上面那些按钮还能跟着路径旋转,就像条蛇一样。其实这个就是获取Path点上的角度,然后根据角度来旋转Item而已。到这里可能有同学会想问:你把人家旋转了,还能正常接收点击或触摸事件吗? 哈哈哈,这个问题我们在之前的文章:(Android实现圆弧滑动效果之ArcSlidingHelper篇)就已经详细分析过了:
所以我们不用担心触摸事件的问题。
好,平时我们在普通的View上做路径动画是做的多了,但把路径动画应用到RecyclerView中还是没试过呢,其实这个也不难,核心的还是大家熟悉的PathMeasure,不过这次我们在获取Path上每一个点的坐标的时候,还需要一个平时我们都不留意的东西,就是getPosTan方法的最后一个参数tan,我们正是要利用这个正切值来计算出Item所需旋转的角度,来看看代码怎么写:
我们模仿SDK里面的做法,来创建一个叫Keyframes的类 (利用这个来获取Path上面的坐标和角度):
public class Keyframes {
private float[] mX; //Path的所有x轴坐标点
private float[] mY; //Path的所有y轴坐标点
private float[] mAngle; //Path上每一个坐标所对应的角度
}
来看看初始化的代码 (初始化的时候就把坐标点和角度信息获取下来,之后就可以直接根据索引来取了,效率很高):
private void initPath(Path path) {
final PathMeasure pathMeasure = new PathMeasure(path, false);
float pathLength = pathMeasure.getLength();
int numPoints = (int) (pathLength / PRECISION) + 1;
//临时存放坐标点
float[] position = new float[2];
//临时存放正切值
float[] tangent = new float[2];
//当前距离
float distance;
for (int i = 0; i < numPoints; ++i) {
//更新当前距离
distance = (i * pathLength) / (numPoints - 1);
//根据当前距离获取对应的坐标点和正切值
pathMeasure.getPosTan(distance, position, tangent);
mX[i] = position[0];
mY[i] = position[1];
//利用反正切函数得到角度
mAngle[i] = fixAngle((float) (Math.atan2(tangent[1], tangent[0]) * 180F / Math.PI));
}
}
/**
* 调整角度,使其在0 ~ 360之间
*
* @param rotation 当前角度
* @return 调整后的角度
*/
private float fixAngle(float rotation) {
float angle = 360F;
if (rotation < 0) {
rotation += angle;
}
if (rotation > angle) {
rotation %= angle;
}
return rotation;
}
来看看如何获取这些值:
SDK中Keyframes类的getValue方法是直接返回一个PointF的,但因为我们这次定义的Keyframes多了一个mAngle,原来的PointF已经不能满足了,所以我们还要新建一个包装类,继承一下PointF,然后加一个angle:
public class PosTan extends PointF {
/**
* 在路径上的位置 (百分比)
*/
public float fraction;
/**
* Item所对应的索引
*/
public int index;
/**
* Item的旋转角度
*/
private float angle;
}
我们来看看改造后的getValue方法:
/**
* 根据传入的百分比来获取对应的坐标点和角度
* @param fraction 当前百分比: 0~1
*/
public PosTan getValue(@FloatRange(from = 0F, to = 1F) float fraction) {
//超出范围的直接返回空
if (fraction >= 1F || fraction < 0) {
return null;
} else {
int index = (int) (mNumPoints * fraction);
//更新temp的内部值
mTemp.set(mX[index], mY[index], mAngle[index]);
return mTemp;
}
}
mTemp就是刚刚扩展自PointF的类,用来存放这些坐标点和角度等数据。
好了,现在我们把路径这一块处理完了,接下来看看LayoutManager那边应该怎么做。
我们先来把最基本的功能做出来:
先来想一下构造方法:
于是,我们的构造方法就可以写成这样:
/**
* @param path 目标路径
* @param itemOffset Item间距
*/
public PathLayoutManager(Path path, int itemOffset) {
this(path, itemOffset, RecyclerView.VERTICAL);
}
/**
* @param path 目标路径
* @param itemOffset Item间距
* @param orientation 滑动方向
*/
public PathLayoutManager(Path path, int itemOffset, @RecyclerView.Orientation int orientation) {
mOrientation = orientation;
mItemOffset = itemOffset;
updatePath(path);
}
这个updatePath方法也就是创建一个Keyframes而已:
/**
* 更新Path
*/
public void updatePath(Path path) {
if (path != null) {
mKeyframes = new Keyframes(path);
if (mItemOffset == 0) {
//这里我们不允许间距为0,因为如果间距为0的话,全部Item都会叠在一起,这样就没有意义了。
throw new IllegalStateException("itemOffset must be > 0 !!!");
}
//计算出这个Path最多能同时出现几个Item
mItemCountInScreen = mKeyframes.getPathLength() / mItemOffset + 1;
}
requestLayout();
}
好,接下来看看我们需要重写的方法,现在我们已经知道了滑动方向,那么判断能否垂直或水平滚动的两个方法就应该这么写:
@Override
public boolean canScrollVertically() {
//设置了滑动方向是垂直,才能接受垂直滚动事件
return mOrientation == RecyclerView.VERTICAL;
}
@Override
public boolean canScrollHorizontally() {
//设置了滑动方向是水平,才能接受水平滚动事件
return mOrientation == RecyclerView.HORIZONTAL;
}
来想想应该怎么布局:因为Keyframes那边的getValue方法是根据Path总长度的百分比来获取到某一个点上的坐标和角度,那么,我们只需要计算出每个Item在Path上的距离就行了,一般情况下可以这样来计算:
Item在Path上的百分比 = Item当前position * 指定的Item间距 / Path总长度
但由于我们的Item是会滚动的,也就是说,上面的方法算出来的是死的,列表一滚动就不对了,所以,还要减去滚动的偏移量。
回顾一下上面讲到的那两个处理滑动的方法,它有个本次偏移量的参数(dx, dy),我们可以在这里记录一下偏移量,然后稍微改一下:
Item在Path上的百分比 = (Item当前position * 指定的Item间距 - 滑动偏移量) / Path总长度
哈哈哈,这样就行啦。
不过呢,因为我们只需要将Path范围内的Item布局出来,超出范围的就不应该参与计算了,还记不记得刚刚在初始化Keyframes的时候还算了一个mItemCountInScreen (Path最多能同时出现几个Item) ?是时候派上用场了,我们还要拿到当前Path里面第一个能显示的Item position,这样的话,能提高不少效率(知道开始position和结束position)。
来看看代码怎么写:
/**
* 初始化需要布局的Item数据
*
* @param result 结果
* @param itemCount Item总数
*/
private void initNeedLayoutItems(List<PosTan> result, int itemCount) {
float currentDistance;
//必须从第一个item开始,因为要拿到最小的,也就是最先的
for (int i = 0; i < itemCount; i++) {
currentDistance = i * mItemOffset - getScrollOffset();
//判断当前距离 >= 0的即表示可见
if (currentDistance >= 0) {
//得到第一个可见的position
mFirstVisibleItemPos = i;
break;
}
}
//结束的position
int endIndex = mFirstVisibleItemPos + mItemCountInScreen;
//防止溢出
if (endIndex > getItemCount()) {
endIndex = getItemCount();
}
float fraction;
PosTan posTan;
for (int i = mFirstVisibleItemPos; i < endIndex; i++) {
//得到当前距离
currentDistance = i * mItemOffset - getScrollOffset();
//得到百分比
fraction = currentDistance / mKeyframes.getPathLength();
//根据百分比从Keyframes中取出对应的坐标和角度
posTan = mKeyframes.getValue(fraction);
if (posTan == null) {
continue;
}
//添加进list中
result.add(new PosTan(posTan, i, fraction));
}
}
getScrollOffset方法很明显就是获取刚刚说的滚动偏移量了,因为现在有两个滑动方向,所以还要判断一下当前方向来返回不同的偏移量:
/**
* 根据当前设置的滚动方向来获取对应的滚动偏移量
*/
private float getScrollOffset() {
return mOrientation == RecyclerView.VERTICAL ? mOffsetY : mOffsetX;
}
现在来看看重写的onLayoutChildren方法:
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (state.getItemCount() == 0) {
//没有Item可布局,就回收全部临时缓存 (参考自带的LinearLayoutManager)
//这里的没有Item,是指Adapter里面的数据集,
//可能临时被清空了,但不确定何时还会继续添加回来
removeAndRecycleAllViews(recycler);
return;
}
//暂时分离和回收全部有效的Item
detachAndScrapAttachedViews(recycler);
List<PosTan> needLayoutItems = new ArrayList<>();
//获取需要布局的items
initNeedLayoutItems(needLayoutItems, state.getItemCount());
//检查一下
if (needLayoutItems.isEmpty() || mKeyframes == null) {
removeAndRecycleAllViews(recycler);
return;
}
//开始布局
onLayout(recycler, needLayoutItems);
}
可以看到我们是先分离和回收了全部有效Item,获取到需要布局的Items之后还调用了一个onLayout方法,来看看:
/**
* 确定Item位置,角度以及尺寸
*
* @param needLayoutItems 需要布局的Item
*/
private void onLayout(RecyclerView.Recycler recycler, List<PosTan> needLayoutItems) {
int x, y;
View item;
for (PosTan tmp : needLayoutItems) {
//根据position获取View
item = recycler.getViewForPosition(tmp.index);
//添加进去,当然里面不一定每次都是调用RecyclerView的addView方法的,
//如果是从缓存区里面找到的,只需调用attachView方法把它重新连接上就行了。
addView(item);
//测量item,当然,也不是每次都会调用measure方法进行测量的,
//它里面会判断,如果已经测量过,而且当前尺寸又没有收到更新的通知,就不会重新测量。
measureChild(item, 0, 0);
//Path线条在View的中间
x = (int) tmp.x - getDecoratedMeasuredWidth(item) / 2;
y = (int) tmp.y - getDecoratedMeasuredHeight(item) / 2;
//进行布局
layoutDecorated(item, x, y, x + getDecoratedMeasuredWidth(item), y + getDecoratedMeasuredHeight(item));
//旋转item
item.setRotation(tmp.getChildAngle());
}
}
可以看到,我们在onLayout方法里面直接遍历传进来的PosTan,然后根据每一个PosTan所对应的position来获取到对应的View,然后进行添加,测量,布局,旋转等操作。
好啦,现在可以运行来看下最基本的效果了。
这时候有细心的同学可能会想说:咦?你还没回收呢!
哈哈,我们在onLayoutChildren方法里面,第一步就是调用了detachAndScrapAttachedViews方法,这方法会把当前有效的ViewHolder全都放进mAttachedScrap里面。onLayoutChildren是在dispatchLayout方法中的dispatchLayoutStep1和dispatchLayoutStep2中有可能会被回调,而最后执行的dispatchLayoutStep3方法呢,就会把mAttachedScrap里面的Holder都放进RecyclerPool中,然后清空mAttachedScrap。
所以我们在这里不需要自己去处理回收了,我们要处理回收的地方,是滑动的那两个回调方法,即scrollHorizontallyBy和scrollVerticallyBy。
好,现在来看看效果吧:
我们随便的画一个路径:
Path path = new Path();
path.moveTo(250,250);
path.rLineTo(600,300);
path.rLineTo(-600,300);
path.rLineTo(600,300);
path.rLineTo(-600,300);
recyclerView.setLayoutManager(new PathLayoutManager(path, 150));
哈哈哈,可以看到效果啦,当然了,为了更直观地看到效果,后面的那条路径是单独一个View画上去的。
但现在滑动的话,是没有反应的,因为还没有处理偏移。
那现在来想一下应该怎么做:其实很简单,就更新一下offset然后调用我们刚刚定义的onLayout方法就行了,当然,这时候别忘了做回收处理了。
来看看代码:
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//检查Keyframes是否已初始化,即检测是否设置了Path
checkKeyframes();
//分离和临时回收
detachAndScrapAttachedViews(recycler);
//临时记录上一次的offset
float lastOffset = mOffsetY;
//更新偏移量
updateOffsetY(dy);
//布局item
relayoutChildren(recycler, state);
//如果offset没有改变,那么就直接return 0,表示不消费本次滑动
return lastOffset == mOffsetY ? 0 : dy;
}
我们先来看看updateOffsetY方法里面做了什么:
/**
* 更新Y轴偏移量
*
* @param offsetY 偏移量
*/
private void updateOffsetY(float offsetY) {
//更新offset
mOffsetY += offsetY;
//路径总长度
int pathLength = mKeyframes.getPathLength();
//item总长度
int itemLength = getItemLength();
//item总长度相对于路径总长度多出来的部分
int overflowLength = itemLength - pathLength;
if (mOffsetY < 0) {
//避免第一个item脱离顶部向下滚动
mOffsetY = 0;
} else if (mOffsetY > overflowLength) {//滑动到底部,并且最后一个item即将脱离底部时
//如果列表能滚动的话,则直接设置为可滑动的最大距离,避免最后一个item向上移
if (itemLength > pathLength) {
mOffsetY = overflowLength;
} else {
//如果列表内容很少,不用滚动就能显示完的话,就不更新offset
//那为什么这里是减呢?因为最上面执行了一句+=,所以现在这样做是抵消第一句的操作。
mOffsetY -= offsetY;
}
}
}
其实也就是更新一下偏移量而已,不过还做了一些判断,就是使它能像正常的列表一样滑动。
好,我们回到scrollVerticallyBy方法,可以看到还调用了一个relayoutChildren,其实这个就是封装了一下上面我们重写的onLayoutChildren方法后面layout部分,使代码得以重用而已:
private void relayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
List<PosTan> needLayoutItems = new ArrayList<>();
//获取需要布局的items
initNeedLayoutItems(needLayoutItems, state.getItemCount());
//判断一下状态
if (needLayoutItems.isEmpty() || mKeyframes == null) {
removeAndRecycleAllViews(recycler);
return;
}
//开始布局
onLayout(recycler, needLayoutItems);
}
这个逻辑不用变,因为我们之前定义initNeedLayoutItems方法时,已经把偏移量考虑进去了。
现在把垂直滚动搞定了,那水平滚动也是一样的写法,只需把offsetY换成offsetX就行了。
好,现在到回收了,我们可以先参考下自带的LinearLayoutManager,看看它是怎么做的,在源码中可以找到这一处:
private void recycleChildren(RecyclerView.Recycler recycler, int startIndex, int endIndex) {
if (startIndex == endIndex) {
return;
}
if (DEBUG) {
Log.d(TAG, "Recycling " + Math.abs(startIndex - endIndex) + " items");
}
if (endIndex > startIndex) {
for (int i = endIndex - 1; i >= startIndex; i--) {
removeAndRecycleViewAt(i, recycler);
}
} else {
for (int i = startIndex; i > endIndex; i--) {
removeAndRecycleViewAt(i, recycler);
}
}
}
emmm,它定义的这个方法,里面是使用removeAndRecycleViewAt来回收的,还有,它是通过传进来的startIndex和endIndex来决定回收的范围的,那我们也仿照它这样,通过传进来的开始和结束索引来回收,看看代码怎么写:
private void recycleChildren(RecyclerView.Recycler recycler, int startIndex, int endIndex) {
if (startIndex == endIndex) {
return;
}
for (int i = startIndex; i <= endIndex; i++) {
final View view = recycler.getViewForPosition(i);
if (view != null) {
removeView(view);
recycler.recycleView(view);
}
}
}
哈哈哈,就这样了。
我们可以分两段来回收,一段是第一个可见Item的前面,另一段是最后一个可见Item的后面。
比如现在一共有20个item,path最多能显示5个,就像这样:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
加粗的56789是列表中能看到的,那么我们就可以把0~4作为前半段回收,再把10~14作为后半段回收。
那应该怎么得到这个startIndex和endIndex呢:
还记不记得我们在获取需要layout的item的时候,把PosTan都放在一个List了?PosTan里面正好保存有对应的index,那么我们就可以拿到屏幕中显示的第一个item索引和最后一个索引了,然后根据这个起始索引来进行范围回收,还可以用屏幕中最多显示的item数量来作为回收的范围,来看看代码怎么写:
private void recycleChildren(RecyclerView.Recycler recycler, List<PosTan> needLayoutDataList) {
//item总数
int itemCount = getItemCount();
//列表中第一个能看到的item索引
//不用担心IndexOutOfBoundsException的问题,
//因为我们在上面已经做了判断,如果list为空,直接return了,不会执行到这里
int firstIndex = needLayoutDataList.get(0).index;
//最后一个能看到的item索引
int lastIndex = needLayoutDataList.get(needLayoutDataList.size() - 1).index;
//前面那一段的起始和结束索引
int forwardStartIndex, forwardEndIndex;
//后面一段的起始和结束索引
int backwardStartIndex, backwardEndIndex;
//等下还需判断是否需要回收
boolean needRecyclerForward = false, needRecyclerBackward = false;
//排除第一个,所以-1
forwardEndIndex = firstIndex - 1;
//回收的范围 = 列表中能同时显示的数量
forwardStartIndex = forwardEndIndex - mItemCountInScreen;
//排除最后一个,所以+1
backwardStartIndex = lastIndex + 1;
//回收的范围 = 列表中能同时显示的数量
backwardEndIndex = backwardStartIndex + mItemCountInScreen;
//如果第一个显示的item索引为0,就不用回收了
if (firstIndex > 0) {
if (forwardStartIndex < 0) {
forwardStartIndex = 0;
}
//标记需要回收
needRecyclerForward = true;
}
//如果adapter数据集中最后一个item正在显示,也不用回收
if (lastIndex < itemCount - 1) {
if (backwardEndIndex >= itemCount) {
backwardEndIndex = itemCount - 1;
}
//标记需要回收
needRecyclerBackward = true;
}
//回收前半段
if (needRecyclerForward) {
recycleChildren(recycler, forwardStartIndex, forwardEndIndex);
}
//回收后半段
if (needRecyclerBackward) {
recycleChildren(recycler, backwardStartIndex, backwardEndIndex);
}
}
就是这样了。
emmm,其实,如果我们不需要开启预布局的话,回收工作还可以做的更简单,这也算是奇技淫巧吧,就是可以直接把Recycler里面的mAttachedScrap全部放进mRecyclerPool中,因为我们在一开始就已经调用了detachAndScrapAttachedViews方法将当前屏幕中有效的ViewHolder全部放进了mAttachedScrap,而在重新布局的时候,有用的Holder已经被重用了,也就是拿出去了,这个mAttachedScrap中剩下的Holder,都是不需要layout的,所以可以把它们都回收进mRecyclerPool中。
来看看这个奇技淫巧的代码:
//拿到临时缓存
List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
//遍历,然后先移除,后回收,其实也就是removeAndRecycleView方法所做的事
for (int i = 0; i < scrapList.size(); i++) {
RecyclerView.ViewHolder holder = scrapList.get(i);
removeView(holder.itemView);
recycler.recycleView(holder.itemView);
}
哈哈哈,是不是简单了很多。
来看看效果:
emmm,可以看到,Adapter中有几百条数据,滑动起来也丝毫不费劲,说明我们在处理回收这个环节中做的还是不错的。
在一开始的效果图中,有一张是可以把全部item都滑出屏幕,这个是怎么做到的呢?其实非常简单,我们只需要改一下处理偏移量那个方法:
private void updateOffsetY(float offsetY) {
......
//如果是溢出模式
if (isOverflowMode()) {
//当item全部滑出屏幕后,为了能及时出现,须把滑动的距离调整下
//如果是全部向下溢出的,限制最多只能是path的长度
if (mOffsetY < -pathLength) {
mOffsetY = -pathLength;
} else if (mOffsetY > itemLength) {
//如果是向上溢出,那就限制最多只能滑动到item的总长度
mOffsetY = itemLength;
}
} else {
.......
}
}
当全部item都滑出屏幕之后,就限制继续往这个方向滚动,这样的话,我们反方向滑动时,items就能立即出现,来看看效果:
为了更清楚地看到效果,我们把背景换成暗色的:
可以看到,在全部item滑出去之后,手指还继续滑动了几下,当反方向滑动时,item还是能立即出来,这就是我们所需要的效果了。
好,现在来看看无限循环应该怎么做:
其实也可以参照上面的溢出模式,在处理滑动的时候,如果超出了一定范围,就重置滑动偏移量,来看看代码,我们这次还是使用updateOffsetY方法来做示范:
/**
* 更新Y轴偏移量
*
* @param offsetY 偏移量
*/
private void updateOffsetY(float offsetY) {
......
//需满足无限循环滚动的条件
if ((isSatisfiedLoopScroll(pathLength, itemLength))) {
//如果全部item即将向上滑出屏幕,这个时候如果是无限循环的话,
//那么就是会显示第0个item,所以我们可以偷梁换柱,把早已滑出屏幕的第0个item,
//移回屏幕中,不用担心有什么副作用,这就像一个矩形,旋转了361度和旋转了1度是一样的道理
if (mOffsetY > itemLength) {
mOffsetY %= itemLength;
//因为是向前偏移了一个item的距离
mOffsetY -= mItemOffset;
} else if (mOffsetY <= -pathLength) {
//原理同上
mOffsetY += itemLength;
mOffsetY += mItemOffset;
}
} else {
......
}
}
可以看到上面调用了一个isSatisfiedLoopScroll方法,这个方法就是用来判断是否满足无限循环滚动条件的,那就是当前设置的滚动模式为无限循环模式,并且Item的总长度要大于Path的总长度,因为同一子View不能同时添加两个到ViewGroup中,如果当前列表能滚动,才可以做无限循环,并不是如果item不够我们就自动帮他添加相同的,不应该这样做。
来看看代码:
/**
* 判断是否满足无限循环滚动条件
* 条件: 必须明确开启无限循环模式,并且Item的总长度要大于Path的总长度
*/
private boolean isSatisfiedLoopScroll() {
checkKeyframes();
int pathLength = mKeyframes.getPathLength();
int itemLength = getItemLength();
return isLoopScrollMode() && itemLength - pathLength > mItemOffset;
}
现在到布局了,我们可以先想象成溢出模式那样,不过,当列表有空缺位置的时候,需要补上下一个item,看例子:
比如现在列表中一共有20条数据,最多同时显示10条:
在溢出模式中是这样的:
15 16 17 18 19 __ __ __ __ __
可以看到19后面的空缺部分,总共有5个,那么在无限循环滚动中,就应该是这样的:
15 16 17 18 19 0 1 2 3 4
也就是把后面空余部分填上对应的item了,所以我们需要计算出空缺item的个数,来看看代码怎么写:
/**
* 获取空缺Item的个数
*/
private int getVacantCount() {
//item总长度
int itemLength = getItemLength();
//path的长度
int pathLength = mKeyframes.getPathLength();
//第一个item较Path终点的偏移量,这个偏移量是以Path的终点为起点的,
//例如 现在一共有10个item:
// 0___1___2___3___4___5 现在的偏移量是>0的,直到:
// 5___6___7___8___9___0 时为0,这个时候继续向右边滚动的话,就会变成负数了
int firstItemScrollOffset = (int) (getScrollOffset() + pathLength);
//同上,区别就是上面的是第一个item,这个是最后一个item,
//例如 现在一共有10个item:
// 0___1___2___3___4___5 现在的偏移量是<0的,一直到:
// 4___5___6___7___8___9 时为0
//这样做就是为了:当最后一个item离开它应在的位置时(常规的滑动模式最后一个item是坐死在最后的位置的),
//能够及时知道,并开始计算出它下一个item索引来补上它的空位
int lastItemScrollOffset = firstItemScrollOffset - itemLength;
//item的总长度 + path的总长度
int lengthOffset = itemLength + pathLength;
//当最后一个item滑出屏幕时(根据上面的例子来讲,是向左边滑):
// 9_|_0___1___2___3___4
// 开始计算的偏移量(正数),因为如果超出了屏幕而不作处理的话,
// 下面计算空缺距离的时候,最大值只能是itemLength
int lastItemOverflowOffset = firstItemScrollOffset > lengthOffset ?
firstItemScrollOffset - lengthOffset : 0;
//空缺的距离
int vacantDistance = lastItemScrollOffset % itemLength + lastItemOverflowOffset;
//空缺的距离 / item之间的距离 = 需补上的item个数
return vacantDistance / mItemOffset;
}
我们知道了空缺的个数后,就能进一步知道当前第一个显示的item索引了:
/**
* 初始化需要布局的Item数据 (无限滚动模式)
*
* @param result 结果
* @param itemCount Item总数
*/
private void initNeedLayoutLoopScrollItems(List<PosTan> result, int itemCount) {
int vacantCount = getVacantCount();
//得出第一个可见的item索引
mFirstVisibleItemPos = vacantCount - mItemCountInScreen - 1;
float currentDistance;
float fraction;
PosTan posTan;
int pos;
for (int i = mFirstVisibleItemPos; i < vacantCount; i++) {
//防止溢出
pos = i % itemCount;
if (pos < 0) {
//比如现在一个有10个item,当前pos=-10,那就表示它对应的索引是0了
if (pos == -itemCount) {
pos = 0;
} else {
//将负数转成有效的索引
// [0,1,2,3,4,5,6,7,8,9]
// -9 --> 1 -8 --> 2
pos += itemCount;
}
}
//得出当前距离
currentDistance = (i + itemCount) * mItemOffset - getScrollOffset();
fraction = currentDistance / mKeyframes.getPathLength();
//拿到坐标数据
posTan = mKeyframes.getValue(fraction);
if (posTan == null) {
continue;
}
result.add(new PosTan(posTan, pos, fraction));
}
}
好,再封装一下获取需要布局的item的方法:
private List<PosTan> getNeedLayoutItems() {
checkKeyframes();
List<PosTan> result = new ArrayList<>();
//item个数
int itemCount = getItemCount();
//满足无限滚动
if (isSatisfiedLoopScroll()) {
initNeedLayoutLoopScrollItems(result, itemCount);
} else {
initNeedLayoutItems(result, itemCount);
}
return result;
}
那么现在onLayout那边就可以直接调用这个方法来获取需要布局的items了。
我们来看看效果吧:
可以了,哈哈哈,是不是很开心!
可能有很多同学之前也都见过有些Banner有缩放的效果,就是越靠近中间就越大,反之越小,我们正是要做这种效果,但是想一下,如果我要缩小而不是放大,或者我要设置两个或三个放大的点呢?显然我们不能把这些数据写死,应该做成动态的,比如说像这样的:
哈哈哈,怎么样,是不是很好玩?
先来想一下我们需要的东西:
其实可以只用一个数组来存放它们,用奇数来表示缩放的位置,偶数表示缩放比例。
那么应该怎样计算出每个item的缩放比例呢?当列表滑动时,item的位置也是会改变的。
可以用item位置相对于Path总长度的百分比来进行动态计算。
看一下这张图:
现在是在路径50%处将item缩放到原来的20%
那么我们可以这样来辅助理解:
1__________0.2___________1
比如现在有一个item在path上的位置百分比是75%,就变成了这样:
1__________0.2_____?_____1
我们需要知道的是总路径的75%相对于0.2~1的之间的百分比是多少?
比如说现在是50%
再根据这个相对百分比得到0.2~1之间的缩放比例:
先算出它们相差的距离:
1 - 0.2 = 0.8;
然后根据相对百分比得到缩放比例:
0.8 * 0.5 = 0.4;
然后在加上基本的缩放比例,比如现在是0.2:
0.4 + 0.2 = 0.6;
所以path上的75%处缩放比例应为60%。
emmm,思路还蛮清晰的,那么,我们应该怎么算出来那个相对百分比呢?
哈哈哈,可能现在有同学已经知道应该怎么做了,没错,就是解两点式直线方程,表达公式为:
(y-y2) / (y1-y2) = (x-x2) / (x1-x2)
回到上面的问题:总路径的75%相对于0.2~1的之间的百分比是多少?
我们现在就可以直接把这些已知数代进去:
0.2所对应的位置是50%,也就是0.5,1所对应的位置是100%,也就是1了,于是:
(0.75 - 0.5) / (1 - 0.5) = 0.5 = 50%
哈哈,我们把这个公式转换成代码:
/**
* 将基于总长度的百分比转换成基于某个片段的百分比 (解两点式直线方程)
*
* @param startX 片段起始百分比
* @param endX 片段结束百分比
* @param currentX 总长度百分比
* @return 该片段的百分比
*/
private static float solveTwoPointForm(float startX, float endX, float currentX) {
return (currentX - startX) / (endX - startX);
}
emmm,我们需要求相对百分比的话,只需要传入起始点,结束点和当前点就行了,那么,我们怎么根据总百分比来找到起始点和结束点呢?来看代码:
/**
* 根据Item在Path上的位置来获取对应的缩放比例
*
* @param fraction Item位置相对于Path总长度的百分比
* @return 该Item的缩放比例
*/
private float getScale(float fraction) {
boolean isHasMin = false;
boolean isHasMax = false;
float minScale = 0;
float maxScale = 0;
float scalePosition;
float minFraction = 1, maxFraction = 1;
//必须从小到大遍历,才能找到最贴近fraction的scale
for (int i = 1; i < mScaleRatio.length; i += 2) {
scalePosition = mScaleRatio[i];
//找更小的
if (scalePosition <= fraction) {
//得到缩放比例
minScale = mScaleRatio[i - 1];
//得到缩放位置
minFraction = mScaleRatio[i];
//标记已找到
isHasMin = true;
} else {
break;
}
}
//必须从大到小遍历,才能找到最贴近fraction的scale
for (int i = mScaleRatio.length - 1; i >= 1; i -= 2) {
scalePosition = mScaleRatio[i];
//找更大的
if (scalePosition >= fraction) {
maxScale = mScaleRatio[i - 1];
maxFraction = mScaleRatio[i];
isHasMax = true;
} else {
break;
}
}
//没找到对应的缩放比例,就不缩放
if (!isHasMin) {
minScale = 1;
}
if (!isHasMax) {
maxScale = 1;
}
//得到相对百分比
fraction = solveTwoPointForm(minFraction, maxFraction, fraction);
//得到相差的比例
float distance = maxScale - minScale;
//得到相对的缩放比例
float scale = distance * fraction;
//还需在原来的基础上增加,得到绝对缩放比例
float result = minScale + scale;
//判断数值是否合法,如不合法,直接使用基础缩放比例
return isFinite(result) ? result : minScale;
}
/**
* 判断数值是否合法
*
* @param value 要判断的数值
* @return 合法为true,反之
*/
private boolean isFinite(float value) {
return !Float.isNaN(value) && !Float.isInfinite(value);
}
那现在我们在item布局之后,可以通过PosTan里面的fraction来获取到对应的缩放比例了,然后设置一下scaleX和scaleY就行了,来改一下onLayout方法:
/**
* 确定Item位置,角度以及尺寸
*
* @param needLayoutItems 需要布局的Item
*/
private void onLayout(RecyclerView.Recycler recycler, List<PosTan> needLayoutItems) {
int x, y;
View item;
for (PosTan tmp : needLayoutItems) {
......
//进行布局
layoutDecorated(item, x, y, x + getDecoratedMeasuredWidth(item), y + getDecoratedMeasuredHeight(item));
//旋转item
item.setRotation(tmp.getChildAngle());
if (mScaleRatio != null) {
//根据item当前位置获取到对应的缩放比例
float scale = getScale(tmp.fraction);
//设置缩放
item.setScaleX(scale);
item.setScaleY(scale);
}
}
}
上面用到的mScaleRatio,就是存放缩放比例和位置的数组,但在设置缩放比例的时候,应注意以下几点:
例如:
[0.8, 0.5] 即表示在路径的50%处把item缩放到原来的80%
[0, 0, 1, 0.5, 0, 1] 表示在路径的起点和终点处,把item缩放至原来的0%,而在50%处把item恢复原样。
先来看看效果图:
其实不应该把落点固定在路径50%处,应该可以自由控制落点,就像这样:
为了更直观地看到效果,我们把item的间距设置大一些:
可以看到,当seekBar进度改变之后,item也相应地作出移动,而且继续滑动item后也还是会回到落点位置,这就是刚刚说的自由控制落点,看上去就觉得灵活了很多。
来想想应该怎么做:
emmmm,整个过程就是这样,我们来看看代码怎么写:
首先是找到最近item的:
/**
* 找出离目标落点最近的item索引
*/
private int findClosestPosition() {
//当前认为最近的索引
int hitPos = -1;
//先获取屏幕中的item
List<PosTan> posTanList = getNeedLayoutItems();
if (posTanList.size() > 1) {
//先认为第0个item是距离目标落点最近的
hitPos = posTanList.get(0).index;
//第0个item与目标落点的距离
float hitFraction = Math.abs(posTanList.get(0).fraction - mAutoSelectFraction);
for (PosTan tmp : posTanList) {
float tempFraction = Math.abs(tmp.fraction - mAutoSelectFraction);
//跟现在认为最近的距离做比较,取更近的那一方
if (tempFraction < hitFraction) {
hitPos = tmp.index;
hitFraction = tempFraction;
}
}
}
//如果没找到,默认为列表中第0个
if (hitPos < 0) {
if (!posTanList.isEmpty()) {
hitPos = posTanList.get(0).index;
}
}
return hitPos;
}
知道了哪个item最接近目标落点之后,开始播放动画:
/**
* 播放平滑滚动动画并更新偏移量
*
* @param position 目标Item索引
*/
private void startValueAnimator(int position) {
//如果上一次的动画未播放完,就先取消它
stopFixingAnimation();
//根据item索引计算出与目标落点之间的距离
int distance = getDistance(position);
mAnimator = ValueAnimator.ofFloat(0, distance).setDuration(mFixingAnimationDuration);
mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
private float mLastScrollOffset;
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float currentValue = (float) animation.getAnimatedValue();
if (mLastScrollOffset != 0) {
float offset = currentValue - mLastScrollOffset;
//判断当前滑动方向,更新对应方向的偏移量
if (canScrollVertically()) {
updateOffsetY(offset);
} else {
updateOffsetX(offset);
}
//更新item位置
requestLayout();
}
mLastScrollOffset = currentValue;
}
});
mAnimator.start();
}
主要是那个getDistance方法,看看是怎么计算出相差的距离:
/**
* 根据传入的position来获取离目标落点的最近距离
*/
private int getDistance(int position) {
PosTan posTan = getVisiblePosTanByPosition(position);
float distance;
//如果这个item当前不在屏幕中,需要通过循环来一个个匹配
if (posTan == null) {
int itemCount = getItemCount();
int count = 0;
do {
count++;
//一直循环匹配
} while (fixOverflowIndex(position + count, itemCount) != position);
//如果设置了无限滚动的话,判断哪一边更接近落点,从而决定是向前滚动还是向后滚动
if (isSatisfiedLoopScroll() && count < 0) {
position += count;
}
//计算选中position与Path起点之间的距离。
distance = position * mItemOffset - getScrollOffset();
} else {
//如果屏幕中存在这个item的话,直接偏移屏幕中的
distance = mKeyframes.getPathLength() * posTan.fraction;
}
//定位到设定的落点位置
distance -= mKeyframes.getPathLength() * mAutoSelectFraction;
return (int) distance;
}
上面的getVisiblePosTanByPosition方法就是检测当前屏幕中是否有目标索引所对应的item:
/**
* 检测当前屏幕中是否有目标索引所对应的item
* 也就是检测该索引所对应的item是否可见
* @param position 目标索引
*/
private PosTan getVisiblePosTanByPosition(int position) {
//获取到屏幕可见的item数据
List<PosTan> needLayoutList = getNeedLayoutItems();
PosTan posTan = null;
for (int i = 0; i < needLayoutList.size(); i++) {
PosTan tmp = needLayoutList.get(i);
//判断索引是否一样,如果一样,表示该索引所对应的item可见
if (tmp.index == position) {
posTan = tmp;
break;
}
}
return posTan;
}
while循环条件里面调用的那个fixOverflowIndex方法就是把本来越界的索引变成有效索引:
/**
* 把小于0或者大于getItemCount()的索引转换成合法的索引
* 比如: getItemCount() = 10
* 如果此时index传 11 那么就返回 1
* 如果index为 -1 则返回 10
*/
private int fixOverflowIndex(int index, int count) {
while (index < 0) {
index += count;
}
return index % count;
}
emmmm,现在动画已经准备好了,那应该在哪里触发呢?这时候就要重写onScrollStateChanged方法了:
@Override
public void onScrollStateChanged(int state) {
switch (state) {
case RecyclerView.SCROLL_STATE_DRAGGING:
//当手指按下时,停止当前正在播放的动画
stopFixingAnimation();
break;
case RecyclerView.SCROLL_STATE_IDLE:
//当列表滚动停止后,判断一下自动选中是否打开
if (isAutoSelect) {
//找到离目标落点最近的item索引
int position = findClosestPosition();
//播放偏移动画
startValueAnimator(position);
}
break;
default:
break;
}
}
可以看到,我们在监听到列表停止滚动之后,开始播放偏移动画。
其实,我们在可以重写scrollToPosition和smoothScrollToPosition方法,因为现在已经把准备工作都做好了,实现它们可以非常简单:
@Override
public void scrollToPosition(int position) {
int itemCount = getItemCount();
if (position > -1 && position < itemCount) {
checkKeyframes();
//先获取到需要偏移的距离,判断滑动方向,然后直接更新偏移量
int distance = getDistance(position);
if (canScrollVertically()) {
updateOffsetY(distance);
} else {
updateOffsetX(distance);
}
//刷新item位置
requestLayout();
}
}
/**
* 平滑滚动
*/
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
if (position > -1 && position < getItemCount()) {
checkKeyframes();
startValueAnimator(position);
}
}
哈哈,现在我们直接调用RecyclerView中的scrollToPosition和smoothScrollToPosition也是有效的。
我们在上面的测试中,RecyclerView的宽高都是指定为match_parent的,如果现在把宽或高换成wrap_content,会发现列表不显示,因为还没有在测量中作处理,我们需要重写onMeasure方法,并在里面判断一下,如果是宽度指定了wrap_content,那么就把宽度设置为Path的宽度,高度也是一样,我们来看代码:
@Override
public void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state, int widthSpec, int heightSpec) {
if (mKeyframes != null) {
int widthMode = View.MeasureSpec.getMode(widthSpec);
int heightMode = View.MeasureSpec.getMode(heightSpec);
//如果RecyclerView宽度设置了wrap_content
//那就把宽度设置为Path的宽度
if (widthMode == View.MeasureSpec.AT_MOST) {
widthSpec = View.MeasureSpec.makeMeasureSpec(mKeyframes.getMaxX(), View.MeasureSpec.EXACTLY);
}
//如果RecyclerView高度设置了wrap_content
//那就把高度设置为Path的高度
if (heightMode == View.MeasureSpec.AT_MOST) {
heightSpec = View.MeasureSpec.makeMeasureSpec(mKeyframes.getMaxY(), View.MeasureSpec.EXACTLY);
}
}
super.onMeasure(recycler, state, widthSpec, heightSpec);
}
Path的宽度即x轴上最大的数,高度即y轴上最大的数。
为保险起见,我们还需要重写isAutoMeasureEnabled方法,禁止自动测量:
@Override
public boolean isAutoMeasureEnabled() {
return false;
}
在LinearLayoutManager源码中可以发现,它只重写了isAutoMeasureEnabled方法并return true的,但因为我们的item布局比较特殊,所以需要自己定义一下。
我们来看一下适配了wrap_content之后的效果:
需把 系统设置 - 开发人员选项 - 显示布局边界这一项开启:
哈哈,可以看到,RecyclerView的尺寸会随着Path的宽高改变而改变的。
再发一次我们的效果图,嘻嘻嘻嘻: