主要需求
- 能进行倒计时
- 可以随时取消、终止倒计时
- 流畅跳动
先看实现效果
这是一个倒计时5s的动画,可能录制帧数导致没有完全显示,大于1秒时每秒显示,小于1秒时显示小数,带加速度先快后慢跳动
实现思路
- 继承
View
来实现我们的控件 - 重写
OnDraw
方法来绘制进度条 - 每秒跳动一次大进度,通过
Handle
定时触发,然后这一秒内,通过动画控制进度条流动 - 圆弧进度条主要由一个大圆+百分比圆弧构成,大圆使用描边,设置大的描边宽度实现圆环效果
- 然后用圆弧覆盖在上面实现进度效果
- 因为圆的圆心角是360° 所以把100%进度拆成360分,每1%绘制3.6°圆弧即可
实现绘制固定进度
class CounterDownView : View {
//region 绘图参数
private lateinit var circlePaint: Paint
private lateinit var textPaint: Paint
private var ringWidthDp = 12f
private var ringBackgroundColor = Color.GRAY
private var ringFillColor = Color.GREEN
//endregion
//region 定时参数
private var targetTime = 5000L
private var currentTime = 1000L
private val interval = 1000
private val handle: Handler by lazy { Handler(Looper.myLooper()!!) }
var progressListener: ProgressListener? = null
//endregion
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : super(
context,
attrs,
defStyleAttr
) {
ringWidthDp = dip2px(12f)
circlePaint = Paint().apply {
isAntiAlias = true //抗锯齿
isDither = true //防抖动
strokeWidth = ringWidthDp
shader = null
}
textPaint = Paint().apply {
isAntiAlias = true //抗锯齿
isDither = true //防抖动
isFakeBoldText = true
color = Color.parseColor("#00FF90")
textSize = dip2px(25f)
textAlign = Paint.Align.CENTER
strokeWidth = 0f
}
ringBackgroundColor = Color.WHITE
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val center = this.width / 2f
val radius = center - ringWidthDp / 2f - ringWidthDp
drawBackgroundCircle(canvas, center, radius)
drawProgressCircle(canvas, center, radius)
drawShowText(canvas, center)
}
/**
* 绘制背景圆环
*/
private fun drawBackgroundCircle(canvas: Canvas, center: Float, radius: Float) {
with(circlePaint) {
color = ringBackgroundColor
style = Paint.Style.STROKE
}
canvas.drawCircle(center, center, radius, circlePaint)
}
/**
* 绘制当前进度
*/
private fun drawProgressCircle(canvas: Canvas, center: Float, radius: Float) {
val rectStartX = center - radius
val rectEndX = center + radius
//圆弧的外接正方形,宽高相等
val oval = RectF(rectStartX, rectStartX, rectEndX, rectEndX)
circlePaint.color = ringFillColor
//计算绘图进度,转化成圆弧的圆心角
val ringAngle = 360f * currentTime / targetTime
//绘制弧
canvas.drawArc(oval, -90f, ringAngle, false, circlePaint)
}
private fun drawShowText(canvas: Canvas, center: Float) {
var showText = ""
val diff = (targetTime - currentTime) / 1000.0
showText = if (diff < 1) { //小于1时显示小数
String.format("%1.1f", diff)
} else {
diff.toInt().toString()
}
//获取文字边框
val textBound = Rect()
textPaint.getTextBounds(showText, 0, showText.length, textBound)
//计算文字基线高度,保证垂直居中
val fontMetrics = textPaint.fontMetricsInt
val baseLine = center + (fontMetrics.bottom - fontMetrics.top) / 2f - fontMetrics.bottom
canvas.drawText(showText, center, baseLine, textPaint)
}
/**
* 启动倒计时
*/
fun start() {
}
/**
* 暂停倒计时
*/
fun pause() {
}
/**
* 恢复倒计时
*/
fun resume() {
}
/**
* 停止倒计时
*/
fun stop() {
}
//倒计时跳动控制核心
private val counterRunnable = Runnable {
}
interface ProgressListener {
fun onTick(counterDownView: CounterDownView): Boolean
fun onFinish()
}
fun dip2px(dipValue: Float): Float {
val scale = Resources.getSystem().displayMetrics.density
return dipValue * scale + 0.5f
}
至此,我们已经能绘制出一个20%进度进度条了
补全倒计时动态设置跳动功能 完整代码
class CounterDownView : View {

//region 绘图参数
private lateinit var circlePaint: Paint
private lateinit var textPaint: Paint
private var ringWidthDp = 12f
private var ringBackgroundColor = Color.GRAY
private var ringFillColor = Color.GREEN
private var valueAnimation: ValueAnimator? = null
private var counterRunnable: Runnable? = null
//endregion
//region 定时参数
private var targetTime = 5000L
private var currentTime = 1000L
private val interval = 1000L
private val counterHandler: Handler by lazy { Handler(Looper.myLooper()!!) }
var progressListener: ProgressListener? = null
//endregion
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : super(
context,
attrs,
defStyleAttr
) {
ringWidthDp = dip2px(12f)
circlePaint = Paint().apply {
isAntiAlias = true //抗锯齿
isDither = true //防抖动
strokeWidth = ringWidthDp
shader = null
}
textPaint = Paint().apply {
isAntiAlias = true //抗锯齿
isDither = true //防抖动
isFakeBoldText = true
color = Color.parseColor("#00FF90")
textSize = dip2px(25f)
textAlign = Paint.Align.CENTER
strokeWidth = 0f
}
ringBackgroundColor = Color.WHITE
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val center = this.width / 2f
val radius = center - ringWidthDp / 2f - ringWidthDp
drawBackgroundCircle(canvas, center, radius)
drawProgressCircle(canvas, center, radius)
drawShowText(canvas, center)
}
/**
* 绘制背景圆环
*/
private fun drawBackgroundCircle(canvas: Canvas, center: Float, radius: Float) {
with(circlePaint) {
color = ringBackgroundColor
style = Paint.Style.STROKE
}
canvas.drawCircle(center, center, radius, circlePaint)
}
/**
* 绘制当前进度
*/
private fun drawProgressCircle(canvas: Canvas, center: Float, radius: Float) {
val rectStartX = center - radius
val rectEndX = center + radius
//圆弧的外接正方形,宽高相等
val oval = RectF(rectStartX, rectStartX, rectEndX, rectEndX)
circlePaint.color = ringFillColor
//计算绘图进度,转化成圆弧的圆心角
val ringAngle = 360f * currentTime / targetTime
//绘制弧
canvas.drawArc(oval, -90f, ringAngle, false, circlePaint)
}
private fun drawShowText(canvas: Canvas, center: Float) {
var showText = ""
val diff = (targetTime - currentTime) / 1000.0
showText = if (diff < 1) { //小于1时显示小数
String.format("%1.1f", diff)
} else {
diff.toInt().toString()
}
//获取文字边框
val textBound = Rect()
textPaint.getTextBounds(showText, 0, showText.length, textBound)
//计算文字基线高度,保证垂直居中
val fontMetrics = textPaint.fontMetricsInt
val baseLine = center + (fontMetrics.bottom - fontMetrics.top) / 2f - fontMetrics.bottom
canvas.drawText(showText, center, baseLine, textPaint)
}
/**
* 启动倒计时
*/
fun start(targetTime: Long = 5000L) {
stop()
//重置当前已走时间为0
currentTime = 0
this.targetTime = targetTime
//先重绘一次界面
invalidate()
resume()
}
/**
* 暂停倒计时
*/
fun pause() {
counterRunnable?.let {
counterHandler.removeCallbacks(it)
}
}
/**
* 恢复倒计时
*/
fun resume() {
counterRunnable?.let {
counterHandler.removeCallbacks(it)
}
//倒计时跳动控制核心
counterRunnable = object : Runnable {
override fun run() {
//到时间了,如果已有动画未完成,则先强制让他完成动画
valueAnimation?.end()
//回调给使用方,触发一次倒计时
if (progressListener?.onTick(this@CounterDownView) == true) {
//主动结束倒计时,并保留状态
return
}
//跳动一次时间为 interval ,从现在开始到下次跳秒前,我们使用值动画完成,让进度条平滑过渡
valueAnimation =
ValueAnimator.ofInt(
this@CounterDownView.currentTime.toInt(),
(this@CounterDownView.currentTime + interval).toInt()
)
valueAnimation?.let { ani ->
ani.addUpdateListener {
this@CounterDownView.currentTime = it.animatedValue.toString().toLong()
postInvalidate()
}
ani.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
if (this@CounterDownView.currentTime >= targetTime) { //只要超过目标时间就主动提示完成
progressListener?.onFinish()
this@CounterDownView.currentTime = 0L
}
}
})
//设置动画插值器
ani.interpolator = DecelerateInterpolator()
ani.duration = this@CounterDownView.interval //设置动画执行时间
ani.start()
}
if ((this@CounterDownView.currentTime + this@CounterDownView.interval) >= this@CounterDownView.targetTime) {
//结束倒计时
return
} else {
counterHandler.postDelayed(this, this@CounterDownView.interval)
}
}
}
// 为了让界面能显示总时间,延迟600ms再开始倒计时,也可以不延迟直接开始
counterRunnable?.let {
counterHandler.postDelayed(it, 600)
}
}
/**
* 停止倒计时
*/
fun stop() {
valueAnimation?.cancel()
pause()
valueAnimation = null
}
interface ProgressListener {
fun onTick(counterDownView: CounterDownView): Boolean
fun onFinish()
}
private fun dip2px(dipValue: Float): Float {
val scale = Resources.getSystem().displayMetrics.density
return dipValue * scale + 0.5f
}
}
关于动画插值器修改
上面例子使用的插值器是DecelerateInterpolator,我们还可以换成线性动画LinearInterpolator: