重学 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

相关推荐
小蜜蜂嗡嗡1 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi001 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
zhangphil3 小时前
Android理解onTrimMemory中ComponentCallbacks2的内存警戒水位线值
android
你过来啊你3 小时前
Android View的绘制原理详解
android
移动开发者1号6 小时前
使用 Android App Bundle 极致压缩应用体积
android·kotlin
移动开发者1号6 小时前
构建高可用线上性能监控体系:从原理到实战
android·kotlin
ii_best11 小时前
按键精灵支持安卓14、15系统,兼容64位环境开发辅助工具
android
美狐美颜sdk11 小时前
跨平台直播美颜SDK集成实录:Android/iOS如何适配贴纸功能
android·人工智能·ios·架构·音视频·美颜sdk·第三方美颜sdk
恋猫de小郭15 小时前
Meta 宣布加入 Kotlin 基金会,将为 Kotlin 和 Android 生态提供全新支持
android·开发语言·ios·kotlin
aqi0016 小时前
FFmpeg开发笔记(七十七)Android的开源音视频剪辑框架RxFFmpeg
android·ffmpeg·音视频·流媒体