看完让你直呼666的自定义LayoutManager之旅

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/c10WTiybQ1Ye3/article/details/83373926

640?wx_fmt=png


今日科技快讯


近日,马蜂窝回应自媒体质疑,公司表示对全站数据进行了检查,点评内容在马蜂窝整体数据量中仅占比2.91%,涉嫌虚假点评的帐号数量在整体用户中的占比更是微乎其微,马蜂窝已对这部分帐号进行清理。自媒体文章所述的马蜂窝用户数量,与事实和第三方机构数据都严重不符。


作者简介


本篇来自 陈小缘 的投稿,通过自定义 LayoutManager 实现了炫酷的飞龙在天效果一起来看看!希望大家喜欢。

陈小缘 的博客地址:

https://me.csdn.net/u011387817


前言


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

好,先来几张效果图:

640?wx_fmt=gif

640?wx_fmt=gif

640?wx_fmt=gif

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

其实这三张图都是有含义的:

首先,我们大家都是龙的传人;

我们之所以能够幸福地生活着,背后自然少不了这些默默守护着我们的振国神器;

在强大的祖国怀抱中,我们的传统文化才能长盛不衰;

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


初步了解 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 上搜了一下,果然还没有人用这个名字,赶紧新建一个仓库!

先来两张基本的效果图:

640?wx_fmt=gif

640?wx_fmt=gif

可以看到,上面那些按钮还能跟着路径旋转,就像条蛇一样。其实这个就是获取 Path 点上的角度,然后根据角度来旋转 Item 而已。到这里可能有同学会想问:你把人家旋转了,还能正常接收点击或触摸事件吗? 哈哈哈,这个问题我们在之前的文章:(Android 实现圆弧滑动效果之ArcSlidingHelper篇)就已经详细分析过了:

当我们调用 View 的 setTranslation()、setScale()、setRotation() 这一系列方法时,会改变这个 View 所对应的矩阵;

等到 ViewGroup 分派事件,遍历子 View 的时候,会判断子 View 所对应的矩阵是否应用过变换,如果有的话,还会调用 matrix 的 mapPoints 方法将触摸坐标点映射到变换后的位置上面,然后再调用 View 的 pointInView 方法来判断此点是否在 View 的范围内;

所以我们不用担心触摸事件的问题。


准备工作(KeyFrame类)


好,平时我们在普通的 View 上做路径动画是做的多了,但把路径动画应用到 RecyclerView 中还是没试过呢,其实这个也不难,核心的还是大家熟悉的 PathMeasure,不过这次我们在获取 Path 上每一个点的坐标的时候,还需要一个平时我们都不留意的东西,就是 getPosTan 方法的最后一个参数 tan,我们正是要利用这个正切值来计算出 Item所需旋转的角度,来看看代码怎么写:

我们模仿 SDK 里面的做法,来创建一个叫 Keyframes 的类 (利用这个来获取 Path 上面的坐标和角度):

640?wx_fmt=png

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

640?wx_fmt=png

来看看如何获取这些值:

SDK中Keyframes 类的 getValue 方法是直接返回一个 PointF 的,但因为我们这次定义的Keyframes多了一个 mAngle,原来的 PointF 已经不能满足了,所以我们还要新建一个包装类,继承一下 PointF,然后加一个 angle:

640?wx_fmt=png

我们来看看改造后的 getValue 方法:

640?wx_fmt=png

mTemp 就是刚刚扩展自 PointF 的类,用来存放这些坐标点和角度等数据。好了,现在我们把路径这一块处理完了,接下来看看 LayoutManager 那边应该怎么做。


创建PathLayoutManager


我们先来把最基本的功能做出来:

  • 重写 generateDefaultLayoutParams 方法,这个是必须的,我们直接返回一个长宽都为WRAP_CONTENT 的 LayoutParams 就行;

  • 重写 onLayoutChildren 方法,在这里面布局 Items;

  • 重写 canScrollHorizontally 和 canScrollVertically 方法,使它支持水平或垂直滚动;

  • 重写 scrollHorizontallyBy 和 scrollVerticallyBy,并在这里处理滚动工作;

先来想一下构造方法:

首先,Path 是必不可少的,但我们也不应该强制在创建 LayoutManager 的时候就要传进来一个非空 Path,这个 Path 应该可以在创建之后再设置;

