关键词:自定义View,圆形进度条,新手教学向
开篇想先问下,Android 这个行业还有新人进来学习吗∠( ᐛ 」∠)_
以图为例,看到下图你第一个想到的实现方案是什么?
是不是想写三个 ProgressBar?
然后产品经理说,这个地方需要有两点需要注意
- 进度在 >100% 和 <100% 的时候效果不同
- 根据用户角色不同,这地方有时候存在展示2~4个进度环的可能
这时候还考虑叠 ProgressBar 吗?
仔细分析一下,这个控件其实并不复杂,会画弧线,会画圈就行。
// 圆环
canvas.drawCircle(...)
// 画弧线
canvas.drawArc(...)
那么,再把这个控件再拆分一下,由Drawable绘制圆形进度条,View负责控制Drawable数量和颜色,这样就OK了。
设计完成,开始实现
进度条Drawable
可以直接看 draw 方法
/**
* 构造方法传入背景颜色、进度条颜色、背景环宽度、进度条宽度
*/
class CircleProgressDrawable(
val bgColor: Int = Color.parseColor("#1F000000"),
val pColor: Int = Color.RED,
val bgWidth: Float = 8F,
val pWidth: Float = 8F
) : Drawable() {
/**
* 默认值和常用值,最好用常量保存
* 就算没有用常量保存,默认值也最好写在变量初始化位置
*/
companion object {
/**
* 默认圆环阴影绘制长度,占整体圆环的百分比
*/
private const val DEFAULT_SHADOW_DRAW_PERCENT = 0.08F
/**
* 阴影默认启示渐变色
*/
private const val DEFAULT_SHADOW_START_COLOR: String = "#70000000"
}
/**
* 当前进度
*/
var progress: Int = 0
/**
* 总进度
*/
var total: Int = 0
/**
* 阴影绘制角度
*/
@FloatRange(from = 0.0, to = 1.0)
var shadowPercent: Float = DEFAULT_SHADOW_DRAW_PERCENT
private var width = 0
private var height = 0
private var centerX = 0
private var centerY = 0
/**
* 圆环半径
*/
private var radius = 0F
/**
* 绘制角度
*/
private var drawPercent = 0F
set(value) {
field = value
// 重新设置绘制角度时,阴影渐变色也要更改
mShadowShader = SweepGradient(
0F,
0F,
intArrayOf(Color.parseColor(DEFAULT_SHADOW_START_COLOR), Color.TRANSPARENT),
floatArrayOf(
0F,
shadowPercent
)
)
}
/**
* 背景绘制 Paint
*/
private val mBgPaint: Paint = Paint().apply {
color = bgColor
style = Paint.Style.STROKE
strokeWidth = bgWidth
}
/**
* 进度绘制 Paint
*/
private val mPaint: Paint = Paint().apply {
color = pColor
style = Paint.Style.STROKE
strokeWidth = pWidth
strokeCap = Paint.Cap.ROUND
}
/**
* overSize 场景下,阴影绘制
*/
private val mShadowPaint: Paint = Paint().apply {
strokeWidth = pWidth
style = Paint.Style.STROKE
strokeCap = Paint.Cap.SQUARE
}
/**
* overSize 场景下,阴影的渐变色
*
* 如果设置阴影角度,渐变色会自动调整
*/
var mShadowShader = SweepGradient(
0F,
0F,
intArrayOf(Color.parseColor(DEFAULT_SHADOW_START_COLOR), Color.TRANSPARENT),
floatArrayOf(
0F,
shadowPercent
)
)
/**
* draw 是一个高频调用方法,尤其是动画过程中,
* 所以尽量保证 draw 里头不要去初始化对象,
* 这样能有效防止内存抖动
*/
override fun draw(canvas: Canvas) {
width = bounds.width()
height = bounds.height()
// 中心点坐标
centerX = (bounds.left + bounds.right) / 2
centerY = (bounds.top + bounds.bottom) / 2
// 半径
radius = bounds.width() / 2 - bgWidth.coerceAtLeast(pWidth) / 2
// 将坐标系平移到中心点,然后将0度位置朝上,
// 不然坐标系在左上角,0度位置是右方,这样写代码会更难
canvas.save()
canvas.translate(centerX.toFloat(), centerY.toFloat())
canvas.rotate(-90F)
// 绘制背景圆环,固定绘制在最底下
canvas.drawCircle(0F, 0F, radius, mBgPaint)
// 计算绘制角度
drawPercent = (progress % total.coerceAtLeast(1)).toFloat() / total.coerceAtLeast(1)
// 绘制进度条,有0%,0~100%,100%,>100%四种样式,要分开处理
if (progress == 0) {
// 没有进度绘制点
canvas.drawPoint(radius, 0F, mPaint)
} else if (progress in 0 until total) {
// 普通进度正常绘制弧线
canvas.drawArc(
-radius,
-radius,
+radius,
+radius,
0F,
360F * drawPercent,
false,
mPaint
)
} else if (progress == total) {
// 满进度直接画圈
canvas.drawCircle(0F, 0F, radius, mPaint)
} else if (progress > total) {
// 进度大于100% 先画圈,然后绘制阴影,最后绘制一段进度条压在阴影上
canvas.drawCircle(0F, 0F, radius, mPaint)
canvas.save()
// 旋转,是因为 SweepShader 的渐变是从 0度角度开始
canvas.rotate(360F * drawPercent)
canvas.drawArc(
-radius,
-radius,
+radius,
+radius,
0F,
360F * shadowPercent,
false,
mShadowPaint.apply {
shader = mShadowShader
}
)
canvas.restore()
// 绘制一段进度条压在阴影上
canvas.drawArc(
-radius,
-radius,
+radius,
+radius,
0F,
360F * drawPercent,
false,
mPaint
)
}
// canvas 的 save 和 restore 必须配对,这点不能忘
canvas.restore()
}
override fun setAlpha(alpha: Int) {
mPaint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
mPaint.colorFilter = colorFilter
}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
这样,一个单独的进度条 drawable 就完成了,支持自定义背景宽度颜色、进度条宽度颜色。
多进度环形进度条 MultiCircleProgressView
Drawable 已经处理了进度条的绘制,那么 View 就只需要控制 Drawable 数量和间距了
p.s. 这里基类不应该用 FrameLayout 的,最初设计是环形进度条 View,而不是 Drawable导致的问题。
class MultiCircleProgressView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
/**
* 进度条间距
* 默认 2dp
*/
var dividerWidth: Float = dp2px(2F)
/**
* 进度条默认宽度
* 默认 8dp
*/
var progressWidth: Float = dp2px(8F)
/**
* 进度条默认背景宽度
* 默认 8dp
*/
var progressBgWidth: Float = dp2px(8F)
/**
* 进度条默认颜色
*/
var progressColor: Int = Color.parseColor("#E03810")
/**
* 进度条默认背景颜色
*/
var progressBgColor: Int = Color.parseColor("#16E03810")
/**
* 保存要绘制的进度条信息
*/
private val progressList = mutableListOf<CircleProgressDrawable>()
/**
* 添加进度条
*
* @param progress 当前进度,默认 0
* @param total 总进度,默认 100
* @param color 进度条颜色,不传取默认颜色
* @param width 进度条宽度,不传取默认宽度
* @param backgroundColor 进度条背景颜色,不传取默认颜色
* @param backgroundWidth 进度条背景宽度,不传取默认宽度
*/
fun addProgress(
progress: Int = 0,
total: Int = 100,
color: Int = progressColor,
width: Float = progressWidth,
backgroundColor: Int = progressBgColor,
backgroundWidth: Float = progressBgWidth,
) {
progressList.add(CircleProgressDrawable(
bgColor = backgroundColor,
bgWidth = backgroundWidth,
pColor = color,
pWidth = width
).apply {
this.progress = progress
this.total = total
})
}
/**
* 清空进度
*/
fun clearProgress() {
progressList.clear()
}
/**
* 刷新进度条
*/
fun refresh() {
invalidate()
}
init {
// viewGroup不加这句话,就不会走onDraw
setWillNotDraw(false)
}
/**
* 收缩距离,onDraw()时,每绘制一条进度条,
* scaleSize增加进度条宽度和间距宽度,
* 用来确认下一个进度条绘制的大小
*/
var scaleSize: Int = 0
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
scaleSize = 0
progressList.forEach {
// 设置 Drawable 边距
it.setBounds(scaleSize, scaleSize, width - scaleSize, height - scaleSize)
canvas?.let { canvas ->
it.draw(canvas)
}
// 计算下一个进度条位置
// it.bgWidth.coerceAtLeast(it.pWidth) 背景宽度和进度条宽度取最大值
scaleSize += (it.bgWidth.coerceAtLeast(it.pWidth) + dividerWidth).toInt()
}
}
private fun dp2px(dp: Float): Float {
return (context.resources.displayMetrics.density * dp + 0.5f)
}
}
这样,一个成熟的业务能用的控件就OK了,最后放一下页面演示代码
package com.elee.uidemo
import android.graphics.Color
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
private val mViewMcp : MultiCircleProgressView by lazy { findViewById(R.id.view_mcp) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// mViewMcp.addProgress(progress = 6, total = 12)
// mViewMcp.addProgress(progress = 9, total = 12, color = Color.GREEN, backgroundColor = Color.parseColor("#1600FF00"))
mViewMcp.addProgress(progress = 3, total = 12, color = Color.parseColor("#4077E6"), backgroundColor = Color.parseColor("#160000FF"))
mViewMcp.addProgress(progress = 15, total = 12, color = Color.parseColor("#64D4C7"), backgroundColor = Color.parseColor("#160000FF"))
mViewMcp.addProgress(progress = 18, total = 12, color = Color.parseColor("#F7963B"), backgroundColor = Color.parseColor("#160000FF"))
// mViewMcp.addProgress(progress = 15, total = 12, color = Color.BLUE, backgroundColor = Color.parseColor("#160000FF"))
// mViewMcp.addProgress(progress = 0, total = 12)
}
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F8F9FA"
tools:context=".MainActivity">
<com.elee.uidemo.MultiCircleProgressView
android:id="@+id/view_mcp"
android:layout_width="100dp"
android:layout_height="100dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
最后,来一道课后习题吧,弧形多进度进度条,一个弧形的背景上要展示多个不同进度~