这个视图实现了一个可以循环滚动的选择器(类似于3D滚轮效果)
比如日期选择器,自定义的一些选择器
1.效果图

2.功能需求
主要功能包括:
- 支持循环滚动(当滚动到边界时,可以循环)
- 3D效果:中间项最大,越往两边越小且透明度降低
- 支持快速滑动(fling)和自动对齐到最近项
- 支持触摸拖动
3.实现思路分析
第一步: 测量,静态绘制 版本MINI
第二步: 滑动,自动居中 默认版本
第三步: 惯性滑动 PLUS版本
第四步: 3D效果和循环滚动 3D版本
3.1 测量,静态绘制 版本MINI
测量原理详解
arduino
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int height = itemHeight * visibleItems;
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), height);
}
3.1.1 高度计算:
- 固定公式:高度 = 项高度 × 可见项数
- 完全由控件内部状态决定
- 不响应父容器的高度约束
3.1.2 getDefaultSize() 宽度测量
这是View类提供的标准测量方法
arduino
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED: // 父容器未指定约束
result = size; // 使用建议的最小宽度
break;
case MeasureSpec.AT_MOST: // wrap_content模式
case MeasureSpec.EXACTLY: // 固定值或match_parent
result = specSize; // 使用父容器指定的尺寸
break;
}
return result;
}
3.1.3 getSuggestedMinimumWidth()
getSuggestedMinimumWidth()
是 Android View
类中的一个受保护方法,用于确定视图在布局过程中应使用的最小宽度建议值。
核心逻辑
- 无背景时 :返回视图的显式最小宽度
mMinWidth
- 有背景时 :返回
mMinWidth
和背景最小宽度中的较大值
3.2 绘制的原理
scss
protected void onDraw(Canvas canvas) {
float centerY = getHeight() / 2f; // 控件垂直中心点
// 绘制可见项(额外绘制2项保证滚动流畅)
for (int i = -visibleItems/2 - 2; i <= visibleItems/2 + 2; i++) {
// 计算虚拟索引(连续滚动的基础)
int virtualIndex = (int)(scrollY / itemHeight) + i;
// 将虚拟索引转换为实际数据索引(实现循环)
int realIndex = getRealIndex(virtualIndex);
// 计算当前项Y坐标(考虑滚动偏移)
float yPos = centerY + i * itemHeight - (scrollY % itemHeight);
// 跳过屏幕外项
if (yPos < -itemHeight || yPos > getHeight() + itemHeight) continue;
boolean isSelected = realIndex == selectedItem;
// 禁用3D时的简单绘制
if(!isUse3D){
canvas.drawText(items.get(realIndex), getWidth()/2, yPos,
isSelected ? selectedTextPaint : textPaint);
continue;
}
// ========== 3D效果核心实现 ==========
// 1. 计算与中心点的距离(归一化距离)
float distance = Math.abs(i - (scrollY % itemHeight)/itemHeight);
// 2. 根据距离计算缩放比例(越远越小)
float scale = 1.0f - 0.2f * distance;
// 3. 根据距离计算透明度(越远越透明)
float alpha = 1.0f - 0.5f * distance;
canvas.save();
// 将坐标系移动到项中心点
canvas.translate(getWidth()/2f, yPos);
// 应用缩放(实现近大远小)
canvas.scale(scale, scale);
// 设置透明度
textPaint.setAlpha((int)(alpha * 255));
selectedTextPaint.setAlpha((int)(alpha * 255));
// 绘制文本(在变换后的坐标系中心)
canvas.drawText(items.get(realIndex), 0, 0, isSelected ? selectedTextPaint : textPaint);
canvas.restore();
}
// 绘制选中区域分割线
float dividerTop = centerY - itemHeight / 2f;
float dividerBottom = centerY + itemHeight / 2f;
canvas.drawLine(0, dividerTop, getWidth(), dividerTop, dividerPaint);
canvas.drawLine(0, dividerBottom, getWidth(), dividerBottom, dividerPaint);
}
绘制中线: 找到整个控件的中心,还要每个item的高度值
绘制文本:
-
计算视图中心Y坐标
centerY
-
从
-visibleItems/2
到visibleItems/2
循环,绘制以选中项为中心的可见项:- 计算实际数据索引
selectedItem + i
- 跳过超出数据范围的索引
- 根据是否是中间项(i==0)决定使用哪种画笔
- 核心计算: 计算Y坐标:
float yPos = centerY + i * itemHeight - (scrollY % itemHeight);
- 计算实际数据索引
-
scrollY % itemHeight
实现平滑的项间过渡效果。 -
虚拟索引转实际索引(循环滚动核心算法)
arduino
/**
* @param virtualIndex 无限滚动的虚拟索引(可正可负)
* @return 实际数据索引 [0, items.size()-1]
*/
private int getRealIndex(int virtualIndex) {
if (items.isEmpty()) return 0;
int size = items.size();
// 双重取模确保结果为正:(-1 % 5 + 5) % 5 = 4
return (virtualIndex % size + size) % size;
}
3.3 滑动,自动居中