因为我们现在是根据路径的点坐标来对 Items 进行布局的,而路径可以是任何形状的,那么 Items 的间距就不能使用 margin 了,所以我们需要外边传进来一个 ItemOffset,用作Items 之间的间距;

还需要有一个滑动方向,这个方向是指手指滑动的方向:水平 or 垂直,当然我们也可以内部默认一个;

于是,我们的构造方法就可以写成这样:

640?wx_fmt=png

这个 updatePath 方法也就是创建一个 Keyframes 而已:

640?wx_fmt=png

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

640?wx_fmt=png


布局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)。

640?wx_fmt=png

getScrollOffset 方法很明显就是获取刚刚说的滚动偏移量了,因为现在有两个滑动方向,所以还要判断一下当前方向来返回不同的偏移量:

 /**
     * 根据当前设置的滚动方向来获取对应的滚动偏移量
     */

    private float getScrollOffset() {
        return mOrientation == RecyclerView.VERTICAL ? mOffsetY : mOffsetX;
    }

现在来看看重写的 onLayoutChildren 方法:

640?wx_fmt=png

可以看到我们是先分离和回收了全部有效 Item,获取到需要布局的 Items 之后还调用了一个 onLayout 方法,来看看:

640?wx_fmt=png

可以看到,我们在 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));

640?wx_fmt=png

哈哈哈,可以看到效果啦,当然了,为了更直观地看到效果,后面的那条路径是单独一个View画上去的。

但现在滑动的话,是没有反应的,因为还没有处理偏移。


支持滑动


那现在来想一下应该怎么做:其实很简单,就更新一下 offset 然后调用我们刚刚定义的onLayout 方法就行了,当然,这时候别忘了做回收处理了。
来看看代码:

640?wx_fmt=png

我们先来看看 updateOffsetY 方法里面做了什么:

640?wx_fmt=png

其实也就是更新一下偏移量而已,不过还做了一些判断,就是使它能像正常的列表一样滑动。

好,我们回到 scrollVerticallyBy 方法,可以看到还调用了一个 relayoutChildren,其实这个就是封装了一下上面我们重写的 onLayoutChildren 方法后面 layout 部分,使代码得以重用而已:

640?wx_fmt=png

这个逻辑不用变,因为我们之前定义 initNeedLayoutItems 方法时,已经把偏移量考虑进去了。

现在把垂直滚动搞定了,那水平滚动也是一样的写法,只需把 offsetY 换成 offsetX 就行了。


回收Items


好,现在到回收了,我们可以先参考下自带的LinearLayoutManager,看看它是怎么做的,在源码中可以找到这一处:

640?wx_fmt=png

emmm,它定义的这个方法,里面是使用 removeAndRecycleViewAt 来回收的,还有,它是通过传进来的 startIndex 和 endIndex 来决定回收的范围的,那我们也仿照它这样,通过传进来的开始和结束索引来回收,看看代码怎么写:

640?wx_fmt=png

哈哈哈,就这样了。我们可以分两段来回收,一段是第一个可见 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

加粗的 5 6 7 8 9 是列表中能看到的,那么我们就可以把0~4作为前半段回收,再把10~14作为后半段回收。

那应该怎么得到这个 startIndex 和 endIndex 呢:

还记不记得我们在获取需要 layout 的 item 的时候,把 PosTan 都放在一个 List 了?PosTan 里面正好保存有对应的 index,那么我们就可以拿到屏幕中显示的第一个 item 索引和最后一个索引了,然后根据这个起始索引来进行范围回收,还可以用屏幕中最多显示的 item 数量来作为回收的范围,来看看代码怎么写:

640?wx_fmt=png

就是这样了。

emmm,其实,如果我们不需要开启预布局的话,回收工作还可以做的更简单,这也算是奇技淫巧吧,就是可以直接把 Recycler 里面的 mAttachedScrap 全部放进 mRecyclerPool中,因为我们在一开始就已经调用了 detachAndScrapAttachedViews 方法将当前屏幕中有效的 ViewHolder 全部放进了 mAttachedScrap,而在重新布局的时候,有用的 Holder 已经被重用了,也就是拿出去了,这个 mAttachedScrap 中剩下的 Holder,都是不需要layout 的,所以可以把它们都回收进 mRecyclerPool 中。

