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

前言

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

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

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

在这里插入图片描述


1. 功能介绍

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

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

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

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

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

2. 绘制逻辑

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

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

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

3. 关键技术点解析

3.1 初始化默认滑块位置

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

    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 点坐标

    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 点坐标

    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)的坐标

    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 拿到余弦值后,通过反余弦函数得到角度值
            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度,开始到结束即可,所以就要限制一下,某些角度触摸点的事件处理:

                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 限制圆内点击事件

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

    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 类的一个静态方法,用于计算一个数的指定次幂。其完整方法签名为:

public static double pow(double a, double b)

参数说明

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

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

3.6 Canvas缓存
            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. 自定义属性

 <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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值