一、目标 & 最终效果
接到一个滚动数字效果的需求,如下图(gif需要一点时间加载):
忙活了一天,得到了下面的效果,还原度还算不错,不过还有优化空间,中间可以插入任意字符,只有数字会滚动。接下来说下解题思路
二、分析设计
1、逐帧分析
分析得到以下结论:
- 数字是连续变换的,比如1变为5,就会经过2、3、4,最终停留在5
- 每个数字动画是独立的,滚动有快有慢,方向也可以不同,当数字变化差距大时,动画的速度也会变化,即每一帧的即「步长」不一样
- 数字未变化完成也可以调转方向,也就是允许动画中改变目标数字
为了方便编写,我们先将静态的一帧展示出来,单列数字完整展开应该是一个 0,1,2,3,4,5,6,7,8,9 的垂直列表,将所有数字展开应该就是这样:
* 9
* 8
* 7 9
* 6 9 8 9 9
* 5 8 7 8 8
* 4 7 6 7 7 9 (上部)
* ----------------------------
* 3 6 5 . 6 6 8 (展示部分)
* ----------------------------
* 2 5 4 5 5 7 (下部)
* 1 4 3 4 4 6
* 0 3 2 3 3 5
* 2 1 2 2 4
* 1 0 1 1 3
* 0 0 0 2
* 1
* 0
设计代码就像读书,第一遍先把书读厚,第二遍再把书读薄。读厚是为了触及细枝末节,读薄是升华归纳。有了展开的样子接下来就方便了。
2、整体思路
一个数组缓存目标数字,一个Map缓存绘制数据,每次绘制时(onDraw)先检查目标数字是否变化,未变化则根据绘制数据绘制「下一步的样子」,变化则更换绘制数据,再通过「postInvalidate」驱动下一帧
- 使用数组是为了按顺序保存每个目标数字,在变化过程中可以实现更换目标数字,我们可以随便操作数组而不影响当前动画
- 使用Map是为了应对频繁变化的绘制数据,方便存取,当然这里用数组也行。
- 使用「步长+postInvalidate」的方式是因为每个动画都可能不一致,但不用去创建多个动画,性能较好。
三、编码
1、定义Viwe & 绘制对象
class RollNumberView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attr, defStyleAttr) {
/**
* 一次动画的总步数,步数越越少动画越快
*/
private var stepCount = 50
private inner class DrawingItem {
var animTop = 0f // 动画高位
var animBot = 0f // 动画低位
var distance = 0f // 动画距离
var step = 0f // 步长(正数数字向下滚动,负数数字向上滚动)
var stepCount = this@RollNumberView.stepCount // 走过的步数
var targetNum = -1 // 目标数字
var offset = 0f // 当前偏移量
var noAnimOnce = true // 此次变换不用动画
fun resetWithNoAnim() {
stepCount = this@RollNumberView.stepCount
targetNum = -1
offset = 0f
noAnimOnce = true
}
}
}
2、设置内容
入参为字符串,可以为任何字符串,如果带了小数点「.」则认为有小数,会限制小数点后的位数,同时限制整个字符串的长度,多余的会被去掉
companion object {
private const val MAX_COUNT = 10
}
private var curNumStr = "" // 用于输入去重
private var showingNum = "" // 当前展示的字符串,用于计算宽度
private val maxDecimal = 4 // 最大小数位数,包括小数点后的字符
private val zeroCharCode = '0'.code
private val targetArray = IntArray(MAX_COUNT) // 目标数字
private val drawingMap = HashMap<Int, Any>() // 绘制数据
fun setNum(numStr: String) {
val numStrTrim = numStr.trim()
if (numStrTrim.isEmpty()) return
if (curNumStr == numStrTrim) return
curNumStr = numStrTrim
var indexOfDot = numStrTrim.indexOf('.')
val showingNum = if (indexOfDot < 0) {
numStrTrim
} else {
// 左右补0
var tempStr = numStrTrim
if (indexOfDot == numStrTrim.length - 1) {
tempStr = "${numStrTrim}0"
}
if (indexOfDot == 0) {
tempStr = "0$tempStr"
indexOfDot = 1
}
// 切割
tempStr.let {
val partOfLeft = fixLeft(it.substring(0, indexOfDot))
val partOfRight = fixRight(it.substring(indexOfDot + 1, it.length))
"$partOfLeft.$partOfRight"
}
}
if (fillArray(showingNum)) {
drawingMap.forEach { entry ->
val value = entry.value
if (value is DrawingItem) value.resetWithNoAnim()
}
requestLayout()
}
invalidate()
//Log.e("RollNumberView", "setNum:$curNumStr, $showingNum, $reset")
}
private fun fixLeft(numStr: String): String {
val limitSize = targetArray.size - maxDecimal
if (numStr.length > limitSize) {
return numStr.substring(numStr.length - limitSize, numStr.length)
}
return numStr
}
private fun fixRight(numStr: String): String {
if (numStr.length > maxDecimal) {
return numStr.substring(0, maxDecimal)
}
return numStr
}
private fun fillArray(numStr: String): Boolean {
var reset = numStr.length != this.showingNum.length
showingNum = numStr
targetArray.fill(0)
val arrSize = targetArray.size
var indexOfArr = arrSize
var indexOfNum = numStr.length
// 从数组末端开始填入
while (--indexOfArr >= 0 && --indexOfNum >= 0) {
val char = numStr[indexOfNum]
val diff = if (Character.isDigit(char)) {
targetArray[indexOfArr] = char.code - zeroCharCode // 设置数字到数组
setDrawingMap(indexOfArr)
} else {
setDrawingMap(indexOfArr, char)
}
if (diff) reset = true
}
return reset // 字符串长度变化、特殊字符位置变化、字符变化时都不展示动画
}
/**
* @return 返回是否需要重置数据
*/
private fun setDrawingMap(index: Int, any: Any = DrawingItem()): Boolean {
val item = drawingMap[index]
return if (item == null) {
drawingMap[index] = any
true
} else {
if (any is DrawingItem && item is DrawingItem) {
return false // 都是数字,认为一致,不替换,保留动画数据
} else if (any is Char && item is Char) {
drawingMap[index] = any
return any != item // 都是char,char不一样就不一致
} else {
drawingMap[index] = any
return true // 不一致
}
}
}
内容设置好后数组内是这个样子(比如365.668):
targetArray: [0,0,3,6,5,0,6,6,8] // 从数组末端开始填入,字符的位置数字不会变更
3、测量
需要测量单个数字的宽高,同时设置wrap_content时的画布展示部分,宽度为文字宽度,高度为画笔fontMetrics的「desent减ascent」
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var oneNumWidth: Float // 数字宽度
private var numHeight: Float // 数字高度
init {
// 自定义属性,初始化paint和数字宽高
val arr = context.obtainStyledAttributes(attr, R.styleable.RollNumberView, defStyleAttr, 0)
paint.textSize = arr.getDimension(
R.styleable.RollNumberView_android_textSize,
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 30f, Resources.getSystem().displayMetrics)
)
paint.color = arr.getColor(R.styleable.RollNumberView_android_textColor, Color.BLACK)
paint.typeface = arr.getFont(R.styleable.RollNumberView_android_fontFamily)
arr.recycle()
oneNumWidth = paint.measureText("0")
numHeight = paint.fontMetrics.let { it.descent - it.ascent }
setNum("0.0")
}
private fun getWrapWidth() =
(paddingRight + paddingLeft + paint.measureText(showingNum) + 0.5).toInt()
private fun getWrapHeight() = (lineHeight() + 0.5).toInt()
private fun lineHeight() = paddingTop + paddingBottom + numHeight
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSpecMode = MeasureSpec.getMode(widthMeasureSpec)
val wSpecSize = MeasureSpec.getSize(widthMeasureSpec)
val width = when (wSpecMode) {
MeasureSpec.EXACTLY -> wSpecSize
else -> getWrapWidth()
}
val hSpecMode = MeasureSpec.getMode(heightMeasureSpec)
val hSpecSize = MeasureSpec.getSize(heightMeasureSpec)
val height = when (hSpecMode) {
MeasureSpec.EXACTLY -> hSpecSize
else -> getWrapHeight()
}
setMeasuredDimension(width, height)
}
4、绘制
绘制前先确认第一帧的样子,我们将展示的数字停留在「-1」的位置,当然负数我们不会绘制出来
* 9 9 9 9 9 9
* 8 8 8 8 8 8
* 7 7 7 7 7 7
* 6 6 6 6 6 6
* 5 5 5 5 5 5
* 4 4 4 4 4 4
* 3 3 3 3 3 3
* 2 2 2 2 2 2
* 1 1 1 1 1 1
* 0 0 0 0 0 0 (上部)
* (0,0)|---------------------------→ x轴
* | . (展示部分)
* |----------------------------
* ↓ (下部)
* y轴
动画过程中某一帧的效果:
* 9 9 9 9 9
* 7 8 8 8 8 8
* 9 7 7 7 7 7
* 8 6 6 6 6 6
* 7 5 5 5 5 5
* 6 4 4 4 4 4
* 5 3 3 3 3 3
* 4 2 2 2 2 2
* 3 1 1 1 1 1 (上部)
* (0,0)|---------------------------→ x轴
* | 2 0 0 . 0 0 0 (展示部分)
* |---------------------------- 这里offset为1个数字的高度
* | 0 (下部)
* | 1
* ↓---------------------------- 这里offset为3个数字的高度
* y轴
上图中,offset为每列的起始绘制点,第一列从「y=3*numHeight」的位置开始绘制,第二列从 「y=numHeight」的位置开始绘制,逐个数字向上绘制。
主要绘制步骤大概是这样(onDraw):
- 按数组下标遍历Map,逐个绘制。判断是字符还是数字,字符直接绘制,数字按动画绘制
- 动画绘制第1步:判断「数组目标数字」和「动画目标数字」是否相同,不同则重新计算动画数据(动画的高低位置、距离、步数)
- 动画绘制第2步:计算「行走一步」的距离,如果不用动画则「直达」
- 动画绘制第3步:当前数字列,从0开始向上绘制,将0-9全部绘制出来
来看下代码吧,代码之后有效果图
companion object {
private const val SHOW_ALL_NUMBER = true // 是否展示所有数字,如果展示父View需要设置clipChildren为false
}
// 自定义动画插值器
private var mInterpolator: TimeInterpolator? = AccelerateDecelerateInterpolator()
private var enableAnim = true // 是否使用动画
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
var curX = paddingLeft.toFloat()
val arrSize = targetArray.size
var i = arrSize - showingNum.length - 1
var invalidate = false
// 从左向右绘制,先绘制最大的位数
while (++i < arrSize) {
drawingMap[i]?.also {
if (it is DrawingItem) {
// 1、计算绘制数据
checkTargetNum(it, i)
// 2、走一步
invalidate = walkOneStep(it) || invalidate
// 3、绘制
drawOneItem(canvas, curX, it.offset)
// 4、右移一位,绘制下一个数字or字符
curX += oneNumWidth
} else if (it is Char) {
val s = it.toString()
canvas.drawText(s, curX, -paint.fontMetrics.ascent, paint)
curX += paint.measureText(s)
}
}
}
if (invalidate) postInvalidate()
}
/**
* 检查是否需要更换目标数字
*/
private fun checkTargetNum(item: DrawingItem, index: Int) {
val targetNum = targetArray[index]
// 目标不同则更换动画数据
if (targetNum != item.targetNum && item.stepCount >= totalStepCount) {
item.targetNum = targetNum
val lineH = lineHeight()
val targetY = (targetNum + 1) * lineH
// Log.w(tag, "index: $index, targetNum: $targetNum, targetY:$targetY")
if (targetY < item.offset) { // targetY在上面
item.animTop = targetY
item.animBot = item.offset
} else { // targetY在下
item.animTop = item.offset
item.animBot = targetY
}
item.distance = item.animBot - item.animTop
item.step = (targetY - item.offset) / totalStepCount
item.stepCount = 0
}
}
/**
* @return 是否需要刷新下一帧
*/
private fun walkOneStep(item: DrawingItem): Boolean {
if (item.step == 0f) return false
if (!enableAnim || item.noAnimOnce) {
item.noAnimOnce = false
// 直达
item.offset = if (item.step > 0) item.animBot else item.animTop
item.step = 0f
item.stepCount = totalStepCount
} else {
calculateNewOffset(item)
if (item.step > 0) { // 数字下移
if (item.offset >= item.animBot) {// - 1) {
item.offset = item.animBot
item.step = 0f
} else {
return true
}
} else {// 数字上移
if (item.offset <= item.animTop) {// + 1) {
item.offset = item.animTop
item.step = 0f
} else {
return true
}
}
}
return false
}
private fun calculateNewOffset(item: DrawingItem) {
val ip = mInterpolator
item.offset = if (ip == null) {
item.offset + item.step
} else {
val pass = item.stepCount * 1f / totalStepCount
val fraction = ip.getInterpolation(pass)
val newPass = fraction * item.distance
if (item.step > 0) newPass + item.animTop else item.animBot - newPass
}
item.stepCount++
}
private fun drawOneItem(canvas: Canvas, startX: Float, startY: Float) {
val lineH = lineHeight()
var drawNum = 0
var drawY: Float
val limitTop = 1 // 减少绘制
val limitBot = height + lineH + paddingBottom - 1 // 减少绘制
// 从下往上绘制,从0到9绘制,startY是0的顶部-paddingTop
while (drawNum < 10) {
drawY = startY - drawNum * lineH
if (SHOW_ALL_NUMBER || limitTop < drawY && drawY < limitBot) {
canvas.drawText(
drawNum.toString(),
startX,
drawY - paddingBottom - paint.fontMetrics.descent,
paint
)
}
drawNum++
}
}
我们来看下开启「SHOW_ALL_NUMBER」的效果:
「checkTargetNum」方法里有个判断「item.stepCount >= totalStepCount」,代表本次动画完成后才进行下次动画,如果去掉会有有趣的效果,有兴趣可以试试
「calculateNewOffset」方法中实现了自定义插值器,让动画有加减速的效果,要注意的是插值器的入参是动画已经播放了百分之多少,一般用时间计算,这里我用了步数,输出是动画当下的位移量的百分比。
最后是一些测试用例:
当前的代码还有优化空间,比如再增加中间状态,让正在变化数字先减速再变为目标数字
四、END
这是一个挺有意思的自定义View,所以记录了下来,这种自定义View的优势就是层级少,性能好。做带动画的自定义View的核心是「心中有坐标」,剩下的就是 Canvas + Paint 一系列API的使用,比如贝塞尔曲线、sava/restore/clip/matrix、Xfermode等等,不过不知现在还有多少人还在撸自定义View,是不是都在做Compose或者跨端了 😂
五、用AI生成试试
后来用Cursor进行了多次尝试创建自定义View,效果有点不尽人意,偶尔还会崩溃,最终还是要改改的。不过AI大势所趋,用起来,开发效率会提升很多。