实现股票滚动数字效果

61 阅读5分钟

一、目标 & 最终效果

接到一个滚动数字效果的需求,如下图(gif需要一点时间加载):

滚动数字-模板.gif

忙活了一天,得到了下面的效果,还原度还算不错,不过还有优化空间,中间可以插入任意字符,只有数字会滚动。接下来说下解题思路

滚动数字-终版.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」的效果:

滚动数字-预览.gif

「checkTargetNum」方法里有个判断「item.stepCount >= totalStepCount」,代表本次动画完成后才进行下次动画,如果去掉会有有趣的效果,有兴趣可以试试

「calculateNewOffset」方法中实现了自定义插值器,让动画有加减速的效果,要注意的是插值器的入参是动画已经播放了百分之多少,一般用时间计算,这里我用了步数,输出是动画当下的位移量的百分比。

最后是一些测试用例:

滚动数字-测试.gif

当前的代码还有优化空间,比如再增加中间状态,让正在变化数字先减速再变为目标数字

四、END

这是一个挺有意思的自定义View,所以记录了下来,这种自定义View的优势就是层级少,性能好。做带动画的自定义View的核心是「心中有坐标」,剩下的就是 Canvas + Paint 一系列API的使用,比如贝塞尔曲线、sava/restore/clip/matrix、Xfermode等等,不过不知现在还有多少人还在撸自定义View,是不是都在做Compose或者跨端了 😂

五、用AI生成试试

image.png 后来用Cursor进行了多次尝试创建自定义View,效果有点不尽人意,偶尔还会崩溃,最终还是要改改的。不过AI大势所趋,用起来,开发效率会提升很多。