3.3.1 滑动
ini
public boolean onTouchEvent(MotionEvent event) {
// 初始化速度跟踪器
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain();
}
velocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 终止当前滚动动画
if (!scroller.isFinished()) {
scroller.abortAnimation();
}
lastTouchY = event.getY();
isDragging = true; // 标记拖动开始
break;
case MotionEvent.ACTION_MOVE:
if (!isDragging) break;
// 计算垂直移动增量(注意方向:手指下滑→内容上移→scrollY增加)
float deltaY = lastTouchY - event.getY();
lastTouchY = event.getY();
scrollY += deltaY; // 更新虚拟滚动位置
// 更新选中项(四舍五入取整)
selectedItem = getRealIndex(Math.round(scrollY / itemHeight));
invalidate(); // 触发重绘
break;
最重要的就是得到滚动的距离:scrollY
scrollY: 是整个偏移量
deltaY: 是一小段距离的偏移量
3.3.2 自动居中
ini
/**
* 将滚动位置对齐到最近的完整项
*/
private void alignToNearestItem() {
isAligning = true;
// 计算目标虚拟索引(四舍五入)
int targetVirtualIndex = Math.round(scrollY / itemHeight);
float targetY = targetVirtualIndex * itemHeight; // 目标精确位置
// 微小偏移直接修正(避免不必要的动画)
if (Math.abs(scrollY - targetY) < 1) {
scrollY = targetY;
selectedItem = getRealIndex(targetVirtualIndex);
invalidate();
} else {
// 启动平滑滚动动画
scroller.startScroll(
0, (int)scrollY, // 起始位置
0, (int)(targetY - scrollY), // 滚动距离
ALIGN_DURATION // 动画时长
);
selectedItem = getRealIndex(targetVirtualIndex);
invalidate();
}
isAligning = false;
}
scss
@Override
public void computeScroll() {
// Scroller动画更新回调
if (scroller.computeScrollOffset()) {
scrollY = scroller.getCurrY(); // 更新虚拟滚动位置
selectedItem = getRealIndex(Math.round(scrollY / itemHeight));
invalidate();
} else {
// 动画结束后强制对齐(防止微小偏移)
if (!isAligning && Math.abs(scrollY % itemHeight) > 0.1f) {
alignToNearestItem();
}
}
}
- 精确计算 :
targetY = targetVirtualIndex * itemHeight
- 微小偏移处理:直接修正避免动画
- 平滑动画 :使用
Scroller
执行300ms的过渡动画 - 二次校验 :在
computeScroll()
中确保最终对齐
3.4 惯性滚动(ACTION_UP
+ 速度检测)
scss
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
isDragging = false;
// 计算当前滑动速度(像素/秒)
velocityTracker.computeCurrentVelocity(1000);
float yVelocity = velocityTracker.getYVelocity();
// 根据速度决定后续行为
if (Math.abs(yVelocity) > MIN_FLING_VELOCITY) {
// 满足惯性滚动条件:使用Scroller模拟惯性
scroller.fling(
0, (int)scrollY, // 起始位置
0, (int)-yVelocity, // 速度(注意Y轴反向)
0, 0, // X边界(无限制)
Integer.MIN_VALUE, // Y最小值(无限)
Integer.MAX_VALUE // Y最大值(无限)
);
} else {
// 慢速抬起:对齐到最近项
alignToNearestItem();
}
recycleVelocityTracker();
invalidate();
-
速度跟踪 :使用
VelocityTracker
计算滑动速度 -
两种结束模式:
- 快速滑动:触发
Scroller.fling()
惯性滚动 - 慢速抬起:执行
alignToNearestItem()
对齐最近项
- 快速滑动:触发
-
方向处理 :手指下滑→内容上移→
scrollY
增加
3.5 循环滚动
arduino
/**
* 虚拟索引转实际索引(循环滚动核心算法)
* @param virtualIndex 无限滚动的虚拟索引(可正可负)
* @return 实际数据索引 [0, items.size()-1]
*/
private int getRealIndex(int virtualIndex) {
if (items.isEmpty()) return 0;
int size = items.size();
// 双重取模确保结果为正:(-1 % 5 + 5) % 5 = 4
return (virtualIndex % size + size) % size;
}
- 循环滚动机制
- 虚拟索引系统 :使用
scrollY
表示虚拟滚动位置(非真实像素) - 索引转换 :
getRealIndex()
通过取模运算将无限滚动的虚拟索引映射到有限数据集 - 示例:有5个元素时,虚拟索引-1 → 实际索引4,实现无缝循环
- 虚拟索引系统 :使用
循环索引转换公式:
scss
(虚拟索引 % 数据长度 + 数据长度) % 数据长度
- 解决负数取模问题:
(-1 % 5 + 5) % 5 = 4
- 保证结果始终在
[0, size-1]
范围内
3.6 3D效果
主要是绘制的适合进行实现
scss
// ========== 3D效果核心实现 ==========
// 1. 计算与中心点的距离(归一化距离)
float distance = Math.abs(i - (scrollY % itemHeight)/itemHeight);
// 2. 根据距离计算缩放比例(越远越小)
float scale = 1.0f - 0.2f * distance;
// 3. 根据距离计算透明度(越远越透明)
float alpha = 1.0f - 0.5f * distance;
canvas.save();
// 将坐标系移动到项中心点
canvas.translate(getWidth()/2f, yPos);
// 应用缩放(实现近大远小)
canvas.scale(scale, scale);
// 设置透明度
textPaint.setAlpha((int)(alpha * 255));
selectedTextPaint.setAlpha((int)(alpha * 255));
// 绘制文本(在变换后的坐标系中心)
canvas.drawText(items.get(realIndex), 0, 0, isSelected ? selectedTextPaint : textPaint);
canvas.restore();
- 距离计算 :计算每个项与中心线的距离
distance
- 动态缩放 :
scale = 1.0 - 0.2 * distance
(距离越大,显示越小) - 透明度衰减 :
alpha = 1.0 - 0.5 * distance
(距离越大,越透明) - 坐标系变换 :通过
canvas.translate()
和canvas.scale()
实现项的中心点变换 3D效果核心计算:
ini
// 计算当前项与中心线的归一化距离
float distance = Math.abs(i - (scrollY % itemHeight)/itemHeight);
// 根据距离计算缩放和透明度
float scale = 1.0f - 0.2f * distance;
float alpha = 1.0f - 0.5f * distance;
i
:当前项在可见区域的序号(中心为0)scrollY % itemHeight
:当前滚动偏移量(0~itemHeight)
该实现通过巧妙的坐标系变换和物理滚动模拟,实现了流畅的3D循环滚轮效果,适合用于时间选择、选项选择等场景。
3.7 边界处理
- 绘制范围限制 :只绘制可见项及缓冲区(
-visibleItems/2-2
到+visibleItems/2+2
)
4.整体架构图

