作者简介
本篇来自 管思妥耶夫斯基 的投稿,介绍了如何使用 RecyclerView 实现复杂的布局,希望对大家有所帮助。
管思妥耶夫斯基 的博客地址:
http://blog.csdn.net/guanyingcao
前言
我们在工作中遇到最多的视图场景恐怕就是各种样式的列表了,这也是由手机屏幕有限的尺寸决定的,随着需求的日益丰满,我们会发现列表的样式也随之做着各种各样的变更:样式越来越多了,布局越来越复杂了,如果我们前面的布局是单纯将各种ViewGroup拼接到一块的,那改动起来就费事了,暂且不说数据量大引起的卡顿问题,面临的工作量绝不是修改布局文件就能搞定的,数据的绑定、事件触发的设置、滑动的处理、手势冲突的解决…甚至还可能要加上些高级UI特效。打开淘宝或京东,我们能看到这样的布局样式:
图1
图2
图3
相信做过电商类app的朋友在产品拿到我们面前这样的页面时都纠结过如何去实现它,大概有这样几种思路:
1、老老实实写布局,UI有多少内容统统手写出来。呵呵。
2、利用滚动控件做嵌套,例如图1可以利用 ScrollView 嵌套多个 GridView 实现,图2可以利用 ListView 嵌套 GridView 实现;
3、利用 RecyclerView 的多级嵌套实现,例如实现这样的布局:
当数据量较大、分屏页数较多的时候,2 和 3 会出现明显的卡顿,这是因为 cpu 需要同时处理各个滑动布局的内部item位置关系以及数据的赋值,这样一来就容易出现 cpu 和 gpu 的计算与展示出现不同步的现象,导致屏幕显示丢帧,造成视觉卡顿的现象,尤其是当item的布局又很复杂时更容易出现此情况,甚至可能出现oom。
4、使用 RecyclerView 实现全布局,用一个 RecyclerView 实现复杂的布局列表,这一种是完全符合谷歌的设计标准的,同时充分利用了 RecyclerView 双缓存的原理,下面我通过一个很典型的例子,带着大家一步步实现它,并通过该例子,解析以上几种常见的布局样式,相信看完后你可能跟我有同样的感觉:绝大部分的复杂列表都是有规可循的。
RecyclerView双缓存技术
RecyclerView 内部维护了一个二级缓存(算上用户设置的,实际上拥有三级缓存),这些缓存是由 RecyclerView 的一个 final 类型的内部类所管理的,实际上由其以下缓存变量决定:
缓存与复用的原理,看下 Google IO 视频中的一张截图:
我们看到,当 ViewHolder 滑出页面时,会暂时存放到 Cache 中,而从 Cache 中移除的 holder,会存放到 RecyclerViewPool 的循环缓存池之中,默认情况下,Cache 缓存2个 holder,RecyclerViewPool 缓存5个 holder,另外不同的 viewType 的缓存互相没有影响。
复杂布局的典型样式
实际上,以上列举的布局样式,可以大致归纳为下图所示:
看着好复杂的样子,举个例子:
我们约定:
-
列表的最顶部的布局叫做 Header,例如常见的轮播图;
-
列表中间区域我们称之为分组;
-
分组的标题部分我们称之为 SectionHeader;
-
分组的内容项我们称之为 SectionBody;
-
分组的结尾称之为 SectionFooter;
-
列表的结束布局称之为 Footer;
复杂布局的列表适配器
先看下我们要实现的效果:
首先定义一个抽象类 SectionedRecyclerViewAdapter,继承 RecyclerView.Adapter
接着对数据分组,定义四个数组,分别记录每项分组的头部数据的 section 的位置,分组内的每一项的 position 的位置:
例如有这样一种数据结构:
其中的年级个班级可分别表示为 section 和 position;接着准备游标,标记各组的 view:
源码里我尽可能的都加上了注释,这里我只截取关键部分,实际上最关键的部分是做各个 item 所在位置关系的计算:
第1步:计算出 item 的总数量,这里定义了一个抽象方法,用来标识当前的分组是否含有 SectionFooter,有的话,遍历时要多加1;
第2步:得到 item 的总数量后,初始化几个数组:初始化与 position 相对应的 section 数组,初始化与 section 相对应的 position 的数组,初始化当前位置是否是一个 Header 的数组,初始化当前位置是否是一个 Footer 的数组;
第3步:通过计算每个 item 的位置信息,将上一步初始化后的数组填充数据,最终这几个数组保存了每个位置的 item 的状态信息,即:是否是 header,是否是 footer,所在的 position 是多少,所在的 section 是多少:
RecyclerView 有一个内部类 AdapterDataObserver,看起名称就知道是用来监控 adapter 数据集变化的,我们自定义一个内部类,继承它,复写 onChanged()方法,当数据集合放生变化时,重新计算各 ItemView 之间的位置关系:
接着复写 onCreateViewHolder,绑定布局类型,这里定义了6种类型的布局:
接着实现 onBindViewHolder,这里做了不同情况的区分:当整个列表拥有头布局的时候是一种情况,没有头布局的时候是一种情况:
接着复写 getItemViewType,告知 RecyclerView 在各个位置的 item 是属于哪一个布局类型的。
这样我们的基本的多布局的 adapter 基类就算完成了,里面我定义了分项 item 的点击事件和取数据的方法;
使用很简单,我们定义好各项的布局:Header的布局、Footer的布局、SectionHeader的布局、SectionFooter的布局、上拉加载的布局,接着实现各自的ViewHolder。
然后就可以定义我们具体的适配器,让它继承自写好的 SectionedRecyclerViewAdapter,在里面完成数据的绑定与展示,在这里,有个地方需要注意的是,需要动态设置 SectionBody 的每个 item 长和宽,并设置其左右边距,需要做一个计算,看图:
代码设置:
接着,每行的列数,实际上是由 GridLayoutManager.SpanSizeLookup 这个类去控制的,我们继承它,实现控制我们任何地方要展示的列数:
public class SectionedSpanSizeLookup extends GridLayoutManager.SpanSizeLookup
实现上拉加载
我们需要监听 RecyclerView 的 OnScrollListener,定义一个类,继承自它,这里我们需要做的是:
1、当滚动状态为 SCROLL_STATE_IDLE 时,判断当前 item 的总数是否填充满了一屏,如果没满,也就没有上拉加载了;
2、当可见 item 的最后一个可见的 item 的位置与 item 的总数一致时,进行下一步;
3、加一个标识符 isLoading,为 true 表示正在请求,请求结束后置为 false,防止多次请求;
这里有一个细节,就是滑动边界的容差值,当 childView 边界完全显示在界面中时才会检测成功.这就导致了一个可能的情况是只差一点点滑动到边界时,也不会检测成功而出发上拉加载的回调,所以要求很高的灵敏度,故加上上下两个容差值,我们认为,当滑动到接近边界时,就认为需要进行上拉加载了,关键代码如下:
实现复杂布局分割线
要实现分割线,需要自定义类继承自 RecyclerView.ItemDecoration,ItemDecoration 有三个方法提供给开发者拓展,依次为:
getItemoffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)
outRect 为包裹在 itemView 外层 View 的坐标参数,如下图,例如:设置 outRect.set(0,0,0,0); 表示 itemView 的四周没有任何的分割空间存在,set 的四个参数分别表示 外围View 距离 itemView 左边、上方、右边、下方四个方向的间隔距离,尤其需要注意的是,在此处设置了 outRect 的四个方向的参数之后,会默认将这个间距设置到 itemView 的四个方向的 padding 上,由此我们可以知道,getItemoffsets 这个方法是我们必须要复写且实现的。
onDraw(Canvas c, RecyclerView parent, RecyclerView.State state)
外围 View 的绘制将被 itemView 遮盖。
onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state)
外围View 将绘制在 itemView 之上,即遮盖 itemView,当我们绘制一些特殊需求时,此方法很是受用,例如:为每个 item 绘制一个角标,表示其状态,例如很多商品都有热卖或者优惠券的角标,利用画笔将 bitmap 绘制出即可:
我先把我们最终要实现的效果图贴出来,看下我们要绘制的分割线的模样和位置:
我们看到,只有每个分组的顶部存在分割线,并且列表的第一个的顶部是不需要分割线的,实现的思路也很简单,我们只需要计算出每个分组 Header 所在的位置,然在在 Header 的顶部设置好外围空间即可:
需要注意,如果用户设置了各方向的 Margin 值,需要在取得 itemView 的各方向的 margin属性后,将该margin抵消掉,因为,用户设置margin的意图明显不是想让分割线覆盖掉的。
最后补充的是,各子View的点击事件,实际上,在 SectionedRecyclerViewAdapter 之中已经预定义了点击回调的接口,我们依然可以通过 EventBus 这种广播框架便捷的实现我们的效果,不赘述。
以上便是实现一个典型的复杂布局的全部思路过程,源码在最后放出,基于此,下面挨个突破各种不规则的复杂布局。
添加空布局
对于空布局,我们希望在使用的时候只需要一行代码 setEmtyView(view),就行了,adapter 无数据时自动调用空布局,看下实现思路:
首先在 SectionedRecyclerViewAdapter 中添加两个私有变量:
一个是空布局View,一个是记录空布局是否显示,然后定义一个方法 checkEmpty(),当数据集合发生改变时,检查是否是空布局:
最后再修改 onCreateViewHolder() 和 onBindViewHolder() 方法,添加空布局的情况即可,使用的时候,只需要 adapter.setEmptyView(view) 一行代码。
提供各种布局实现思路
先看淘宝的首页,也就是上面 图1,整个滚动视图,顶部是一个广告轮播,接着是一个分类的网格视图,再接着是类似公告的广告显示区域,下面是一条分割线,再往下又是一个网格视图,广告轮播我们可以当做整个列表的顶部 Header,广告公告的视图当做每一个分组的 footer,不需要显示的做隐藏。
当然分类区域每行的列数和分割线下面的推荐商品区域的每行列数有差异的,这个在 SpanSizeLookup 这个继承类中去控制即可。我们又看到,不仅列数不一致,连样式都变了!实际上,有两种方案可以控制样式的变动,一种是将所有的样式都写好到一个布局里面,控制其显示与隐藏(可以使用 ViewStub 做隐藏与显示),考虑到性能问题,不大推荐这种方法。
另外一种是为不同的分组加一个类型判断,例如 section=0 的分组是分类的分组类型,我们定义一个 SECTION_TYPE_0 的常量来标识它,section=1 的分组是商品推荐分组类型,我们再定义一个常量 SECTION_TYPE_1 的常量来标识它,以此类推,然后修改我们写好的 SectionedRecyclerViewAdapter 这个类,添加泛型类型,然后分别在复写方法 onCreateViewHolder、onBindViewHolder 和 getItemViewType 里作区分,最后在我们继承SpanSizeLookup的类中,按照定义好的常量去做区分即可。
思路有了,实现起来并不复杂。实际上,甚至连分割线以上的部分,即:轮播、分类、广告公告都可以成为列表的Header,只不过这样的话,分类需要我们单独去完成布局的构建,单省去了分组布局多类型的步骤。图2、3 和 我们的demo一样,按思路实现即可。
接着看京东的分类,这是一个非典型的分类布局,不过依然可以找到规则,思路是,将分组的标题、输入价格的两个控件、以及下方的分割线合并到一起,作为每个分组的 Header便可轻松解决,在需要的位置控制相关控件的隐藏与显示即可。画出来是这样的:
小结:加点题外话吧,实际上,技术的积累没有什么捷径可言,点点滴滴都是自己一步一个脚印走过来的,在学习新的东西的时候,我们除了要抱着务实本分的基本原则外,我觉得还要有敢于钻研、不怕争辩的的态度。最后,路漫漫其修远兮,希望各位都能在后半年有长足的进步,完善自己的一套知识体系。源码地址:
https://github.com/gycold/SectionRecyclerViewAdapter
更多
每天学习累了,看些搞笑的段子放松一下吧。关注最具娱乐精神的公众号,每天都有好心情。