您的位置:首页 > 其它

掌握自定义LayoutManager之实现流式布局

2016-12-07 08:00 260 查看


今日科技快讯
昨日,人民币兑美元汇率闹出巨大乌龙事件,短短两个小时,Google上查询的人民币兑美元的汇率从6.8 : 1变成了7.4 : 1 ,人民币资产瞬间缩水了约8%!一时间引起了极多人的恐慌。
后经查实,Google使用的是一家xe rate的网站数据,除了这家网站以外,其他网站的汇率数据都还保持在1 : 6.8左右,算是Google闹出了一个大的乌龙事件,也让很多人松了一口气。
作者简介
本篇是 张旭童 的第四篇投稿了,这是一个系列文章,我选取了实战部分分享给大家,如果对LayoutManager还不熟悉的朋友,可以去作者博客看看前置文章。
张旭童 的博客地址:
http://blog.csdn.net/zxt0601
概述
在开始之前,我想说,如果需求是每个Item宽高一样,实现起来复杂度比每个Item宽高不一样的,要小10+倍。
然而我们今天要实现的流式布局,恰巧就是至少每个Item的宽度不一样,所以在计算坐标的时候算的我死去活来。先看一下效果图:



艾玛,换成妹子图后貌似好看了许多,我都不认识它了,好吧,项目里它一般长下面这样:



往常这种效果,我们一般使用自定义ViewGroup实现,我以前也写了一个:

自定义VG实现流式布局  http://blog.csdn.net/zxt0601/article/details/50533658
这不最近再研究自定义LayoutManager么,想来想去也没有好的创意,就先拿它开第一刀吧。
(后话:流式布局Item宽度不一,不知不觉给自己挖了个大坑,造成拓展一些功能难度倍增,观之网上的DEMO,99%Item的大小都是一样的,so,这个系列的下一篇我计划 实现一个Item大小一样 的酷炫LayoutManager。但是最终做成啥样的效果还没想好,有朋友看到酷炫的效果可以告诉我,我去高仿一个。)

自定义LayoutManager的步骤:

以本文的流式布局为例,需求是一个垂直滚动的布局,子View以流式排列。先总结一下步骤:
实现 generateDefaultLayoutParams() 

实现 onLayoutChildren() 

竖直滚动需要 重写canScrollVertically()和scrollVerticallyBy()

下面我们就一步一步来吧。

实现generateDefaultLayoutParams
如果没有特殊需求,大部分情况下,我们只需要如下重写该方法即可。



RecyclerView.LayoutParams 是继承自 android.view.ViewGroup.MarginLayoutParams 的,所以可以方便的使用各种margin。
这个方法最终会在 recycler.getViewForPosition(i) 时调用到,在该方法浩长源码的最下方:



重写完这个方法就能编译通过了,只不过然并卵,界面上是一片空白,下面我们就走进onLayoutChildren()方法 ,为界面添加Item。
注:99%用不到的情况:如果需要存储一些额外的东西在LayoutParams里,这里返回你自定义的LayoutParams即可。
当然,你自定义的LayoutParams需要继承自 RecyclerView.LayoutParams。

onLayoutChildren
该方法是LayoutManager的入口。它会在如下情况下被调用:
在RecyclerView初始化时,会被调用两次

在调用adapter.notifyDataSetChanged()时,会被调用。

在调用setAdapter替换Adapter时,会被调用。

在RecyclerView执行动画时,它也会被调用。即RecyclerView 初始化 、 数据源改变时 都会被调用。

(关于初始化时为什么会被调用两次,我在系列第一篇文章里已经分析过。)
在系列开篇我已经提到,它相当于 ViewGroup 的 onLayout() 方法,所以我们需要在里面layout当前屏幕可见的所有子View,千万不要layout出所有的子View。本文如下编写:



这个 fill(recycler, state) 方法将是你自定义LayoutManager之旅一生的敌人,简单的说它承担了以下任务:
在考虑滑动位移的情况下:
回收所有屏幕不可见的子View

layout所有可见的子View

在这一节,我们先看一下它的简单版本,不考虑滑动位移,不考虑滑动方向等,只考虑初始化时,从头至尾,layout所有可见的子View,在下一节我会配合滑动事件放出它的完整版.



用到的一些工具函数(在系列开篇已介绍过):



如上编写一个超级简单的fill()方法,运行,你的程序应该就能看到流式布局的效果出现了。
可是千万别开心,因为痛苦的计算远没到来。
如果这些都看不懂,那么我建议:
直接下载完整代码,配合后面的章节看,看到后面也许前面的就好理解了= =。

去学习一下自定义ViewGroup的知识。

此时虽然界面上已经展示了流式布局的效果,可是它并不能滑动,下一节我们让它动起来。

动起来

想让我们自定义的LayoutManager动起来,最简单的写法如下:



offsetChildrenVertical(-realOffset) 这句话移动所有的childView.
返回值会被RecyclerView用来判断是否达到边界, 如果返回值 != 传入的dy,则会有一个边缘的发光效果,表示到达了边界。而且返回值还会被RecyclerView用于计算fling效果。
写完编译,哇塞,真的跟随手指滑动了,只不过能动的总共就我们在上一节layout的那些Item,Item并没有回收,也没有新的Item出现。
好了,下面开始正经的写它吧:



这里用realOffset变量保存实际的位移,也是return 回去的值。大部分情况下它=dy。
在边界处,为了防止越界,做了一些处理,realOffset 可能不等于dy。
和别的文章不同的是,我参考了LinearLayoutManager的源码,先考虑滑动位移进行View的回收、填充( fill() 函数),然后再真正的位移这些子Item。
在fill()的过程中