5.总结
5.1 零点坐标:是对于你当前的自定义控件的左上角,而不是屏幕的左上角
5.2 computeScroll()是什么时候调用
(1) 当调用 invalidate()
后
- 当你调用
scroller.fling()
或scroller.startScroll()
后,必须手动调用invalidate()
。 - 这会触发 View 的重绘流程,在
View.draw()
的执行过程中,系统会检查Scroller
是否还有未完成的动画:
(2) 在动画未完成时递归调用
- 在
computeScroll()
内部,如果scroller.computeScrollOffset()
返回true
(表示动画仍在进行),你需要 再次调用invalidate()
: - 这样会形成一个循环:
invalidate() → draw() → computeScroll() → invalidate() → ...
直到动画完成(scroller.isFinished()
为true
)。
5.3 fling()和startScroll()的区别是什么 核心区别对比
特性 | startScroll() |
fling() |
---|---|---|
动画类型 | 线性匀速 | 减速运动(惯性) |
驱动方式 | 指定距离 + 时长 | 初始速度 + 范围限制 |
速度控制 | 固定时长完成 | 速度逐渐减至 0 |
参数方向 | dx/dy 直接表示滚动方向 |
velocityY 需取反(与触摸事件相反) |
典型用途 | 对齐项、按钮点击滚动 | 快速滑动后的惯性效果 |
5.4 惯性滚动的图解
1. fling()
- 启动惯性滚动
2. startScroll()
- 启动平滑滚动
3. computeScroll()
- 滚动动画主循环
4. computeScrollOffset()
- 计算滚动偏移

插值器应用
ini
scroller = new Scroller(context, new DecelerateInterpolator());
5.5 滚动的3种方案:
GestureDetector