2. Android 自定义view 高级UI实战:打造丝滑3D循环滚轮时间选择器

这个视图实现了一个可以循环滚动的选择器(类似于3D滚轮效果)

比如日期选择器,自定义的一些选择器

1.效果图

2.功能需求

主要功能包括:

  1. 支持循环滚动(当滚动到边界时,可以循环)
  2. 3D效果:中间项最大,越往两边越小且透明度降低
  3. 支持快速滑动(fling)和自动对齐到最近项
  4. 支持触摸拖动

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/2visibleItems/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;
}
  1. 循环滚动机制
    • 虚拟索引系统 :使用 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

6.源码

项目的地址:github.com/pengcaihua1...

相关推荐
迷曳几秒前
28、鸿蒙Harmony Next开发:不依赖UI组件的全局气泡提示 (openPopup)和不依赖UI组件的全局菜单 (openMenu)、Toast
前端·ui·harmonyos·鸿蒙
爱分享的程序员13 分钟前
前端面试专栏-工程化:29.微前端架构设计与实践
前端·javascript·面试
上单带刀不带妹16 分钟前
Vue3递归组件详解:构建动态树形结构的终极方案
前端·javascript·vue.js·前端框架
-半.18 分钟前
Collection接口的详细介绍以及底层原理——包括数据结构红黑树、二叉树等,从0到彻底掌握Collection只需这篇文章
前端·html
90后的晨仔38 分钟前
📦 Vue CLI 项目结构超详细注释版解析
前端·vue.js
@大迁世界39 分钟前
用CSS轻松调整图片大小,避免拉伸和变形
前端·css
一颗不甘坠落的流星39 分钟前
【JS】获取元素宽高(例如div)
前端·javascript·react.js
白开水都有人用41 分钟前
VUE目录结构详解
前端·javascript·vue.js
if时光重来1 小时前
axios统一封装规范管理
前端·vue.js
m0dw1 小时前
js迭代器
开发语言·前端·javascript