Android 技术分享|【自定义 View】实现 Material Design 的 Loading 效果
- 2022 年 3 月 24 日
本文字数:4172 字
阅读完需:约 14 分钟
预期效果
实现思路
分析一下这个动画,效果应该是通过两个动画来实现的。
一个不停变速伸缩的扇形动画
一个固定速度的旋转动画
扇形可以通过canvas#drawArc
来实现
旋转动画可以用setMatrix
实现
圆角背景可以通过canvas#drawRoundRect
实现
还需要一个计时器来实现动画效果
这个 View 最好能够更方便的修改样式,所以需要定义一个 declare-styleable,方便通过布局来修改属性。这些元素应该包括:
最底层的卡片颜色
卡片内变局
内部长条的颜色
长条的粗细
长条的距离中心的半径
字体大小
字体颜色
因为用到动画,避免掉帧,最好离屏绘制到缓冲帧上,再通知 view 绘制缓冲帧。
代码实现
定义一下 styleable
<declare-styleable name="MaterialLoadingProgress">
<attr name="loadingProgress_circleRadius" format="dimension" />
<attr name="loadingProgress_cardColor" format="color" />
<attr name="loadingProgress_cardPadding" format="dimension" />
<attr name="loadingProgress_strokeWidth" format="dimension" />
<attr name="loadingProgress_strokeColor" format="color" />
<attr name="loadingProgress_text" format="string" />
<attr name="loadingProgress_textSize" format="dimension" />
<attr name="loadingProgress_textColor" format="color" />
</declare-styleable>
在代码中解析 styleable
init {
val defCircleRadius = context.resources.getDimension(R.dimen.dp24)
val defCardColor = Color.WHITE
val defCardPadding = context.resources.getDimension(R.dimen.dp12)
val defStrokeWidth = context.resources.getDimension(R.dimen.dp5)
val defStrokeColor = ContextCompat.getColor(context, R.color.teal_200)
val defTextSize = context.resources.getDimension(R.dimen.sp14)
val defTextColor = Color.parseColor("#333333")
if (attrs != null) {
val attrSet = context.resources.obtainAttributes(attrs, R.styleable.MaterialLoadingProgress)
circleRadius = attrSet.getDimension(R.styleable.MaterialLoadingProgress_loadingProgress_circleRadius, defCircleRadius)
cardColor = attrSet.getColor(R.styleable.MaterialLoadingProgress_loadingProgress_cardColor, defCardColor)
cardPadding = attrSet.getDimension(R.styleable.MaterialLoadingProgress_loadingProgress_cardPadding, defCardPadding)
strokeWidth = attrSet.getDimension(R.styleable.MaterialLoadingProgress_loadingProgress_strokeWidth, defStrokeWidth)
strokeColor = attrSet.getColor(R.styleable.MaterialLoadingProgress_loadingProgress_strokeColor, defStrokeColor)
text = attrSet.getString(R.styleable.MaterialLoadingProgress_loadingProgress_text) ?: ""
textSize = attrSet.getDimension(R.styleable.MaterialLoadingProgress_loadingProgress_textSize, defTextSize)
textColor = attrSet.getColor(R.styleable.MaterialLoadingProgress_loadingProgress_textColor, defTextColor)
attrSet.recycle()
} else {
circleRadius = defCircleRadius
cardColor = defCardColor
cardPadding = defCardPadding
strokeWidth = defStrokeWidth
strokeColor = defStrokeColor
textSize = defTextSize
textColor = defTextColor
}
paint.textSize = textSize
if (text.isNotBlank())
textWidth = paint.measureText(text)
}
实现一个计时器,再定义一个数据类型来存储动画相关数据,还有一个动画插值器
Timer 定时器
private fun startTimerTask() {
val t = Timer()
t.schedule(object : TimerTask() {
override fun run() {
if (taskList.isEmpty())
return
val taskIterator = taskList.iterator()
while (taskIterator.hasNext()) {
val task = taskIterator.next()
task.progress += 17
if (task.progress > task.duration) {
task.progress = task.duration
}
if (task.progress == task.duration) {
if (!task.convert) {
task.startAngle -= 40
if (task.startAngle < 0)
task.startAngle += 360
}
task.progress = 0
task.convert = !task.convert
}
task.progressFloat = task.progress / task.duration.toFloat()
task.interpolatorProgress = interpolator(task.progress / task.duration.toFloat())
task.currentAngle = (320 * task.interpolatorProgress).toInt()
post { task.onProgress(task) }
}
}
}, 0, 16)
timer = t
}
定义一个数据模型
private data class AnimTask(
var startAngle: Int = 0,// 扇形绘制起点
val duration: Int = 700,// 动画时间
var progress: Int = 0,// 动画已执行时间
var interpolatorProgress: Float = 0f,// 插值器计算后的值,取值0.0f ~ 1.0f
var progressFloat: Float = 0f,// 取值0.0f ~ 1.0f
var convert: Boolean = false,// 判断扇形的绘制进程,为true时反向绘制
var currentAngle: Int = 0,// 绘制扇形使用
val onProgress: (AnimTask) -> Unit// 计算完当前帧数据后的回调
)
动画插值器
private fun interpolator(x: Float) = x * x * (3 - 2 * 2)
定义初始化缓冲帧
此方法在外部调用显示 loading 时调用即可,调用前需判断是否已经初始化
private fun initCanvas() {
bufferBitmap = Bitmap.createBitmap(measuredWidth, measuredHeight, Bitmap.Config.ARGB_8888)
bufferCanvas = Canvas(bufferBitmap)
}
实现扇形的绘制
private fun drawFrame(task: AnimTask) {
bufferBitmap.eraseColor(Color.TRANSPARENT)
val centerX = measuredWidth.shr(1)
val centerY = measuredHeight.shr(1)
rectF.set(
centerX - circleRadius, centerY - circleRadius,
centerX + circleRadius, centerY + circleRadius
)
paint.strokeWidth = strokeWidth
paint.color = strokeColor
paint.strokeCap = Paint.Cap.ROUND
paint.style = Paint.Style.STROKE
// 这里的判断,对应扇形逐渐延长、及逐渐缩短
if (task.convert) {
bufferCanvas.drawArc(
rectF, task.startAngle.toFloat(), -(320.0f - task.currentAngle.toFloat()), false, paint
)
} else {
bufferCanvas.drawArc(
rectF, task.startAngle.toFloat(), task.currentAngle.toFloat(), false, paint
)
}
invalidate()
}
实现扇形整体缓慢转圈
private fun drawRotation(task: AnimTask) {
val centerX = measuredWidth.shr(1)
val centerY = measuredHeight.shr(1)
bufferMatrix.reset()
bufferMatrix.postRotate(task.progressFloat * 360f, centerX.toFloat(), centerY.toFloat())
bufferCanvas.setMatrix(bufferMatrix)
}
一定要记得调用
matrix#reset
否则效果就会像这样 XD:
到这里,核心功能基本就完成了。
定义一个
showProgress
方法以及dismissProgress
方法,方便外部使用
展示
fun showProgress() {
if (showing)
return
if (!this::bufferBitmap.isInitialized) {
initCanvas()
}
taskList.add(AnimTask {
drawFrame(it)
})
taskList.add(AnimTask(duration = 5000) {
drawRotation(it)
})
startTimerTask()
showing = true
visibility = VISIBLE
}
关闭
fun dismissProgress() {
if (!showing)
return
purgeTimer()
showing = false
visibility = GONE
}
最后看一下View#onDraw
的实现:
override fun onDraw(canvas: Canvas) {
val centerX = measuredWidth.shr(1)
val centerY = measuredHeight.shr(1)
val rectHalfDimension = if (circleRadius > textWidth / 2f) circleRadius + cardPadding else textWidth / 2f + cardPadding
rectF.set(
centerX - rectHalfDimension,
centerY - rectHalfDimension,
centerX + rectHalfDimension,
if (text.isNotBlank()) centerY + paint.textSize + rectHalfDimension else centerY + rectHalfDimension
)
paint.color = cardColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(rectF, 12f, 12f, paint)
if (text.isNotBlank()) {
val dx = measuredWidth.shr(1) - textWidth / 2
paint.color = textColor
canvas.drawText(text, dx, rectF.bottom - paint.textSize, paint)
}
if (this::bufferBitmap.isInitialized)
canvas.drawBitmap(bufferBitmap, bufferMatrix, paint)
}
源代码请移步:ARCallPlus。
版权声明: 本文为 InfoQ 作者【anyRTC开发者】的原创文章。
原文链接:【http://xie.infoq.cn/article/09efeb8481aa1c221c3f644b9】。文章转载请联系作者。
anyRTC开发者
实时交互,万物互联! 2020.08.10 加入
实时交互,万物互联,全球实时互动云服务商领跑者!
评论