RecyclerView 自定义 LayoutManager, 打造不规则布局

4,348 阅读10分钟
原文链接: mp.weixin.qq.com

本文由亓斌投稿。

亓斌的博客地址:

http://blog.csdn.net/qibin0506

一直以来灵活的LayoutManager也作为RecyclerView的一大特色被介绍,不过自定义LayoutManager的文章倒是不多,一起来看看作者是如何自定义的。


1

RecyclerView的时代

   

自从google推出了RecyclerView这个控件, 铺天盖地的一顿叫好, 开发者们也都逐渐从ListView,GridView等控件上转移到了RecyclerView上, 那为什么RecyclerView这么受开发者的青睐呢? 一个主要的原因它的高灵活性, 我们可以自定义点击事件, 随意切换显示方式, 自定义item动画, 甚至连它的布局方式我们都可以自定义.

2

吐吐嘈

   

夸完了RecyclerView, 我们再来吐槽一下大家在工作中各种奇葩需求, 大家在日常工作中肯定会遇到各种各种的奇葩需求, 这里没就包括奇形怪状的需求的UI. 站在我们开发者的角度, 看到这些奇葩的UI, 心中无数只草泥马呼啸崩腾而过, 在愤愤不平的同时还不得不老老实实的去找解决方案… 好吧, 吐槽这么多, 其实大家都没有错, 站在开发者的角度, 这样的需求无疑增加了我们很多工作量, 不加班怎么能完成? 但是站在老板的角度, 他也是希望将产品做好, 所以才会不断的思考改需求.

3

效果展示

   

开始进入正题, 今天我们的主要目的还是来自定义一个LayoutManager, 实现一个奇葩的UI, 这样的一个布局我也是从我的一个同学的需求那看到的, 我们先来看看效果.

当然了, 效果不是很优雅, 主要是配色问题, 配色都是随机的, 所以肯定没有UI上好看.

原始需求是一个死的布局, 当然用自定义View的形式可以完成, 但是我认为那样不利于扩展, 例如效果图上的从每组3个变成每组9个, 还有一点很重要, 就是用RecyclerView我们还得轻松的利用View的复用机制. 好了, UI我们就先介绍到这, 下面我们开始一步步的实现这个效果.

4

自定义LayoutManager

   

前面说了, 我们这个效果是利用自定义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没有处理滑动操作, 是的, 滑动操作需要我们自己来处理…

5

让RecyclerView动起来

   

要想让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


掘金是一个高质量的技术社区,从 RxJava 到 React Native,性能优化到优秀开源库,让你不错过 Android 开发的每一个技术干货。长按图片二维码识别或者各大应用市场搜索「掘金」,技术干货尽在掌握中。

查看图片

如果你有好的文章想和大家分享,欢迎投稿,直接向我投递文章链接即可。

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

查看图片