本文主要是介绍Android 自定义 RecyclerView LayoutManager,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
Android 自定义 LayoutManager
转载:https://blog.csdn.net/u011387817/article/details/81875021
先上效果图:
初步了解LayoutManager
所谓知己知彼,方能百战百胜。在自定义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无缝配合,打造出我们想要的任何效果。
自定义LayoutManager基本流程
让Items显示出来
我们在自定义ViewGroup中,想要显示子View,无非就三件事:
- 添加 通过addView方法把子View添加进ViewGroup或直接在xml中直接添加;
- 测量 重写onMeasure方法并在这里决定自身尺寸以及每一个子View大小;
- 布局 重写onLayout方法,在里面调用子View的layout方法来确定它的位置和尺寸;
其实在自定义LayoutManager中,在流程上也是差不多的,我们需要重写onLayoutChildren方法,这个方法会在初始化或者Adapter数据集更新时回调,在这方法里面,需要做以下事情:
- 进行布局之前,我们需要调用detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);
- 分离了之后,我们就要想办法把它们再添加回去了,所以需要通过addView方法来添加,那这些View在哪里得到呢? 我们需要调用 Recycler的getViewForPosition(int position) 方法来获取;
- 获取到Item并重新添加了之后,我们还需要对它进行测量,这时候可以调用measureChild或measureChildWithMargins方法,两者的区别我们已经了解过了,相信同学们都能根据需求选择更合适的方法;
- 在测量完还需要做什么呢? 没错,就是布局了,我们也是根据需求来决定使用layoutDecorated还是layoutDecoratedWithMargins方法;
- 在自定义ViewGroup中,layout完就可以运行看效果了,但在LayoutManager还有一件非常重要的事情,就是回收了,我们在layout之后,还要把一些不再需要的Items回收,以保证滑动的流畅度;
回收
说到RecyclerView的回收机制,相信也有不少同学了解过了,RecyclerView的回收任务是交给一个内部类: Recycler 来负责的,一般情况下(忽略ViewCacheExtension,因为这个需要自己实现),它有4个存放回收Holder的集合,分别是:
- 可直接重用的临时缓存:mAttachedScrap,mChangedScrap;
- 可直接重用的缓存:mCachedViews;
- 需重新绑定数据的缓存:mRecyclerPool.mScrap;
为什么说前面两个是临时缓存呢?
因为每当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)
这两个方法,一个是水平滑动时的回调,一个是垂直滑动。
我们来看看参数:
- dx(dy) 表示本次较于上一次的偏移量,<0为 向右(下) 滚动,>0为向左(上) 滚动;
- recycler 就是我们刚刚说到的,处理回收和获取Items的对象;
- state 看名字就能大概知道,我们可以借助它来获取到一些很有用的信息,比如说isPreLayout,itemCount之类的;
可以看到这两个方法还需要返回一个int,就是要告诉RecyclerView,本次我们实际消费(偏移)的距离,比如说当滚动到最底部时,不能继续往下滚动,这时候就应该返回0了。
我们在重写这两个方法时,就要根据当前偏移量来对Items做出相应的偏移,这样列表就能随手指滚动起来了,当然了,别忘了回收这一重要环节。
定义自己的LayoutManager
好了,学习了一堆理论知识,是时候将它应用起来,做出属于自己的LayoutManager了,这次我们要做一个很炫酷的效果,就是让Item跟着路径走,哈哈哈。
先给它起个比较接地气的名字,就叫做PathLayoutManager吧,github上搜了一下,果然还没有人用这个名字,赶紧新建一个仓库!
先来两张基本的效果图:
可以看到,上面那些按钮还能跟着路径旋转,就像条蛇一样。其实这个就是获取Path点上的角度,然后根据角度来旋转Item而已。到这里可能有同学会想问:你把人家旋转了,还能正常接收点击或触摸事件吗? 哈哈哈,这个问题我们在之前的文章:(Android实现圆弧滑动效果之ArcSlidingHelper篇)就已经详细分析过了:
- 当我们调用View的setTranslation()、setScale()、setRotation()这一系列方法时,会改变这个View所对应的矩阵;
- 等到ViewGroup分派事件,遍历子View的时候,会判断子View所对应的矩阵是否应用过变换,如果有的话,还会调用matrix的mapPoints方法将触摸坐标点映射到变换后的位置上面,然后再调用View的pointInView方法来判断此点是否在View的范围内;
所以我们不用担心触摸事件的问题。
准备工作 (Keyframes类)
好,平时我们在普通的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上每一个坐标所对应的角度
}
- 1
- 2
- 3
- 4
- 5
来看看初始化的代码 (初始化的时候就把坐标点和角度信息获取下来,之后就可以直接根据索引来取了,效率很高):
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那边应该怎么做。
创建PathLayoutManager
我们先来把最基本的功能做出来:
- 重写generateDefaultLayoutParams方法,这个是必须的,我们直接返回一个长宽都为WRAP_CONTENT的LayoutParams就行;
- 重写onLayoutChildren方法,在这里面布局Items;
- 重写canScrollHorizontally和canScrollVertically方法,使它支持水平或垂直滚动;
- 重写scrollHorizontallyBy和scrollVerticallyBy,并在这里处理滚动工作;
先来想一下构造方法:
- 首先,Path是必不可少的,但我们也不应该强制在创建LayoutManager的时候就要传进来一个非空Path,这个Path应该可以在创建之后再设置;
- 因为我们现在是根据路径的点坐标来对Items进行布局的,而路径可以是任何形状的,那么Items的间距就不能使用margin了,所以我们需要外边传进来一个ItemOffset,用作Items之间的间距;
- 还需要有一个滑动方向,这个方向是指手指滑动的方向:水平or垂直,当然我们也可以内部默认一个;
于是,我们的构造方法就可以写成这样:
/*** @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最多能同时出现几个ItemmItemCountInScreen = mKeyframes.getPathLength() / mItemOffset + 1;}requestLayout();}
好,接下来看看我们需要重写的方法,现在我们已经知道了滑动方向,那么判断能否垂直或水平滚动的两个方法就应该这么写:
@Overridepublic boolean canScrollVertically() {//设置了滑动方向是垂直,才能接受垂直滚动事件return mOrientation == RecyclerView.VERTICAL;}@Overridepublic boolean canScrollHorizontally() {//设置了滑动方向是水平,才能接受水平滚动事件return mOrientation == RecyclerView.HORIZONTAL;}
布局Items
来想想应该怎么布局:因为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) {//得到第一个可见的positionmFirstVisibleItemPos = i;break;}}//结束的positionint 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方法:
@Overridepublic void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {if (state.getItemCount() == 0) {//没有Item可布局,就回收全部临时缓存 (参考自带的LinearLayoutManager)//这里的没有Item,是指Adapter里面的数据集,//可能临时被清空了,但不确定何时还会继续添加回来removeAndRecycleAllViews(recycler);return;}//暂时分离和回收全部有效的ItemdetachAndScrapAttachedViews(recycler);List<PosTan> needLayoutItems = new ArrayList<>();//获取需要布局的itemsinitNeedLayoutItems(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获取Viewitem = 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));//旋转itemitem.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方法就行了,当然,这时候别忘了做回收处理了。
来看看代码:
@Overridepublic int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {//检查Keyframes是否已初始化,即检测是否设置了PathcheckKeyframes();//分离和临时回收detachAndScrapAttachedViews(recycler);//临时记录上一次的offsetfloat lastOffset = mOffsetY;//更新偏移量updateOffsetY(dy);//布局itemrelayoutChildren(recycler, state);//如果offset没有改变,那么就直接return 0,表示不消费本次滑动return lastOffset == mOffsetY ? 0 : dy;}
- 看看updateOffsetY方法里面做了什么:
/*** 更新Y轴偏移量** @param offsetY 偏移量*/private void updateOffsetY(float offsetY) {//更新offsetmOffsetY += 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<>();//获取需要布局的itemsinitNeedLayoutItems(needLayoutItems, state.getItemCount());//判断一下状态if (needLayoutItems.isEmpty() || mKeyframes == null) {removeAndRecycleAllViews(recycler);return;}//开始布局onLayout(recycler, needLayoutItems);}
这个逻辑不用变,因为我们之前定义initNeedLayoutItems方法时,已经把偏移量考虑进去了。
现在把垂直滚动搞定了,那水平滚动也是一样的写法,只需把offsetY换成offsetX就行了。
回收Items
好,现在到回收了,我们可以先参考下自带的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;//排除第一个,所以-1forwardEndIndex = firstIndex - 1;//回收的范围 = 列表中能同时显示的数量forwardStartIndex = forwardEndIndex - mItemCountInScreen;//排除最后一个,所以+1backwardStartIndex = 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// 开始计算的偏移量(正数),因为如果超出了屏幕而不作处理的话,// 下面计算空缺距离的时候,最大值只能是itemLengthint 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 --> 2pos += 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的scalefor (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的scalefor (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));//旋转itemitem.setRotation(tmp.getChildAngle());if (mScaleRatio != null) {//根据item当前位置获取到对应的缩放比例float scale = getScale(tmp.fraction);//设置缩放item.setScaleX(scale);item.setScaleY(scale);}}}
上面用到的mScaleRatio,就是存放缩放比例和位置的数组,但在设置缩放比例的时候,应注意以下几点:
- 数组长度必须是双数;
- 偶数索引表示要缩放的比例;
- 奇数索引表示在路径上的位置(0~1);
- 奇数索引必须要递增,即越往后的数值应越大;
例如:
[0.8, 0.5] 即表示在路径的50%处把item缩放到原来的80%
[0, 0, 1, 0.5, 0, 1] 表示在路径的起点和终点处,把item缩放至原来的0%,而在50%处把item恢复原样。
自动选中效果
先来看看效果图:
其实不应该把落点固定在路径50%处,应该可以自由控制落点,就像这样:
为了更直观地看到效果,我们把item的间距设置大一些:
可以看到,当seekBar进度改变之后,item也相应地作出移动,而且继续滑动item后也还是会回到落点位置,这就是刚刚说的自由控制落点,看上去就觉得灵活了很多。
来想想应该怎么做:
- 可以先遍历屏幕中的item,把每一个item的位置,跟目标落点作比较,从而找到离目标落点最接近的那一个item,然后计算出来相差的距离;
- 再根据这个相差的距离,播放一个ValueAnimator,updateListener里面直接调用我们之前的updateOffset方法来更新偏移量,然后通过requestLayout来通知更新item位置;
- 我们需要重写onScrollStateChanged方法,来监听RecyclerView的状态,当滚动停止时,找到离目标落点最近的item,然后播放偏移动画;
emmmm,整个过程就是这样,我们来看看代码怎么写:
首先是找到最近item的:
/*** 找出离目标落点最近的item索引*/private int findClosestPosition() {//当前认为最近的索引int hitPos = -1;//先获取屏幕中的itemList<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;@Overridepublic 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方法了:
@Overridepublic 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方法,因为现在已经把准备工作都做好了,实现它们可以非常简单:
@Overridepublic 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();}}/*** 平滑滚动*/@Overridepublic void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {if (position > -1 && position < getItemCount()) {checkKeyframes();startValueAnimator(position);}}
哈哈,现在我们直接调用RecyclerView中的scrollToPosition和smoothScrollToPosition也是有效的。
适配wrap_content
我们在上面的测试中,RecyclerView的宽高都是指定为match_parent的,如果现在把宽或高换成wrap_content,会发现列表不显示,因为还没有在测量中作处理,我们需要重写onMeasure方法,并在里面判断一下,如果是宽度指定了wrap_content,那么就把宽度设置为Path的宽度,高度也是一样,我们来看代码:
@Overridepublic 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方法,禁止自动测量:
@Overridepublic boolean isAutoMeasureEnabled() {return false;}
在LinearLayoutManager源码中可以发现,它只重写了isAutoMeasureEnabled方法并return true的,但因为我们的item布局比较特殊,所以需要自己定义一下。
我们来看一下适配了wrap_content之后的效果:
需把 系统设置 - 开发人员选项 - 显示布局边界这一项开启:
哈哈,可以看到,RecyclerView的尺寸会随着Path的宽高改变而改变的。
再发一次我们的效果图,嘻嘻嘻嘻:
好啦,我们这篇文章算是结束了,有错误的地方请指出,谢谢大家!
这篇关于Android 自定义 RecyclerView LayoutManager的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!