RecyclerView 自定义 LayoutManager ,打造不规则布局
Editor's Note
自定义 LayoutManager 升级打怪之路,推荐阅读~
The following article is from 鸿洋 Author 亓斌
这是 JsonChao 的第 291 期分享
一直以来灵活的LayoutManager也作为RecyclerView的一大特色被介绍,不过自定义LayoutManager的文章倒是不多,一起来看看作者是如何自定义的。
自从google推出了RecyclerView这个控件, 铺天盖地的一顿叫好, 开发者们也都逐渐从ListView,GridView等控件上转移到了RecyclerView上, 那为什么RecyclerView这么受开发者的青睐呢? 一个主要的原因它的高灵活性, 我们可以自定义点击事件, 随意切换显示方式, 自定义item动画, 甚至连它的布局方式我们都可以自定义.
夸完了RecyclerView, 我们再来吐槽一下大家在工作中各种奇葩需求, 大家在日常工作中肯定会遇到各种各种的奇葩需求, 这里没就包括奇形怪状的需求的UI. 站在我们开发者的角度, 看到这些奇葩的UI, 心中无数只CNM呼啸崩腾而过, 在愤愤不平的同时还不得不老老实实的去找解决方案… 好吧, 吐槽这么多, 其实大家都没有错, 站在开发者的角度, 这样的需求无疑增加了我们很多工作量, 不加班怎么能完成? 但是站在老板的角度, 他也是希望将产品做好, 所以才会不断的思考改需求.
开始进入正题, 今天我们的主要目的还是来自定义一个LayoutManager, 实现一个奇葩的UI, 这样的一个布局我也是从我的一个同学的需求那看到的, 我们先来看看效果.
当然了, 效果不是很优雅, 主要是配色问题, 配色都是随机的, 所以肯定没有UI上好看.
原始需求是一个死的布局, 当然用自定义View的形式可以完成, 但是我认为那样不利于扩展, 例如效果图上的从每组3个变成每组9个, 还有一点很重要, 就是用RecyclerView我们还得轻松的利用View的复用机制. 好了, UI我们就先介绍到这, 下面我们开始一步步的实现这个效果.
前面说了, 我们这个效果是利用自定义RecyclerView的LayoutManager实现的, 所以, 首先我们要准备一个类让它继承RecyclerView.LayoutManager.
public class CardLayoutManager extends RecyclerView.LayoutManager
定义完成后, android studio会提醒我们去实现一下RecyclerView.LayoutManager里的一个抽象方法,
这样, 其实一个最简单的LayoutManager我们就完成了, 不过现在在界面上是什么也没有的, 因为我们还没有对item view进行布局. 在开始布局之前, 还有几个参数需要我们从构造传递, 一个是每组需要显示几个, 一个当每组的总宽度小于RecyclerView总宽度的时候是否要居中显示, 来重写一下构造方法.
ok, 在完成准备工作后, 我们就开始着手准备进行item的布局操作了, 在RecyclerView.LayoutManager中布局的入口是一个叫onLayoutChildren的方法. 我们来重写这个方法.
这里的代码很长, 我们一点点的来分析, 首先一个detachAndScrapAttachedViews方法, 这个方法是RecyclerView.LayoutManager的, 它的作用是将界面上的所有item都detach掉, 并缓存在scrap中,以便下次直接拿出来显示.
接下来我们通过以下代码来获取第一个item view并测量它.
View first = recycler.getViewForPosition(0);
measureChildWithMargins(first, 0, 0);
int itemWidth = getDecoratedMeasuredWidth(first);
int itemHeight = getDecoratedMeasuredHeight(first);
为什么只测量第一个view呢?
这里是因为在我们的这个效果中所有的item大小都是一样的, 所以我们只要获取第一个的大小, 就知道所有的item的大小了.
另外还有个方法getDecoratedMeasuredWidth, 这个方法是什么意思?
其实类似的还有很多, 例如getDecoratedMeasuredHeight, getDecoratedLeft… 这个getDecoratedXXX的作用就是获取该view以及他的decoration的值, 大家都知道RecyclerView是可以设置decoration的.
继续代码
int firstLineSize = mGroupSize / 2 + 1;
int secondLineSize = firstLineSize + mGroupSize / 2;
这两句主要是来获取每一组中第一行和第二行中item的个数.
if (isGravityCenter && firstLineSize
* itemWidth < getHorizontalSpace()) {
mGravityOffset =
(getHorizontalSpace() - firstLineSize * itemWidth) / 2;
} else {
mGravityOffset = 0;
}
这几行代码的作用是当设置了isGravityCenter为true, 并且每组的宽度小于recyclerView的宽度时居中显示.
接下来的一个if...else...在if中的是判断当前item是否在它所在组的第一行. 为什么要加这个判断?
大家看效果就知道了, 因为第二行的view的起始会有一个二分之一的item宽度的偏移, 而且相对于第一行, 第二行的高度是偏移了二分之一的item高度. 至于这里面具体的逻辑大家可以对照着效果图去看代码, 这里就不一一解释了.
再往下, 我们记录了item的总宽度和总高度, 并且调用了fill方法, 其实在这个onLayoutChildren方法中我们仅仅记录了所有的item view所在的位置, 并没有真正的去layout它, 那真正的layout肯定是在这个fill方法中了,
在这里面, 我们首先定义了一个displayRect, 他的作用就是标记当前显示的区域, 因为RecyclerView是可滑动的, 所以这个区域不能简单的是0~高度/宽度这么一个值, 我们还要加上当前滑动的偏移量.
接下来, 我们通过getChildCount获取RecyclerView中的所有子view, 并且依次判断这些view是否在当前显示范围内, 如果不再, 我们就通过removeAndRecycleView将它移除并回收掉, recycle的作用是回收一个view, 并等待下次使用, 这里可能会被重新绑定新的数据. 而scrap的作用是缓存一个view, 并等待下次显示, 这里的view会被直接显示出来.
ok, 继续代码, 又一个for循环, 这里是循环的getItemCount, 也就是所有的item个数, 这里我们依然判断它是不是在显示区域, 如果在, 则我们通过recycler.getViewForPosition(i)拿到这个view, 并且通过addView添加到RecyclerView中, 添加进去了还没完, 我们还需要调用measureChildWithMargins方法对这个view进行测量. 最后的最后我们调用layoutDecorated对item view进行layout操作.
好了, 我们来回顾一下这个fill方法都是干了什么工作, 首先是回收操作, 这保证了RecyclerView的子view仅仅保留可显示范围内的那几个, 然后就是将这几个view进行布局.
现在我们来到MainActivity中,
mRecyclerView = (RecyclerView) findViewById(R.id.list);
mRecyclerView.setLayoutManager(
new CardLayoutManager(mGroupSize, true));
mRecyclerView.setAdapter(mAdapter);
然后大家就可以看到上面的效果了, 高兴ing… 不过手指在屏幕上滑动的一瞬间, 高兴就会变成纳闷了. 纳尼? 怎么不能滑动呢? 好吧, 是因为我们的LayoutManager没有处理滑动操作, 是的, 滑动操作需要我们自己来处理…
要想让RecyclerView能滑动, 我们需要重写几个方法.
public boolean canScrollVertically() {}
public int scrollVerticallyBy(int dy,
RecyclerView.Recycler recycler,
RecyclerView.State state) {}
同样的, 因为我们的LayoutManager还支持横向滑动, 所以还有
public boolean canScrollHorizontally() {}
public int scrollHorizontallyBy(int dx,
RecyclerView.Recycler recycler,
RecyclerView.State state) {}
我们先来看看竖直方向上的滑动处理.
第一个方法返回true代表着可以在这个方法进行滑动, 我们主要是来看第二个方法.
首先我们还是先调用detachAndScrapAttachedViews将所有的子view缓存起来, 然后一个if...else...判断是做边界检测, 接着我们调用offsetChildrenVertical来做偏移, 主要代码中这里的参数, 是对scrollVerticallyBy取反, 因为在scrollVerticallyBy参数中这个dy在我们手指往左滑动的时候是正值, 可能是google感觉这个做更加直观吧. 接着我们还是调用fill方法来做新的子view的布局, 最后我们记录偏移量并返回.
这里面的逻辑还算简单, 横向滑动的处理逻辑也相同, 下面给出代码, 就不再赘述了.
ok, 现在我们再次运行程序, 发现RecyclerView真的可以滑动了. 到现在为止我们的自定义LayoutManager已经实现了. 不过那个菱形咋办呢? 算了, 直接搞一张图片上去就行了. 其实刚开始我也是这么想的, 不过仔细想想, 一个普通的图片是有问题的. 我们还是要通过自定义view的方式去实现.
来搞一搞那个菱形
上面提到了, 那个菱形用图片是有问题的, 问题出在哪呢? 先来说答案吧: 点击事件. 说到这可能有些同学已经明白了, 也有一部分还在纳闷中… 我们来具体分析一下. 首先来张图.
大家看黄色框部分, 其实第三个view的布局是在黄色框里面的, 那如果我们点击第一个view的黄色框里面的区域是不是就点击到第三个view上了? 而我们的感觉是点击在了第一个上, 所以一个普通的view在这里是不适用的. 根据这个问题, 我们再来想想自定义这个view的思路, 是不是只要我们在dispatchTouchEvent方法中来判断点击的位置是不是在那个菱形中, 如果不在就返回false, 让事件可以继续在RecyclerView往下分发就可以了?
下面我们根据这个思路来实现这么个view.
代码并不长, 首先我们通过Path来规划好我们要绘制的菱形的路径, 然后在onDraw方法中将这个Path绘制出来, 这样, 那个菱形就出来了.
我们还是重点来关注一下dispatchTouchEvent方法, 这个方法中我们通过一个isEventInPath来判断是不是DOWN事件发生在了菱形内, 如果不是则直接返回false, 不处理事件.
通过上面的分析, 我们发现其实重点是在isEventInPath中, 这个方法咋写的呢?
判断点是不是在某一个区域内, 我们是通过Region来实现的, 首先我们通过Path.computeBounds方法来获取到这个path的边界, 然后通过Region.contains来判断这个点是不是在该区域内.
到现在为止, 整体的效果我们已经实现完成了, 而且点击事件我们处理的也非常棒, 如果大家有这种需求, 可以直接copy该代码使用, 如果没有就当让
大家来熟悉一下如何自定义LayoutManager了.
参考链接:
https://github.com/hehonghui/android-tech-frontier/
最后给出github地址:
https://github.com/qibin0506/CardLayoutManager
END
往期推荐
点击下方卡片关注 JsonChao,为你构建一套
大厂青睐的 T 型人才系统
▲ 点击上方卡片关注 JsonChao,构建一套
大厂青睐的 T 型人才知识体系
欢迎把文章分享到朋友圈