前言
一个自定义的圆形 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