Android改进angel引擎,Android—RecyclerView进阶(5)—自定义LayoutManager

本文详细介绍了如何自定义RecyclerView的LayoutManager,以实现类似转盘的效果。通过计算Item的角度和坐标,以及处理滑动事件,使得RecyclerView在横向滑动时,Item能够像转盘一样旋转展示。同时,文章还展示了如何处理边界条件,确保滑动的平滑性和正确性。
摘要由CSDN通过智能技术生成

我的CSDN: ListerCi

我的简书: 东方未曦

一、简介&示例

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

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

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

00b1dc9e10ef

gif-转盘效果.gif

二、布局与移动

2.1 初始化时的Item布局

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

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

00b1dc9e10ef

布局设计.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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值