数字滚动动画组件

2025-06-30

代码

<!-- AniNum组件:数字滚动动画组件 -->

<template>

  <view class="ani-num-container">

    <view class="ani-num-item" :style="{ height: `${aniItemHeight}rpx` }" v-for="(item, index) in displayList" :key="index">

      <view class="text-num-box" :style="{ ...computedAniNumItemStyle, ...getAniNumItemStyle(index) }">

        <view class="text-num" :style="textNumStyle" v-if="item === '.'">{{ item }}</view>

        <view class="text-num" :style="textNumStyle" v-else v-for="(numItem, numIndex) in 10" :key="numIndex">{{ numIndex }}</view>

      </view>

    </view>

  </view>

</template>



<script setup>

import { ref, computed, watch, onUnmounted } from 'vue'

// Tip: 自定义的转换数字类型方法,可自行替换
import { toNum } from 'utils/index'



const props = defineProps({

  initValue: {

    type: [Number, String],

    default: 0

  },

  value: {

    type: [Number, String],

    default: 0

  },

  fontSize: {

    type: Number,

    default: 32

  },

  color: {

    type: String,

    default: '#fff'

  },

  bold: {

    type: Boolean,

    default: false

  },

  duration: {

    type: Number,

    default: 1500

  }

})



let timeout = null

let isInit = false



const prevNum = ref(0)

const curNum = ref(0)



const displayList = computed(() => {

  const { endList } = formatNumStrList(prevNum.value, curNum.value)



  console.log('%c [ displayList ]-32', 'font-size:13px; background:pink; color:#bf2c9f;', endList)

  return endList

})



const computedAniNumItemStyle = computed(() => {

  return {

    transition: `transform ${props.duration}ms ease-in-out, opacity 0.5s linear`

  }

})



const aniItemHeight = computed(() => {

  return props.fontSize + 10

})



const textNumStyle = computed(() => {

  return {

    height: `${aniItemHeight.value}rpx`,

    lineHeight: `${aniItemHeight.value}rpx`,

    fontSize: `${props.fontSize}rpx`,

    color: `${props.color}`,

    fontWeight: `${props.bold ? 'bold' : 'normal'}`

  }

})



onUnmounted(() => {

  clearTimeout(timeout)

  timeout = null

})



/** 更新数值

 * @param {*} newVal 新的数值

 * @param {*} delay 动画持续时间

 */

const updateValue = (newVal = props.value, delay = 0) => {

  const commonHandle = newVal => {

    prevNum.value = curNum.value

    curNum.value = newVal

  }

  newVal = toNum(newVal)

  console.log('%c [ updateValue ]-93', 'font-size:13px; background:pink; color:#bf2c9f;', newVal)

  clearTimeout(timeout)

  // 动画持续时间为0时,直接更新数值

  if (delay === 0) {

    commonHandle(newVal)

  }

  // 否则,延迟更新数值

  else {

    timeout = setTimeout(() => commonHandle(newVal), delay)

  }

}



const getAniNumItemStyle = index => {

  const curItem = displayList.value[index]

  const isDot = curItem.type === 'dot' // 是否是小数点

  const transY = isDot ? 0 : toNum(curItem.value) * 100

  const isShow = curItem.type !== 'repairZero' // 是否显示

  return {

    transform: `translateY(-${transY}%)`,

    opacity: isShow ? 1 : 0,

    width: isShow ? 'auto' : 0

  }

}



// 将数字字符串数字转化为相同的排列顺序,某位数不足的补0

const formatNumStrList = (start, end) => {

  let { intStrList: startIntStrList, floatStrList: startFloatStrList } = getIntAndFloatOfNum(start)

  let { intStrList: endIntStrList, floatStrList: endFloatStrList } = getIntAndFloatOfNum(end)



  const intStrLenDiff = Math.abs(startIntStrList.length - endIntStrList.length)

  if (intStrLenDiff > 0) {

    const repairZeroList = []

    for (let i = 0; i < intStrLenDiff; i++) {

      repairZeroList.push({

        value: '0',

        type: 'repairZero'

      })

    }

    if (startIntStrList.length > endIntStrList.length) {

      endIntStrList.unshift(...repairZeroList)

    } else {

      startIntStrList.unshift(...repairZeroList)

    }

  }



  const floatStrLenDiff = Math.abs(startFloatStrList.length - endFloatStrList.length)

  if (floatStrLenDiff > 0) {

    const repairZeroList = []

    for (let i = 0; i < floatStrLenDiff; i++) {

      repairZeroList.push({

        value: '0',

        type: 'repairZero'

      })

    }

    if (startFloatStrList.length > endFloatStrList.length) {

      endFloatStrList.push(...repairZeroList)

    } else {

      startFloatStrList.push(...repairZeroList)

    }

  }



  const startList = [

    ...startIntStrList,

    ...(startFloatStrList.length

      ? [

          {

            value: '.',

            type: 'dot'

          },

          ...startFloatStrList

        ]

      : [])

  ]

  const endList = [

    ...endIntStrList,

    ...(endFloatStrList.length

      ? [

          {

            value: '.',

            type: 'dot'

          },

          ...endFloatStrList

        ]

      : [])

  ]

  return {

    startList,

    endList

  }

}



const getIntAndFloatOfNum = num => {

  const numStr = toNum(num).toString()

  const int = numStr.split('.')[0]

  const float = numStr.split('.')[1] || []



  const intStrList = int.split('').map(item => ({

    value: item,

    type: 'int'

  }))

  const floatStrList = (float.length ? float.split('') : []).map(item => ({

    value: item,

    type: 'float'

  }))

  return {

    intStrList,

    floatStrList

  }

}



watch(

  () => props.initValue,

  (newVal, oldVal) => {

    if (isInit) return

    console.log('%c [ props.initValue ]-193', 'font-size:13px; background:pink; color:#bf2c9f;', newVal, oldVal, props.initValue)

    updateValue(newVal)

    // 只有在initValue现在的值和之前的值都存在并且不相等时,才认为是初始化

    isInit = newVal && oldVal && newVal !== oldVal

  },

  {

    immediate: true

  }

)



watch(

  () => props.value,

  newVal => {

    if (!isInit) return



    console.log('%c [ watch--value ]-225', 'font-size:13px; background:pink; color:#bf2c9f;', newVal)

    updateValue(newVal, props.duration)

  }

)



defineExpose({

  updateValue

})

</script>



<style lang="scss" scoped>

.ani-num-container {

  display: inline-flex;

  align-items: flex-end;

  .ani-num-item {

    display: flex;

    flex-direction: column;

    overflow: hidden;

    .text-num-box {

      height: 100%;

      display: flex;

      flex-direction: column;

    }

    .text-num {

      color: #fff;

      text-align: center;

    }

  }

}

</style>

使用示例




<template>
  <view>
    <ani-num :init-value="initValue" :value="value" :duration="1000"></ani-num>
  </view>
</template>

<script setup>
import { ref } from 'vue'
import AniNum from './AniNum.vue'

const initValue = ref(0)
const value = ref(100)
</script>

效果

NEXT
移动端视口高度