Android—RecyclerView进阶(5)—自定义LayoutManager

我的CSDN: ListerCi
我的简书: 东方未曦

一、简介&示例

虽然官方提供的LinearLayoutManager和GridLayoutManager等已经可以满足绝大部分需求了,但是当我们对Item的布局有特殊的需求时就需要我们自定义LayoutManager。自定义LayoutManager作为RecyclerView的一大难点,对自定义View和RecyclerView复用机制相关的知识有一定的要求,建议各位同学打好基础再学习。

首先强推启舰大神的自定义LayoutManager系列博客,可以说把自定义LayoutManager讲透了
RecyclerView系列之三自定义LayoutManager
RecyclerView系列之四实现回收复用
RecyclerView系列之五回收复用实现方式二
RecyclerView系列之六实现滚动画廊控件

当然网上还有很多优秀的自定义LayoutManager样例,例如......马蜂窝:把RecyclerView撸成马蜂窝,当年我第一次看到这篇博客时可谓虎躯一震。

眼瞅着各路大神都实现了这么优秀的自定义LayoutManager,我琢磨许久,把RecyclerView撸成了一个转盘,效果如下所示,今天就来讲讲怎么实现它。

gif-转盘效果.gif

二、布局与移动

2.1 初始化时的Item布局

首先来看如何对item进行布局,我们知道转盘的整体是一个圆,只不过这个圆很大,只有一部分的Item可以显示在屏幕上,因此可以设计布局如下。其中红色框代表屏幕,在初始化时,第0个Item会被布局到屏幕中央,我们称它的角度为0。

那么怎么判断其他Item要不要被布局到屏幕上呢?如果先计算一个Item的坐标再去判断是否与屏幕相交会有些麻烦,不过由于各个Item之间的角度相等,我们容易得到每个Item与中间虚线的角度差,当这个角度差小于某个值的时候,我们认为它会显示在可视区域,就可以将这个Item布局到屏幕上。

布局设计.png

假设这个圆的半径为Radius,Item之间的角度为Angle,初始化时我们需要将前面的几个Item布局到屏幕上。由于Item坐标的计算依赖于圆心,因此我们首先要得到圆心的坐标: (circleX=screenWidth/2, circleY=screenHeight/2 + Radius),之后可以通过圆心坐标和三角函数计算每个Item中心的坐标。

初始化时第i个Item的角度为i*Angle,因此该Item的x坐标为circleX+sin(i*Angle)*Radius,y坐标为circleY-cos(i*Angle)*Radius,不过Java计算三角函数时传入的参数不是角度而是弧度,因此需要将角度转化为弧度,最终计算坐标的代码如下:

float curAngle = index * mEachAngle;
int xToAdd = (int) (Math.sin(2 * Math.PI / 360 * curAngle) * mRadius);
int yToMinus = (int) (Math.cos(2 * Math.PI / 360 * curAngle) * mRadius);
int x = mCircleMidX + xToAdd;
int y = mCircleMidY - yToMinus;

2.2 移动时的Item布局

刚刚我们计算了初始化时Item的坐标,那么Item移动时的坐标该如何计算呢?
由于坐标的计算依赖于当前Item的角度,当转盘移动时,所有Item的角度都会变化。因此可以通过一个值mMovedAngle保存所有Item(也就是转盘整体)向后移动的角度,可得第i个Item此时的角度为i * Angle - mMovedAngle,接下来看如何计算mMovedAngle的值。

由于转盘只支持横向移动,当RecyclerView移动时会传入一个dx表示本次横向移动的距离。当Radius的值很大时,dx基本等于圆弧的长度,所以可以通过周长公式计算单次移动的角度值。

    private float convertDxToAngle(int dx) {
        return (float) (360 * dx / (2 * Math.PI * mRadius));
    }

将角度转化为dx的方法如下,在边界处理时会使用到。

    private int convertAngleToDx(float angle) {
        return (int) (2 * Math.PI * mRadius * angle / 360);
    }

我们在scrollHorizontallyBy(int dx, ...)方法中更新mMovedAngle,首先进行边界处理。mMovedAngle的范围是[0, (getItemCount() - 1) * Angle],假设本次移动的角度为moveAngle ,当mMovedAngle + moveAngle > (getItemCount() - 1) * Angle时,表示滑动到了右边界;当mMovedAngle + moveAngle < 0时,表示滑动到了左边界,此时需要将多余的角度去掉并计算真正的dx,代码如下所示。

    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        float moveAngle = convertDxToAngle(dx);
        int actualDx = dx;
        if (mMovedAngle + moveAngle > getMaxScrollAngle()) {
            moveAngle = getMaxScrollAngle() - mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        } else if (mMovedAngle + moveAngle < 0) {
            moveAngle = -mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        }
        mMovedAngle += moveAngle;
        // 根据mMovedAngle对Item布局......
        return actualDx;
    }

    private int getMaxScrollAngle() {
        return (getItemCount() - 1) * mEachAngle;
    }

三、具体实现

对于LayoutManager来说,不关心转盘的半径和Item的角度差,这两个值由构造函数传入,让用户去自定义。LayoutManager的成员变量和构造函数如下。

