重学 Android 自定义 View 系列(十二):环形SeekBar剖析

前言

一个自定义的圆形 SeekBar,类似于传统的 SeekBar 但采用了圆形轨迹。最近被一个网友私信问有没有类似效果的View,因为前面做过几个环形进度条,这个不就加个触摸效果么,以为不算很难,但深入了解后,才发现事情并没有那么简单...

你需要具备的知识:三角函数正弦余弦计算、反三角函数、角度弧度区别..

该View 由 CircleSeekbar 优化而来,深入进行剖析其原理,效果图如下:


1. 功能介绍

  • 绘制圆形轨道(进度条):支持背景轨道和进度轨道两层绘制和背景圆环缓存。

  • 支持触摸交互:用户可以通过手指拖动控制进度。

  • 自定义进度范围:允许设置最小值、最大值以及当前进度。

  • 进度变化监听:提供回调接口,实时反馈进度变化。

  • 状态保存与恢复:支持 onSaveInstanceState() 和 onRestoreInstanceState() 以应对屏幕旋转等情况。

2. 绘制逻辑

  • 绘制背景轨道:使用 canvas.drawArc() 绘制一个完整的灰色圆弧。

  • 绘制进度条:基于当前进度计算角度,并在同一圆弧路径上绘制一个有颜色的进度部分。

  • 绘制指示点(滑块):计算当前进度对应的角度,并在圆弧末端绘制一个小圆点作为拖动滑块。

3. 关键技术点解析

3.1 初始化默认滑块位置

如果我们在xml或代码中设置了默认值,就需要给滑块和进度进行初始化,首先计算出默认进度所对应夹角的余弦值:

java 复制代码
    private void refreshPosition() {
        //计算当前角度
        mCurAngle = (double) mCurProcess / mMaxProcess * 360.0;
        //Math.toRadians(mCurAngle) 将角度 mCurAngle 从度数转换为弧度。因为在 Java 的 Math 库中,三角函数(如 cos 方法)使用的是弧度制
        double cos = -Math.cos(Math.toRadians(mCurAngle));
        refreshWheelCurPosition(cos);
    }

通过余弦值计算 X 点坐标

java 复制代码
    private float calcXLocationInWheel(double angle, double cos) {
        // Math.sqrt(1 - cos * cos) 根据三角函数关系,对于给定角度的余弦值 cos,通过 sin²α + cos²α = 1,
        // 这里计算出正弦值(sin = Math.sqrt(1 - cos * cos))
        if (angle < 180) {
            return (float) (getMeasuredWidth() / 2 + Math.sqrt(1 - cos * cos) * mUnreachedRadius);
        } else {
            return (float) (getMeasuredWidth() / 2 - Math.sqrt(1 - cos * cos) * mUnreachedRadius);
        }
    }

由三角函数公式sin²α + cos²α = 1 可知,Math.sqrt(1 - cos * cos) 就是开 1 - cos²α 的平方根,计算得到当前角度的正弦值,因为 angle < 180代表右半圆,大于圆心 X 坐标,使用➕,得到滑块的 X 坐标点。

通过余弦值计算 Y 点坐标

java 复制代码
    private float calcYLocationInWheel(double cos) {
        return getMeasuredWidth() / 2 + mUnreachedRadius * (float) cos;
    }

因为cos 传进来的本来就是负值,所以这里直接用cos 计算,补充一下:(0度 至 90度)cos 是正值,(90度 至 180度)cos 是负值,(180度 至 270度)cos 是负值,(270度 至 360度)cos 是正值。

来,让我们梦回高中:

3.2 通过触摸点拿到倾斜角的余弦值

onTouchEvent 方法中,可以得到当前触摸点(X,Y)的坐标

java 复制代码
    private float computeCos(float x, float y) {
        float width = x - getWidth() / 2;
        float height = y - getHeight() / 2;
        float slope = (float) Math.sqrt(width * width + height * height);
        return height / slope;
    }

由于起始点是从圆的顶点开始的,所以要计算的是顶点与slope夹角的余弦值,这一点不同于前面文章环形进度条的计算方式!

3.3 拿到余弦值后,通过反余弦函数得到角度值

java 复制代码
            private static final double RADIAN = 180 / Math.PI;//弧度
            //....
            double angle;
            if (x < getWidth() / 2) { // 滑动超过180度  触摸点在左半边(180°~360°)
                angle = Math.PI * RADIAN + Math.acos(cos) * RADIAN;
            } else { // 没有超过180度 触摸点在右半边(0°~180°)
                angle = Math.PI * RADIAN - Math.acos(cos) * RADIAN;
            }

在单位圆中,任意点的坐标(x,y)可以表示为(cosθ, sinθ),已知点的坐标,可以通过反余弦函数(arccos)求出角度,反余弦函数返回的是弧度值 ,范围是[0, π](0°~180°),它无法直接区分左半圆和右半圆,可知,上面计算可简化为:180 + θ180 - θ

这样就拿到了触摸点相对于顶点的顺时针旋转角度值!

3.4 防止滑过一圈后还能滑动

正常情况下,我们希望从0度 滑到 360度,开始到结束即可,所以就要限制一下,某些角度触摸点的事件处理:

java 复制代码
                if (mCurAngle > 270 && angle < 90) {
                    mCurAngle = 360;
                    cos = -1;// 防止跳跃
                } else if (mCurAngle < 90 && angle > 270) {
                    mCurAngle = 0;
                    cos = -1;
                } else {
                    mCurAngle = angle;
                }