来看看这个奇技淫巧的代码:

640?wx_fmt=png

哈哈哈,是不是简单了很多。

来看看效果:

640?wx_fmt=gif

emmm,可以看到,Adapter 中有几百条数据,滑动起来也丝毫不费劲,说明我们在处理回收这个环节中做的还是不错的。


允许滚动溢出


在一开始的效果图中,有一张是可以把全部 item 都滑出屏幕,这个是怎么做到的呢?其实非常简单,我们只需要改一下处理偏移量那个方法:

640?wx_fmt=png

当全部 item 都滑出屏幕之后,就限制继续往这个方向滚动,这样的话,我们反方向滑动时,items 就能立即出现,来看看效果:
为了更清楚地看到效果,我们把背景换成暗色的:

640?wx_fmt=gif

可以看到,在全部 item 滑出去之后,手指还继续滑动了几下,当反方向滑动时,item还是能立即出来,这就是我们所需要的效果了。


无限循环滚动


好,现在来看看无限循环应该怎么做:

其实也可以参照上面的溢出模式,在处理滑动的时候,如果超出了一定范围,就重置滑动偏移量,来看看代码,我们这次还是使用 updateOffsetY 方法来做示范:

640?wx_fmt=png

可以看到上面调用了一个 isSatisfiedLoopScroll 方法,这个方法就是用来判断是否满足无限循环滚动条件的,那就是当前设置的滚动模式为无限循环模式,并且 Item 的总长度要大于 Path 的总长度,因为同一子 View 不能同时添加两个到 ViewGroup 中,如果当前列表能滚动,才可以做无限循环,并不是如果 item 不够我们就自动帮他添加相同的,不应该这样做。

来看看代码:

640?wx_fmt=png

现在到布局了,我们可以先想象成溢出模式那样,不过,当列表有空缺位置的时候,需要补上下一个 item,看例子:

比如现在列表中一共有20条数据,最多同时显示10条:

在溢出模式中是这样的:

15 16 17 18 19 __ __ __ __ __

可以看到19后面的空缺部分,总共有5个,那么在无限循环滚动中,就应该是这样的:

15 16 17 18 19 0 1 2 3 4

也就是把后面空余部分填上对应的 item 了,所以我们需要计算出空缺 item 的个数,来看看代码怎么写:

640?wx_fmt=png

我们知道了空缺的个数后,就能进一步知道当前第一个显示的 item 索引了:

640?wx_fmt=png

好,再封装一下获取需要布局的 item 的方法:

640?wx_fmt=png

那么现在 onLayout 那边就可以直接调用这个方法来获取需要布局的 items 了。
我们来看看效果吧:

640?wx_fmt=gif

可以了,哈哈哈,是不是很开心!


动态设置缩放


可能有很多同学之前也都见过有些 Banner 有缩放的效果,就是越靠近中间就越大,反之越小,我们正是要做这种效果,但是想一下,如果我要缩小而不是放大,或者我要设置两个或三个放大的点呢?显然我们不能把这些数据写死,应该做成动态的,比如说像这样的:

640?wx_fmt=gif

640?wx_fmt=gif

哈哈哈,怎么样,是不是很好玩?

先来想一下我们需要的东西:

  • 一组缩放比例的数值;

  • 一组缩放位置的数值;

其实可以只用一个数组来存放它们,用奇数来表示缩放的位置,偶数表示缩放比例。那么应该怎样计算出每个 item 的缩放比例呢?当列表滑动时,item 的位置也是会改变的。可以用 item 位置相对于 Path 总长度的百分比来进行动态计算。看一下这张图:

640?wx_fmt=png

现在是在路径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%

哈哈,我们把这个公式转换成代码:

640?wx_fmt=png

emmm,我们需要求相对百分比的话,只需要传入起始点,结束点和当前点就行了,那么,我们怎么根据总百分比来找到起始点和结束点呢?来看代码:

640?wx_fmt=png

那现在我们在 item 布局之后,可以通过 PosTan 里面的 fraction 来获取到对应的缩放比例了,然后设置一下 scaleX 和 scaleY 就行了,来改一下 onLayout 方法

640?wx_fmt=png

上面用到的mScaleRatio,就是存放缩放比例和位置的数组,但在设置缩放比例的时候,应注意以下几点:

  • 数组长度必须是双数;

  • 偶数索引表示要缩放的比例;

  • 奇数索引表示在路径上的位置(0~1);

  • 奇数索引必须要递增,即越往后的数值应越大;

例如:

[0.8, 0.5] 即表示在路径的50%处把 item 缩放到原来的80%

[0, 0, 1, 0.5, 0, 1] 表示在路径的起点和终点处,把 item 缩放至原来的0%,而在50%处把item 恢复原样。


自动选中效果

先来看看效果图:

640?wx_fmt=gif

其实不应该把落点固定在路径50%处,应该可以自由控制落点,就像这样:
为了更直观地看到效果,我们把 item 的间距设置大一些:

640?wx_fmt=gif

可以看到,当 seekBar 进度改变之后,item 也相应地作出移动,而且继续滑动 item 后也还是会回到落点位置,这就是刚刚说的自由控制落点,看上去就觉得灵活了很多。

来想想应该怎么做:

可以先遍历屏幕中的 item,把每一个 item 的位置,跟目标落点作比较,从而找到离目标落点最接近的那一个 item,然后计算出来相差的距离;

再根据这个相差的距离,播放一个 ValueAnimator,updateListener 里面直接调用我们之前的 updateOffset 方法来更新偏移量,然后通过 requestLayout 来通知更新 item 位置;

我们需要重写 onScrollStateChanged 方法,来监听 RecyclerView 的状态,当滚动停止时,找到离目标落点最近的 item,然后播放偏移动画;

emmmm,整个过程就是这样,我们来看看代码怎么写:

首先是找到最近 item 的:

640?wx_fmt=png

知道了哪个 item 最接近目标落点之后,开始播放动画:

640?wx_fmt=png

主要是那个 getDistance 方法,看看是怎么计算出相差的距离:

640?wx_fmt=png

上面的 getVisiblePosTanByPosition 方法就是检测当前屏幕中是否有目标索引所对应的item:

640?wx_fmt=png

while 循环条件里面调用的那个 fixOverflowIndex 方法就是把本来越界的索引变成有效索引:

640?wx_fmt=png

emmmm,现在动画已经准备好了,那应该在哪里触发呢?这时候就要重写onScrollStateChanged 方法了:

640?wx_fmt=png

可以看到,我们在监听到列表停止滚动之后,开始播放偏移动画。
其实,我们在可以重写 scrollToPosition和smoothScrollToPosition 方法,因为现在已经把准备工作都做好了,实现它们可以非常简单:

640?wx_fmt=png

哈哈,现在我们直接调用 RecyclerView 中的 scrollToPosition 和 smoothScrollToPosition也是有效的。


适配wrap_content

我们在上面的测试中,RecyclerView 的宽高都是指定为 match_parent 的,如果现在把宽或高换成 wrap_content,会发现列表不显示,因为还没有在测量中作处理,我们需要重写onMeasure 方法,并在里面判断一下,如果是宽度指定了 wrap_content,那么就把宽度设置为 Path 的宽度,高度也是一样,我们来看代码:

640?wx_fmt=png

Path 的宽度即 x 轴上最大的数,高度即y轴上最大的数。
为保险起见,我们还需要重写 isAutoMeasureEnabled 方法,禁止自动测量:

  @Override
    public boolean isAutoMeasureEnabled() {
        return false;
    }

在 LinearLayoutManager 源码中可以发现,它只重写了 isAutoMeasureEnabled 方法并return true 的,但因为我们的 item 布局比较特殊,所以需要自己定义一下。

我们来看一下适配了 wrap_content 之后的效果:

需把 系统设置 - 开发人员选项 - 显示布局边界这一项开启:

640?wx_fmt=gif

哈哈,可以看到,RecyclerView 的尺寸会随着 Path 的宽高改变而改变的。

再发一次我们的效果图,嘻嘻嘻嘻:

640?wx_fmt=gif


总结


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

https://github.com/wuyr/PathLayoutManager


欢迎长按下图 -> 识别图中二维码

或者 扫一扫 关注我的公众号

640.png?

640?wx_fmt=jpeg

猜你喜欢

转载自blog.csdn.net/c10WTiybQ1Ye3/article/details/83373926
今日推荐