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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值