我的CSDN: ListerCi
我的简书: 东方未曦
一、简介&示例
虽然官方提供的LinearLayoutManager和GridLayoutManager等已经可以满足绝大部分需求了,但是当我们对Item的布局有特殊的需求时就需要我们自定义LayoutManager。自定义LayoutManager作为RecyclerView的一大难点,对自定义View和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