经常在一些app里面有一些滑动的圆环进度条,比如这样的
所以觉得自己动手搓一个,可控的圆环进度条。
先上图吧【博主在真机随意设置的一些】
先说明目前已经实现的功能模块
- 支持自定义进度,包括当前进度【progress】,最大进度【max】
- 支持自定义大小【modifier】
- 支持点触【enableClick】改变进度,滑动【enableDrag】改变进度开关
- 支持起点[开始角度]【startAngle】,这是起点设置的地方
- 支持末端[扫描角度]【sweepAngle】
- 支持底层正常圆弧宽度【normalStrokeWidth】
- 支持进度圆弧宽度[进度条]【progressStrokeWidth】
- 支持底层正常圆弧颜色[目前仅支持纯色]【normalColor】
- 支持进度圆弧颜色[支持纯色,渐变色]【progressColors】
- 支持滑动球总开关【showThumb】----这个开关是控制用户是现实绘制小球还是自定义图片小球【关闭默认不显示球,直接跟弧度一样】
- 支持在开启滑动总开关showThumb = true的时候,支持绘制圆球【showDefaultThumb】和支持用户自定义图片球【showThumbDrawable】,都开启状态下,并且设置thumbDrawable = true优先显示用户设置【可设置thumbRadius大小,thumbColor颜色】,如果同时设置showThumbDrawable = true,并且设置相关属性【thumbRadius大小,外部小球thumbColor颜色,showInnerDot是否显示拖动球的内部小球,innerDotColor内部小球颜色,innerDotRadius大小】等属性,还是优先显示用户设置自定义球
- 支持首端球显示控制【showStartCap】
- 支持末端球显示控制【showEndCap】
- 同时支持首端球和末端球自定义图片球还是绘画球体【支持自定义大小】,绘制球支持颜色控制
- 支持进度回调函数onProgressChanged
高度自定义,需要用户熟练掌握参数定义
以下是源码【支持原创,你想做成git开源库也行,备注博主链接就ok啦】
package com.fj.test.screen.circularseekbar
import android.content.res.Resources.getSystem
import android.view.MotionEvent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.input.pointer.RequestDisallowInterceptTouchEvent
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.fj.test.R
import com.hjq.toast.Toaster
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* ⭐⭐⭐ Jetpack Compose 版本的SeekBar ⭐⭐⭐
*
* 功能特性:
* - 圆弧进度条绘制
* - 拖拽按钮(Thumb)
* - 触摸交互(拖拽和点击)
* - 渐变色支持
* - 完全基于 Compose Canvas 实现
* 等等
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ComposeArcSeekBar(
progress: Int, // 当前进度
max: Int = 100, // 最大进度
modifier: Modifier = Modifier,
startAngle: Float = 125f, // 开始角度
sweepAngle: Float = 290f, // 弧线范围
normalStrokeWidth: Dp = 15.dp, // ⭐ 正常弧线宽度
progressStrokeWidth: Dp = 15.dp, // ⭐ 进度弧线宽度
normalColor: Color = Color(0xFFECF1F8), // ⭐ 外层颜色
progressColors: List<Color> = listOf(Color(0xFF95BAFF)), // ⭐ 渐变色
showThumb: Boolean = true, // ⭐ 总开关:显示任何形式的按钮
showDefaultThumb: Boolean = true, // ⭐ 新增:显示默认圆形按钮
showThumbDrawable: Boolean = true, // ⭐ 新增:显示自定义 Drawable 按钮
enableDrag: Boolean = false, // 允许拖拽
enableClick: Boolean = false, // 允许点击
thumbRadius: Dp = 12.dp, // ⭐ 默认按钮半径
thumbColor: Color = Color.White, // ⭐ 默认按钮颜色
thumbDrawable: Int? = null, // ⭐ 支持自定义 Drawable
thumbDrawableSize: Dp = 24.dp, // ⭐ 控制 Drawable 大小
// ⭐⭐⭐ 新增:内部小圆点控制 ⭐⭐⭐
showInnerDot: Boolean = true, // ⭐ 是否显示内部小圆点
innerDotColor: Color? = null, // ⭐ 内部小圆点颜色(null时使用进度色)
innerDotRadius: Dp? = null, // ⭐ 内部小圆点半径(null时使用默认比例)
// ⭐⭐⭐ 新增:首端球和末端球控制 ⭐⭐⭐
showStartCap: Boolean = false, // ⭐ 是否显示首端球
showEndCap: Boolean = false, // ⭐ 是否显示末端球
startCapRadius: Dp = 8.dp, // ⭐ 首端球半径
endCapRadius: Dp = 8.dp, // ⭐ 末端球半径
startCapColor: Color = Color.White, // ⭐ 首端球颜色
endCapColor: Color = Color.White, // ⭐ 末端球颜色
startCapDrawable: Int? = null, // ⭐ 首端球自定义图片
endCapDrawable: Int? = null, // ⭐ 末端球自定义图片
startCapDrawableSize: Dp = 16.dp, // ⭐ 首端球图片大小
endCapDrawableSize: Dp = 16.dp, // ⭐ 末端球图片大小
onProgressChanged: (progress: Int, fromUser: Boolean) -> Unit = { _, _ -> } // 监听进度变化
) {
// 当前进度状态 - 响应外部progress参数变化
var mProgress by remember(progress) { mutableStateOf(progress) }
var mProgressPercent by remember { mutableStateOf((progress * 100.0f / max).toInt()) }
// 获取 Context 用于加载 Drawable
val context = LocalContext.current
// 计算像素值
val dpScale = getSystem().displayMetrics.density
val mThumbRadius = dpScale * thumbRadius.value
val mAllowableOffsets = dpScale * 15f // 触摸时可偏移距离
val normalStrokeWidthInPx = dpScale * normalStrokeWidth.value // ⭐ 正常弧线宽度
val progressStrokeWidthInPx = dpScale * progressStrokeWidth.value // ⭐ 进度弧线宽度
val thumbDrawableSizeInPx = dpScale * thumbDrawableSize.value // ⭐ Drawable 大小
// ⭐⭐⭐ 内部小圆点像素值计算 ⭐⭐⭐
val mInnerDotRadius = if (innerDotRadius != null) {
val customRadius = dpScale * innerDotRadius.value
// 确保内部圆点不超过外部圆的大小
kotlin.math.min(customRadius, mThumbRadius)
} else {
// 默认为外部圆半径的 30%
mThumbRadius * 0.3f
}
val mInnerDotColor = innerDotColor ?: (progressColors.firstOrNull() ?: Color(0xFF95BAFF))
// ⭐⭐⭐ 首端球和末端球像素值计算 ⭐⭐⭐
val startCapRadiusInPx = dpScale * startCapRadius.value
val endCapRadiusInPx = dpScale * endCapRadius.value
val startCapDrawableSizeInPx = dpScale * startCapDrawableSize.value
val endCapDrawableSizeInPx = dpScale * endCapDrawableSize.value
// 拖拽状态
var isCanDrag by remember { mutableStateOf(false) }
var mThumbCenterX by remember { mutableStateOf(0f) }
var mThumbCenterY by remember { mutableStateOf(0f) }
var mCircleCenterX by remember { mutableStateOf(0f) }
var mCircleCenterY by remember { mutableStateOf(0f) }
var mRadius by remember { mutableStateOf(0f) }
val requestDisallowInterceptTouchEvent = remember {
RequestDisallowInterceptTouchEvent()
}
// 计算进度比例
fun getRatio(): Float = mProgress * 1.0f / max
// 获取触摸坐标的夹角度数
fun getTouchDegrees(x: Float, y: Float): Float {
val x1 = x - mCircleCenterX
val y1 = y - mCircleCenterY
var angle = (atan2(y1, x1) * 180 / PI).toFloat()
angle -= startAngle
while (angle < 0) {
angle += 360f
}
return angle
}
// 通过弧度换算得到当前进度
fun getProgressForAngle(angle: Float): Int {
return kotlin.math.round(1.0f * max / sweepAngle * angle).toInt()
}
// 计算两点距离
fun getDistance(x1: Float, y1: Float, x2: Float, y2: Float): Float {
return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2))
}
// 检测是否可拖拽
fun checkCanDrag(x: Float, y: Float) {
val distance = getDistance(mThumbCenterX, mThumbCenterY, x, y)
isCanDrag = distance <= mThumbRadius + mAllowableOffsets
}
// 更新拖拽进度
fun updateDragThumb(x: Float, y: Float, isSingle: Boolean) {
var newProgress = getProgressForAngle(getTouchDegrees(x, y))
if (!isSingle) {
val tempProgressPercent = (newProgress * 100.0f / max).toInt()
// 当滑动至边界值时,增加进度校准机制
if (mProgressPercent < 10 && tempProgressPercent > 90) {
newProgress = 0
} else if (mProgressPercent > 90 && tempProgressPercent < 10) {
newProgress = max
}
val progressPercent = (newProgress * 100.0f / max).toInt()
// 拖动进度突变不允许超过50%
if (kotlin.math.abs(progressPercent - mProgressPercent) > 50) {
return
}
}
// 设置进度
if (mProgress != newProgress) {
if (newProgress < 0) {
newProgress = 0
} else if (newProgress > max) {
newProgress = max
}
mProgress = newProgress
mProgressPercent = (mProgress * 100.0f / max).toInt()
onProgressChanged(mProgress, true)
}
}
// 判断坐标点是否在弧形上
fun isInArc(x: Float, y: Float): Boolean {
val distance = getDistance(mCircleCenterX, mCircleCenterY, x, y)
if (kotlin.math.abs(distance - mRadius) <= mThumbRadius + mAllowableOffsets) {
if (sweepAngle < 360) {
val angle = (getTouchDegrees(x, y) + startAngle) % 360
return if (startAngle + sweepAngle <= 360) {
angle >= startAngle && angle <= startAngle + sweepAngle
} else {
angle >= startAngle || angle <= (startAngle + sweepAngle) % 360
}
}
return true
}
return false
}
// 绘制
Canvas(
modifier = modifier
.pointerInteropFilter(requestDisallowInterceptTouchEvent) { motionEvent ->
if (enableDrag) {
// 处理拖拽事件
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
checkCanDrag(motionEvent.x, motionEvent.y)
if (isCanDrag) {
requestDisallowInterceptTouchEvent(true)
}
}
MotionEvent.ACTION_MOVE -> {
if (isCanDrag) {
updateDragThumb(motionEvent.x, motionEvent.y, false)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
requestDisallowInterceptTouchEvent(false)
isCanDrag = false
}
}
}
if (enableClick && motionEvent.action == MotionEvent.ACTION_DOWN && !isCanDrag) {
if (isInArc(motionEvent.x, motionEvent.y)) {
updateDragThumb(motionEvent.x, motionEvent.y, true)
}
}
enableDrag || enableClick
}
) {
mCircleCenterX = size.width / 2f
mCircleCenterY = size.height / 2f
val minDimension = kotlin.math.min(size.width, size.height)
val maxStrokeWidth = kotlin.math.max(normalStrokeWidth.toPx(), progressStrokeWidth.toPx())
mRadius = (minDimension / 2f) - maxStrokeWidth / 2f
val thumbAngle = startAngle + sweepAngle * getRatio()
mThumbCenterX =
(mCircleCenterX + mRadius * cos(Math.toRadians(thumbAngle.toDouble()))).toFloat()
mThumbCenterY =
(mCircleCenterY + mRadius * sin(Math.toRadians(thumbAngle.toDouble()))).toFloat()
val center = Offset(mCircleCenterX, mCircleCenterY)
drawArc(
color = normalColor,
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset(center.x - mRadius, center.y - mRadius),
size = androidx.compose.ui.geometry.Size(mRadius * 2, mRadius * 2),
style = Stroke(
width = normalStrokeWidth.toPx(),
cap = StrokeCap.Round
)
)
val ratio = getRatio()
if (ratio > 0f) {
// 1. 先绘制带渐变的进度弧线
withTransform({
rotate(degrees = startAngle, pivot = center)
}) {
val brush = if (progressColors.size > 1) {
Brush.sweepGradient(colors = progressColors, center = center)
} else {
SolidColor(progressColors.first())
}
drawArc(
brush = brush,
startAngle = 0f,
sweepAngle = sweepAngle * ratio,
useCenter = false,
topLeft = Offset(center.x - mRadius, center.y - mRadius),
size = androidx.compose.ui.geometry.Size(mRadius * 2, mRadius * 2),
style = Stroke(
width = progressStrokeWidth.toPx(),
cap = StrokeCap.Round
)
)
}
// ⭐⭐⭐ 核心修复:绘制起点圆点,修复渐变色断层bug ⭐⭐⭐
// 2. 在弧线起点处,画一个实心圆盖住断层【目前我找不到更有效办法】
if (progressStrokeWidth.toPx() > 0 && progressColors.isNotEmpty()) {
// 计算起点的中心坐标
val startCapCenterX =
(mCircleCenterX + mRadius * cos(Math.toRadians(startAngle.toDouble()))).toFloat()
val startCapCenterY =
(mCircleCenterY + mRadius * sin(Math.toRadians(startAngle.toDouble()))).toFloat()
// 使用渐变色的第一种颜色绘制圆
drawCircle(
color = progressColors.first(),
radius = progressStrokeWidth.toPx() / 2f,
center = Offset(startCapCenterX, startCapCenterY)
)
}
}
// ⭐⭐⭐ 绘制首端球和末端球 ⭐⭐⭐
// 绘制首端球(在弧线起始位置)
if (showStartCap) {
val startCapCenterX = (mCircleCenterX + mRadius * cos(Math.toRadians(startAngle.toDouble()))).toFloat()
val startCapCenterY = (mCircleCenterY + mRadius * sin(Math.toRadians(startAngle.toDouble()))).toFloat()
val startCapCenter = Offset(startCapCenterX, startCapCenterY)
if (startCapDrawable != null) {
// 使用自定义图片
val drawable = androidx.core.content.ContextCompat.getDrawable(context, startCapDrawable)
drawable?.let { d ->
val capSize = startCapDrawableSizeInPx.toInt()
val bitmap = d.toBitmap(capSize, capSize)
val imageBitmap = bitmap.asImageBitmap()
val halfSize = capSize / 2f
drawImage(
image = imageBitmap,
topLeft = Offset(
startCapCenterX - halfSize,
startCapCenterY - halfSize
)
)
}
} else {
// 使用默认圆形球
drawCircle(
color = startCapColor,
radius = startCapRadiusInPx,
center = startCapCenter
)
}
}
// 绘制末端球(在弧线结束位置)
if (showEndCap) {
val endAngle = startAngle + sweepAngle
val endCapCenterX = (mCircleCenterX + mRadius * cos(Math.toRadians(endAngle.toDouble()))).toFloat()
val endCapCenterY = (mCircleCenterY + mRadius * sin(Math.toRadians(endAngle.toDouble()))).toFloat()
val endCapCenter = Offset(endCapCenterX, endCapCenterY)
if (endCapDrawable != null) {
// 使用自定义图片
val drawable = androidx.core.content.ContextCompat.getDrawable(context, endCapDrawable)
drawable?.let { d ->
val capSize = endCapDrawableSizeInPx.toInt()
val bitmap = d.toBitmap(capSize, capSize)
val imageBitmap = bitmap.asImageBitmap()
val halfSize = capSize / 2f
drawImage(
image = imageBitmap,
topLeft = Offset(
endCapCenterX - halfSize,
endCapCenterY - halfSize
)
)
}
} else {
// 使用默认圆形球
drawCircle(
color = endCapColor,
radius = endCapRadiusInPx,
center = endCapCenter
)
}
}
// 绘制按钮
if (showThumb) {
val thumbCenter = Offset(mThumbCenterX, mThumbCenterY)
if (thumbDrawable != null && showThumbDrawable) {
val drawable =
androidx.core.content.ContextCompat.getDrawable(context, thumbDrawable)
drawable?.let { d ->
val customThumbSize = thumbDrawableSizeInPx.toInt()
val bitmap = d.toBitmap(customThumbSize, customThumbSize)
val imageBitmap = bitmap.asImageBitmap()
val halfSize = customThumbSize / 2f
drawImage(
image = imageBitmap,
topLeft = Offset(
mThumbCenterX - halfSize,
mThumbCenterY - halfSize
)
)
}
} else if (showDefaultThumb) {
if (isCanDrag) {
drawCircle(
color = thumbColor,
radius = mThumbRadius + 5f,
center = thumbCenter
)
} else {
drawCircle(
color = thumbColor,
radius = mThumbRadius,
center = thumbCenter
)
}
if (showInnerDot) {
drawCircle(
color = mInnerDotColor,
radius = mInnerDotRadius,
center = thumbCenter
)
}
}
}
}
}
// 预览演示
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultComposePreview() {
val context = LocalContext.current
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val size = screenWidth * 0.5f
var composeProgress by remember { mutableStateOf(10) } // ⭐ 默认进度为 10 【0-100】
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// ⭐ 示例1:显示自定义 Drawable
ComposeArcSeekBar(
progress = composeProgress,
max = 100,
modifier = Modifier.size(size),
startAngle = 125f,
sweepAngle = 290f,
normalStrokeWidth = 20.dp,
progressStrokeWidth = 10.dp,
normalColor = Color(0xFFECF1F8),
progressColors = listOf(Color(0xFF95BAFF)),
showThumb = true,
showDefaultThumb = false, // ⭐ 不显示默认圆形
showThumbDrawable = true, // ⭐ 显示自定义 Drawable
enableDrag = true,
enableClick = true,
thumbDrawable = R.drawable.ic_01,
thumbDrawableSize = 32.dp,
onProgressChanged = { progress, fromUser ->
composeProgress = progress
Toaster.show("Drawable Thumb: $progress")
}
)
var composeProgress3 by remember { mutableStateOf(80) } // ⭐ 默认进度为 10 【0-100】
// ⭐ 示例2:展示首端球和末端球功能
ComposeArcSeekBar(
progress = composeProgress3,
max = 100,
modifier = Modifier.size(size),
startAngle = 270f,
sweepAngle = 180f,
normalStrokeWidth = 10.dp,
progressStrokeWidth = 10.dp,
normalColor = Color(0xFFECF1F8),
progressColors = listOf(
Color(0xFFC8BDFF), // 起始:紫色
Color(0xFF95BAFF), // 中间:蓝色
Color(0xFFFF0000) // 结束:红色
),
// ⭐ 总显示
showThumb = true,
// ⭐ 自定义 Drawable
showThumbDrawable = false,
thumbDrawable = R.drawable.ic_01,
// 球大小
thumbRadius = 20.dp,
// ⭐ 显示默认圆形按钮
showDefaultThumb = true,
showInnerDot = true,
thumbColor = Color.Yellow,
// ⭐⭐⭐ 内部小圆点控制 ⭐⭐⭐
innerDotColor = Color.Red,
innerDotRadius = 10.dp,
// ⭐⭐⭐ 新增:首端球和末端球控制 ⭐⭐⭐
showStartCap = true, // ⭐ 显示首端球
showEndCap = true, // ⭐ 显示末端球
startCapRadius = 12.dp, // ⭐ 首端球半径
endCapRadius = 12.dp, // ⭐ 末端球半径
startCapColor = Color.Green, // ⭐ 首端球颜色(绿色)
endCapColor = Color.Blue, // ⭐ 末端球颜色(蓝色)
startCapDrawable = null, // ⭐ 使用默认圆形球
endCapDrawable = R.drawable.ic_01, // ⭐ 末端球使用自定义图片
endCapDrawableSize = 24.dp, // ⭐ 末端球图片大小
enableDrag = true,
enableClick = true,
onProgressChanged = { progress, fromUser ->
composeProgress3 = progress
Toaster.show("With Start/End Caps: $progress")
}
)
}
}
以上代码,博主给出Android Studio预览样式,贴图如下:
赠人玫瑰,手留余香,博主喜欢开源代码,但也请麻烦各位支持作者原创的产品,谢谢!