流程:

一. 会先考虑到dy,回收界面上不可见的Item。
二. 填充布局子View
三. 判断是否将dy都消费掉了,如果消费不掉:例如滑动距离太多,屏幕上的View已经填充完了,仍有空白,那么就要修正dy给realOffset。

注意事项一:考虑滑动的方向

在填充布局子View的时候,还要考虑滑动的方向,即填充的顺序,是从头至尾填充,还是从尾至头部填充。
如果是向底部滑动,那么是顺序填充,显示底端position更大的Item。( dy>0)
如果是向顶部滑动,那么是逆序填充,显示顶端positon更小的Item。(dy<0)

注意事项二:流式布局 逆序布局子View的问题

再啰嗦最后一点,我们想象一下这个逆序填充的过程:
正序过程可以自上而下,自左向右layout 子View,每次layout之前判断当前这一行宽度+子View宽度,是否超过父控件宽度,如果超过了就另起一行。
逆序时,有两种方案:

1. 利用Rect保存子View边界

正序排列时,保存每个子View的Rect;
逆序时,直接拿出来,layout。

2. 逆序化

自右向左layout子View,每次layout之前判断当前这一行宽度+子View宽度,是否超过父控件宽度
如果超过了就另起一行。并且判断最后一个子View距离父控件左边的offset,平移这一行的所有子View,较复杂,采用方案1.
(我个人认为这两个方案都不太好,希望有朋友能提出更好的方案。)
下面上码:





思路已经在前面讲解过,代码里也配上了注释,计算坐标等都是数学问题,略饶人,需要用笔在纸上写一写,或者运行调试调试。没啥好办法。
值得一提的是,可以通过 getChildCount() 和 recycler.getScrapList().size() 查看 当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0. 
官方的LayoutManager都是达标的,本例也是达标的,网上大部分文章的Demo,都是不合格的。
原因在系列开篇也提过,不再赘述。
至此我们的自定义LayoutManager已经可以用了,使用的效果就和文首的两张图一模一样。
下面再提及一些其他注意点和适配事项:

适配notifyDataSetChanged
此时会回调onLayoutChildren()函数。因为我们流式布局的特殊性,每个Item的宽度不一致,所以化简处理,每次这里归零。
//初始化区域
mVerticalOffset = 0;
mFirstVisiPos = 0;
mLastVisiPos = getItemCount();

如果每个Item的大小都一样,逆序顺序layoutChild都比较好处理,则应该在此判断,getChildCount(),大于0说明是DatasetChanged()操作,(初始化的第二次也会childCount>0)。根据当前记录的position和位移信息去fill视图即可。

适配Adapter的替换
我根据24.2.1源码,发现网上的资料对这里的处理其实是不必要的。

资料中的做法如下:

当对 RecyclerView 设置一个 新的Adapter 时,onAdapterChanged()方法会被回调,一般的做法是在这里remove掉所有的View。此时onLayoutChildren()方法会被再次调用,一个新的轮回开始。



我的新观点:

通过查看源码+打断点跟踪分析,调用RecyclerView.setAdapter后,调用顺序依次为

1. Recycler.setAdapter():



那么我们查看 setAdapterInternal() 方法:



也就是说 更换Adapter一开始,还没有执行到LayoutManager.onAdapterChanged(),界面上的View都已经被remove掉了,我们的操作属于多余的

2. LayoutManager.onAdapterChanged()

空实现:也没必要实现了
public void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter) {}

3. Recycler.onAdapterChanged():

该方法先清空scapCache区域(貌似也是多余,一开始被清空过了),然后调用RecyclerViewPool.onAdapterChanged() 



4. RecyclerViewPool.onAdapterChanged()

如果没有别的Adapter在用这个RecyclerViewPool,会清空RecyclerViewPool的缓存。



5. LayoutManager.onLayoutChildren()

新的布局开始。

总结
引用一段话

They are also extremely complex, and hard to get right. For every amount of effort RecyclerView requires of you, it is doing 10x more behind the scenes.

本文Demo仍有很大完善空间,有些需要完善的细节非常复杂,需要经过多次试验才能得到正确的结果(这里我更加敬佩Google提供的三个LM)。每一个我们想要实现的需求,可能要花费比我们想象的时间*10倍的时间。
上篇也提及到的,不要过度优化,达成需求就好。
可以通过getChildCount()和recycler.getScrapList().size() 查看当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0.
官方的LayoutManager都是达标的,本例也是达标的,网上大部分文章的Demo,都是不合格的。
感兴趣的同学可以对网上的各个Demo打印他们onCreateViewHolder执行的次数,以及上述两个参数的值,和官方的LayoutManager比较,这三个参数先达标,才算是及格的LayoutManager,但后续优化之路仍很长。
本系列文章相关代码传送门:

自定义LayoutManager实现的流式布局 https://github.com/mcxtzhang/FlowLayoutManager
更多
每天学习累了,看些搞笑的段子放松一下吧。关注最具娱乐精神的公众号,每天都有好心情。



如果你有好的技术文章想和大家分享,欢迎向我的公众号投稿,投稿具体细节请在公众号主页点击“投稿”菜单查看。

欢迎长按下图 -> 识别图中二维码或者扫一扫关注我的公众号:



如果觉得有收获可赞赏作者
赞赏

人赞赏
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: