本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

前言

哈哈哈,起这个标题并非标题党,这次真的是飞龙在天。至于为什么是第十一式呢,因为这刚好是第十一篇文章。
好,先来几张效果图:


是不是感觉很好玩的样子呢?上面的每一个Item(图片)都是可以能够像普通列表那样接受触摸事件的哦,因为这本质上也只是一个RecyclerView而已。
其实这三张图都是有含义的:

  1. 首先,我们大家都是龙的传人;
  2. 我们之所以能够幸福地生活着,背后自然少不了这些默默守护着我们的振国神器;
  3. 在强大的祖国怀抱中,我们的传统文化才能长盛不衰;

所以,大家趁年轻努力学习,将来为实现中华民族伟大复兴的中国梦添砖加瓦!

好,下面我们开始进入正题


初步了解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,无非就三件事:

  1. 添加 通过addView方法把子View添加进ViewGroup或直接在xml中直接添加;
  2. 测量 重写onMeasure方法并在这里决定自身尺寸以及每一个子View大小;
  3. 布局 重写onLayout方法,在里面调用子View的layout方法来确定它的位置和尺寸;

其实在自定义LayoutManager中,在流程上也是差不多的,我们需要重写onLayoutChildren方法,这个方法会在初始化或者Adapter数据集更新时回调,在这方法里面,需要做以下事情:

  1. 进行布局之前,我们需要调用detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);
  2. 分离了之后,我们就要想办法把它们再添加回去了,所以需要通过addView方法来添加,那这些View在哪里得到呢? 我们需要调用 Recycler的getViewForPosition(int position) 方法来获取;
  3. 获取到Item并重新添加了之后,我们还需要对它进行测量,这时候可以调用measureChild或measureChildWithMargins方法,两者的区别我们已经了解过了,相信同学们都能根据需求选择更合适的方法;
  4. 在测量完还需要做什么呢? 没错,就是布局了,我们也是根据需求来决定使用layoutDecorated还是layoutDecoratedWithMargins方法;
  5. 在自定义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)

这两个方法,一个是水平滑动时的回调,一个是垂直滑动。
我们来看看参数:

  1. dx(dy) 表示本次较于上一次的偏移量,<0为 向右(下) 滚动,>0为向左(上) 滚动
  2. recycler 就是我们刚刚说到的,处理回收和获取Items的对象;
  3. 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上每一个坐标所对应的角度
}

来看看初始化的代码 (初始化的时候就把坐标点和角度信息获取下来,之后就可以直接根据索引来取了,效率很高):

    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最多能同时出现几个Item
            mItemCountInScreen = mKeyframes.getPathLength() / mItemOffset + 1;
        }
        requestLayout();
    }

好,接下来看看我们需要重写的方法,现在我们已经知道了滑动方向,那么判断能否垂直或水平滚动的两个方法就应该这么写:

    @Override
    public boolean canScrollVertically() {
    	//设置了滑动方向是垂直,才能接受垂直滚动事件
        return mOrientation == RecyclerView.VERTICAL;
    }

    @Override
    public 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) {
                //得到第一个可见的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就行了。

回收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;

        //排除第一个,所以-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~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;
        //先获取屏幕中的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也是有效的。

适配wrap_content

我们在上面的测试中,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的宽高改变而改变的。

再发一次我们的效果图,嘻嘻嘻嘻:
在这里插入图片描述

好啦,我们这篇文章算是结束了,有错误的地方请指出,谢谢大家!

github地址:https://github.com/wuyr/PathLayoutManager 欢迎star

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