看代码好像不容易理解,来看两张图吧:

强制设置角度为360


当当前滑块位置相对于角度是 `mCurAngle > 270`,即上图粉色区域时,这时如果点击红色区域即 `angle < 90`,就强制设置为360度,就是满进度,防止出现不正常现象。

强制设置角度为0

同上所述,只是反过来了而已。

3.5 限制圆内点击事件

只有当点击圆环及内部才能触发刷新事件

java 复制代码
    private boolean isTouch(float x, float y) {
        double radius = (getWidth() - getPaddingLeft() - getPaddingRight() + getCircleWidth()) / 2;
        double centerX = getWidth() / 2;
        double centerY = getHeight() / 2;
        return Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2) < radius * radius;
    }

Math.pow 是Java中 Math 类的一个静态方法,用于计算一个数的指定次幂。其完整方法签名为:

java 复制代码
public static double pow(double a, double b)

参数说明

  • a:底数,即要进行乘方运算的基础数值。
  • b:指数,用于指定底数要相乘的次数。

直接比较 平方距离 和 半径平方, 三角型 两边平方之和大于第三边平方 就不在圆内,如下图所示:

3.6 Canvas缓存

java 复制代码
            if (mCacheCanvas == null) {
                buildCache(centerX, centerY, wheelRadius);
            }
            //...
    private void buildCache(float centerX, float centerY, float wheelRadius) {
          mCacheBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
          mCacheCanvas = new Canvas(mCacheBitmap);

          //画环
          mCacheCanvas.drawCircle(centerX, centerY, wheelRadius, mWheelPaint);
    }

在此View中,需要实时更新的一直是进度圆环和滑块位置,而背景圆环始终不变,所以可以将背景圆环的画布缓存下来,防止不必要的重绘,提升性能,具体表现在:

  • 减少重复计算:背景圆环的几何计算只需要一次
  • 避免重复绘制:静态内容只需绘制一次到 Bitmap,之后直接复用
  • 降低GPU负担:减少每帧需要上传到GPU的数据量

这种缓存方式 特别适合静态内容多、动态内容少的自定义 View。

4. 自定义属性

xml 复制代码
 <declare-styleable name="CircleSeekBar">
    <!-- 进度条的最大值 -->
    <attr name="wheel_max_process" format="integer" />

    <!-- 当前进度值 -->
    <attr name="wheel_cur_process" format="integer" />

    <!-- 已完成部分的进度条颜色 -->
    <attr name="wheel_reached_color" format="color" />

    <!-- 已完成部分的进度条宽度 -->
    <attr name="wheel_reached_width" format="dimension" />

    <!-- 是否为已完成进度条的两端添加圆角 -->
    <attr name="wheel_reached_has_corner_round" format="boolean"/>

    <!-- 未完成部分的进度条颜色 -->
    <attr name="wheel_unreached_color" format="color" />

    <!-- 未完成部分的进度条宽度 -->
    <attr name="wheel_unreached_width" format="dimension" />

    <!-- 指示滑块的颜色 -->
    <attr name="wheel_pointer_color" format="color" />

    <!-- 指示滑块的半径 -->
    <attr name="wheel_pointer_radius" format="dimension" />

    <!-- 是否为指示滑块添加阴影 -->
    <attr name="wheel_has_pointer_shadow" format="boolean" />

    <!-- 是否为进度条整体添加阴影 -->
    <attr name="wheel_has_wheel_shadow" format="boolean" />

    <!-- 指示滑块阴影的半径大小 -->
    <attr name="wheel_pointer_shadow_radius" format="dimension" />

    <!-- 进度条整体阴影的半径大小 -->
    <attr name="wheel_shadow_radius" format="dimension" />

    <!-- 是否开启缓存优化(减少重复绘制,提高性能) -->
    <attr name="wheel_has_cache" format="boolean" />

    <!-- 是否允许用户手动触摸滑动进度 -->
    <attr name="wheel_can_touch" format="boolean" />

    <!-- 是否限制只能在一个完整圆周内滑动进度 -->
    <attr name="wheel_scroll_only_one_circle" format="boolean" />
</declare-styleable>

5. 最后

恭喜你,上了一堂生动形象的数学课,哈哈哈

源码已上传Github: DiyView

相关推荐
练习本18 分钟前
Android系统架构模式分析
android·java·架构·系统架构
每次的天空5 小时前
Kotlin 内联函数深度解析:从源码到实践优化
android·开发语言·kotlin
练习本5 小时前
Android MVC架构的现代化改造:构建清晰单向数据流
android·架构·mvc
早上好啊! 树哥6 小时前
android studio开发:设置屏幕朝向为竖屏,强制应用的包体始终以竖屏(纵向)展示
android·ide·android studio
YY_pdd7 小时前
使用go开发安卓程序
android·golang
Android 小码峰啊8 小时前
Android Compose 框架物理动画之捕捉动画深入剖析(29)
android·spring
bubiyoushang8888 小时前
深入探索Laravel框架中的Blade模板引擎
android·android studio·laravel
cyy2988 小时前
android 记录应用内存
android·linux·运维
CYRUS STUDIO9 小时前
adb 实用命令汇总
android·adb·命令模式·工具
这儿有一堆花9 小时前
安卓应用卡顿、性能低下的背后原因
android·安卓