该控件实现参考:https://www.gcssloop.com/tools/arc-seekbar
先上一张效果图歪楼:
渣图一张.gif
接下来就是一步步实现啦,完整源码放在github(https://github.com/gindoc/ArcSeekBar)上了,关键实现步骤如下:
1、首先得知道画弧,有两种方法:
a、直接通过canvas画弧:
canvas.drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter, @NonNull Paint paint)
b、间接通过path画弧
path.addArc(RectF oval, float startAngle, float sweepAngle)
canvas.drawPath(path, paint)
这里采用path画弧,因为后面方便画弧线的border。这里要注意一下,我们提供的rectF需要调整一下,因为paint的线宽问题会导致弧线显示不全,如下:
不调整的代码
arcRectF.set(0, 0, width, height)
结果如下图
弧线被吃了.png调整的代码
val fix = arcPaint.strokeWidth/2
arcRectF.set(fix, fix, width-fix, height-fix)
这样就正常啦.png
2、得知道怎么画边框吧
关键代码就是paint.getFillPath(Path src, Path dst)
其中src是我们圆弧的path, dst是用来存放边框的path的,拿到dst后,我们就可以画弧线的边框啦:
arcPaint.getFillPath(arcPath, borderPath)
borderPath.close()
canvas?.drawPath(borderPath, borderPaint)
效果如下:
卧槽,边框也被吃了.png
原因是borderPaint的线宽导致的,所以我们还需要调整arcRectF,调整如下:
val fix = arcPaint.strokeWidth/2 + borderPaint.strokeWidth/2
arcRectF.set(fix, fix, width-fix, height-fix)
... // 其他照旧
效果就是这样的:
又正常啦.png
3、接着就是填充色
首先,需要给arcPaint设置SweepGradient,因为弧线的颜色是渐变的。关键代码如下:
val stopPos = sweepAngle / 360 // 弧线滑过的角度在360度中的位置[0,1]
val len = mArcColors.size - 1
val distance = stopPos / len
val pos = FloatArray(mArcColors.size)
for (i in mArcColors.indices) {
pos[i] = distance * I // 每个颜色的位置
}
val gradient = SweepGradient(mCenterX, mCenterY, mArcColors, pos)
arcPaint.shader = gradient
画出来的结果如下:
image.png
WTF!!!什么鬼
WTF.png
这是因为我们的角度是从0度开始的(也就是上图中黄蓝交界那里),所以这里我们要将SweepGradient的起始位置旋转到弧线开始的地方,调整后的代码如下:
val stopPos = sweepAngle / 360
val len = mArcColors.size - 1
val distance = stopPos / len
val pos = FloatArray(mArcColors.size)
for (i in mArcColors.indices) {
pos[i] = distance * I
}
val gradient = SweepGradient(mCenterX, mCenterY, mArcColors, pos)
val matrix = Matrix()
matrix.setRotate(startAngle, mCenterX, mCenterY) // 顺时针旋转到弧线开始的位置
gradient.setLocalMatrix(matrix)
arcPaint.shader = gradient
运行结果如下:
好像哪里怪怪的.png
等等,好像哪里怪怪的,为神马蓝色屁股后面有一小截黄色???
原因是线宽造成的,SweepGradient的颜色分布是0~360,而上图蓝黄交界处是360,那一小截黄色是小于360度的位置,所以显示了黄色。
怎么解决呢?将SweepGradient的起始位置逆时针旋转一定的角度。
旋转多少,可以旋转弧线长 arcPaint.strokeWidth/2 所占用的角度。
代码入下:
val stopPos = sweepAngle / 360
val len = mArcColors.size - 1
val distance = stopPos / len
val pos = FloatArray(mArcColors.size)
for (i in mArcColors.indices) {
pos[i] = distance * I
}
val gradient = SweepGradient(mCenterX, mCenterY, mArcColors, pos)
val matrix = Matrix()
matrix.setRotate(startAngle - getStrokeDegree(), mCenterX, mCenterY) // 顺时针旋转到弧线开始的位置
gradient.setLocalMatrix(matrix)
arcPaint.shader = gradient
/**
* 获取线宽占用的角度,因为画弧时线宽会导致突出一小部分
*/
private fun getStrokeDegree(): Float {
val path = Path()
path.addArc(arcRectF, 0f, 360f)
val tan = FloatArray(2)
val pos = FloatArray(2)
val pathMeasure = PathMeasure(path, false)
pathMeasure.getPosTan(arcPaint.strokeWidth / 2, pos, tan) // 可以考虑arcPaint.strokeWidth
val degree = Math.atan2((tan[1]).toDouble(), (tan[0]).toDouble()) * 180.0 / Math.PI
return Math.abs(degree - 90).toFloat()
}
后来补的一张图.png
其实上图你仔细看会发现,蓝色下端还是有一丁点的黄色。考虑到是弧线,我们其实可以逆时针旋转弧线长为arcPaint.strokeWidth所占用的角度。
4、滑块绘制
// 计算滑块位置
private fun computeThumbPos() {
val distance = pathMeasure.length * mProgressPresent
pathMeasure.getPosTan(distance, thumbPos, null) // thumbPos是个数组,用于存储坐标
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
var fix = arcPaint.strokeWidth/2 + borderPaint.strokeWidth/2
fix = Math.max(fix, thumbPaint.strokeWidth / 2) + thumbShadowRadius // arcRectF的范围也要再调整下
arcRectF.set(fix, fix, width-fix, height-fix)
... // 其他照旧
computeThumbPos()
...
}
override fun onDraw(canvas: Canvas?) {
...
thumbPaint.color = getColor()
thumbPaint.setShadowLayer(thumbShadowRadius, 0f, 0f, thumbShadowColor) // 设置滑块阴影
canvas?.drawPoint(thumbPos[0], thumbPos[1], thumbPaint) // 画滑块
thumbPaint.clearShadowLayer()
}
// 以下获取颜色代码摘自https://www.gcssloop.com/tools/arc-seekbar
/**
* 获取当前进度的具体颜色
*
* @return 当前进度在渐变中的颜色
*/
fun getColor(): Int {
return getColor(mProgressPresent)
}
/**
* 获取某个百分比位置的颜色
*
* @param radio 取值[0,1]
* @return 最终颜色
*/
private fun getColor(radio: Float): Int {
val diatance = 1.0f / (mArcColors.size - 1)
val startColor: Int
val endColor: Int
if (radio >= 1) {
return mArcColors[mArcColors.size - 1]
}
for (i in mArcColors.indices) {
if (radio <= i * diatance) {
if (i == 0) {
return mArcColors[0]
}
startColor = mArcColors[i - 1]
endColor = mArcColors[I]
val areaRadio = getAreaRadio(radio, diatance * (i - 1), diatance * i)
return getColorFrom(startColor, endColor, areaRadio)
}
}
return -1
}
/**
* 计算当前比例在子区间的比例
*
* @param radio 总比例
* @param startPosition 子区间开始位置
* @param endPosition 子区间结束位置
* @return 自区间比例[0, 1]
*/
private fun getAreaRadio(radio: Float, startPosition: Float, endPosition: Float): Float {
return (radio - startPosition) / (endPosition - startPosition)
}
/**
* 取两个颜色间的渐变区间 中的某一点的颜色
*
* @param startColor 开始的颜色
* @param endColor 结束的颜色
* @param radio 比例 [0, 1]
* @return 选中点的颜色
*/
private fun getColorFrom(startColor: Int, endColor: Int, radio: Float): Int {
val redStart = Color.red(startColor)
val blueStart = Color.blue(startColor)
val greenStart = Color.green(startColor)
val redEnd = Color.red(endColor)
val blueEnd = Color.blue(endColor)
val greenEnd = Color.green(endColor)
val red = (redStart + ((redEnd - redStart) * radio + 0.5)).toInt()
val greed = (greenStart + ((greenEnd - greenStart) * radio + 0.5)).toInt()
val blue = (blueStart + ((blueEnd - blueStart) * radio + 0.5)).toInt()
return Color.argb(255, red, greed, blue)
}
效果如下:
image.png
注:如果滑块阴影没出现,要关闭硬件加速
setLayerType(View.LAYER_TYPE_SOFTWARE, null) //对单独的View在运行时阶段禁用硬件加速
5、最后就是手势交互
首先是点击,采用GestureDetector
处理点击事件:
mDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent?): Boolean {
e?.let {
// 判断是否点击在了进度区域
if (!isInArcProgress(it.x, it.y)) return false
// 点击允许突变
mProgressPresent = getProgress(it.x, it.y)
computeThumbPos()
} ?: return false
return true
}
})
// 以下代码参考https://www.gcssloop.com/tools/arc-seekbar
// 判断该点是否在进度条上面
fun isInArcProgress(px: Float, py: Float): Boolean {
val pos = floatArrayOf(px, py)
return mArcRegion.contains(pos[0].toInt(), pos[1].toInt())
}
// 获取滑动进度
private fun getProgress(px: Float, py: Float): Float {
val diffAngle = getDiffAngle(px, py)
var progress = diffAngle / sweepAngle
log { "real progress: $progress diffAngle:$diffAngle" }
if (progress < 0) {
progress = 0f
} else if (progress > 1) {
progress = 1f
}
return progress
}
private fun getDiffAngle(px: Float, py: Float): Float {
val touchAngle = getAngle(px, py)
val diffAngle = touchAngle - startAngle
log { "progress touchAngle:$touchAngle diffAngle:$diffAngle " }
log { "progress diffAngleForStart:${Math.abs(diffAngle)} diffAngleForEnd:${Math.abs((diffAngle + 360) % 360 - sweepAngle)}" }
// 判断当前 触摸点 离 起点 的角度近,还是离 终点 的角度近
if (Math.abs(diffAngle) < Math.abs((diffAngle + 360) % 360 - sweepAngle)) { // 离起点近
return diffAngle
} else { // 离终点近
return (diffAngle + 360) % 360
}
}
// 计算指定位置与内容区域中心点的夹角
private fun getAngle(px: Float, py: Float): Float {
var angle = (Math.atan2((py - mCenterY).toDouble(), (px - mCenterX).toDouble()) * 180 / Math.PI).toFloat()
if (angle < 0) {
angle += 360f
}
return angle
}
然后是滑动:
override fun onTouchEvent(event: MotionEvent?): Boolean {
event?.let {
when (it.action) {
MotionEvent.ACTION_DOWN -> judgeCanDrag(it) // 点击时,判断是否可以拖动滑块
MotionEvent.ACTION_MOVE -> {
if (!canDrag) return true
val tmpProgress = getProgress(it.x, it.y)
log { "progress: $tmpProgress" }
log { "progress " }
// 处理突变,当进度突变大于0.5时,不处理进度(出现场景: 从进度0 在弧线外滑动到 进度1)
if (Math.abs(tmpProgress - mProgressPresent) > 0.5f) return true
mProgressPresent = tmpProgress
computeThumbPos()
}
}
}
mDetector.onTouchEvent(event)
invalidate()
return true
}
// 是否可以拖动滑块
private fun judgeCanDrag(event: MotionEvent) {
val x = event.x - thumbPos[0]
val y = event.y - thumbPos[1]
val distance = Math.sqrt((x * x + y * y).toDouble())
canDrag = distance <= thumbPaint.strokeWidth / 2 * 1.5
}
完整代码见https://github.com/gindoc/ArcSeekBar
最后还是要感谢博客https://www.gcssloop.com/tools/arc-seekbar提供的思路和帮助。
网友评论