public class TurntableLayoutManager extends RecyclerView.LayoutManager {

    private int mRadius; // 转盘半径
    private int mEachAngle; // Item间的角度差

    private int mItemWidth;
    private int mItemHeight;
    private int mCircleMidX; // 圆心X坐标
    private int mCircleMidY; // 圆心Y坐标
    private float mMovedAngle = 0;

    public TurntableLayoutManager(int radius, int eachAngle) {
        this.mRadius = radius;
        this.mEachAngle = eachAngle;
    }

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
    }

    // ......
}

接下来通过onLayoutChildren(...)方法对初始化时的Item进行布局,主要为4步:
① 由于每个Item的大小相等,先将Item的大小计算出来。
② 计算转盘的圆心坐标,用于之后根据角度计算Item的坐标
③ 回收当前屏幕上的ItemView
④ 对要显示在屏幕上的Item布局

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 1. 假设每个Item大小相等, 得到item的大小
        initItemSize(recycler);
        // 2. 根据屏幕中心得到转盘的圆心
        int screenMidX = getWidth() / 2;
        int screenMidY = getHeight() / 2;
        mCircleMidX = screenMidX;
        mCircleMidY = screenMidY + mRadius;
        // 3. 回收屏幕上的ItemView
        detachAndScrapAttachedViews(recycler);
        // 4. 当Item角度的绝对值小于50°时,将其布局到屏幕上
        // 这个值可以自己调整,只要显示效果没问题即可
        for (int i = 0; i < getItemCount(); i++) {
            if (Math.abs(i * mEachAngle) < 50) {
                layoutViewByIndex(recycler, i);
            }
        }
    }

    private void initItemSize(RecyclerView.Recycler recycler) {
        View view = recycler.getViewForPosition(0);
        addView(view);
        measureChildWithMargins(view, 0, 0);
        mItemWidth = getDecoratedMeasuredWidth(view);
        mItemHeight = getDecoratedMeasuredHeight(view);
        removeAndRecycleView(view, recycler);
    }

    private void layoutViewByIndex(RecyclerView.Recycler recycler, int index) {
        float curAngle = index * mEachAngle - mMovedAngle;
        int xToAdd = (int) (Math.sin(2 * Math.PI / 360 * curAngle) * mRadius);
        int yToMinus = (int) (Math.cos(2 * Math.PI / 360 * curAngle) * mRadius);
        int x = mCircleMidX + xToAdd;
        int y = mCircleMidY - yToMinus;

        View viewForPosition = recycler.getViewForPosition(index);
        addView(viewForPosition);
        measureChildWithMargins(viewForPosition, 0, 0);
        // 将item布局
        layoutDecorated(viewForPosition, x - mItemWidth / 2, y - mItemHeight / 2,
                x + mItemWidth / 2, y + mItemHeight / 2);
        // 调整Item自身的旋转角度
        viewForPosition.setRotation(curAngle);
    }

最后就要考虑滑动了,其实在第2节已经讲的差不多了,只要通过已经移动的角度来计算当前Item的实际角度并布局就可以了,这里先使能横向滑动。

    @Override
    public boolean canScrollHorizontally() {
        return true;
    }

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

滑动事件在scrollHorizontallyBy(...)中处理。首先对滑动距离dx进行边界处理,并转化为角度;随后将角度绝对值>=50的Item回收;最后对角度绝对值<50的Item进行布局。

    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        float moveAngle = convertDxToAngle(dx);
        int actualDx = dx;
        if (mMovedAngle + moveAngle > getMaxScrollAngle()) {
            moveAngle = getMaxScrollAngle() - mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        } else if (mMovedAngle + moveAngle < 0) {
            moveAngle = -mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        }
        mMovedAngle += moveAngle;
        for (int i = 0; i < getChildCount(); i++) {
            View view = getChildAt(i);
            if (view != null) {
                int position = getPosition(view);
                float curAngle = position * mEachAngle + mMovedAngle;
                if (Math.abs(curAngle) >= 50) {
                    removeAndRecycleView(view, recycler);
                }
            }
        }
        // 回收当前屏幕上的所有ItemView
        detachAndScrapAttachedViews(recycler);
        for (int i = 0; i < getItemCount(); i++) {
            float curAngle = i * mEachAngle - mMovedAngle;
            if (Math.abs(curAngle) < 50) {
                layoutViewByIndex(recycler, i);
            }
        }
        return actualDx;
    }

    private int getMaxScrollAngle() {
        return (getItemCount() - 1) * mEachAngle;
    }

    private float convertDxToAngle(int dx) {
        return (float) (360 * dx / (2 * Math.PI * mRadius));
    }

    private int convertAngleToDx(float angle) {
        return (int) (2 * Math.PI * mRadius * angle / 360);
    }

写完之后检查一下ViewHolder的复用情况,通过在onCreateViewHolder()中打Log的方式计算,发现一共create了7个ViewHolder,复用情况良好。
到这里这次的自定义LayoutManager就结束了,源码可以去github下载:RecyclerViewDemo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353

推荐阅读更多精彩内容