【Uni-app组件日志】🚀数字滚动动画的终极实现:让你的数据"活"起来

809 阅读4分钟

当数据遇上动画,枯燥的数字瞬间有了生命力。今天我们来探讨如何用Vue实现一个让人眼前一亮的数字滚动组件。

🎯 为什么我们需要这样的组件?

想象一下这些场景:

  • 📊 大屏数据看板上的实时销售额
  • 🎮 游戏中玩家积分的动态增长
  • 💰 电商页面价格变动的丝滑过渡
  • 📈 股票应用中股价的实时跳动

传统的数字切换就像"咔嚓"一声的开关灯,而我们要实现的是日出日落般的渐变美感。

💡 技术挑战与巧妙解法

挑战1:如何让数字"滚"起来?

常规思路:直接改变数字内容
我们的方案:将每个数字位视为一个独立的滚轮,通过transform: translateY控制位置

挑战2:不同位数的数字如何对齐?

巧妙之处:动态补零算法,让"9"变"100"时,自动在前面补充隐藏的"0"

挑战3:小数点如何处理?

创新点:将小数点作为特殊字符单独处理,不参与滚动动画

🛠️ 核心实现揭秘

数据结构设计:让每个字符都有身份

// 每个字符都有自己的类型标识
{
  value: '8',
  type: 'int'  // 整数位
}
{
  value: '.',
  type: 'dot'  // 小数点
}
{
  value: '0',
  type: 'repairZero'  // 补零位(隐藏)
}

动画算法:数字滚轮的物理模拟

const getAniNumItemStyle = index => {
  const curItem = displayList.value[index]
  const isDot = curItem.type === 'dot'
  // 关键算法:每个数字占100%高度,通过百分比控制滚动位置
  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             // 宽度动态调整
  }
}

🎨 完整组件实现


<!-- 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>

⚡ 性能优化的秘密武器

1. 智能初始化机制

// 巧妙的初始化判断,避免不必要的动画
isInit = newVal && oldVal && newVal !== oldVal

2. 内存泄漏防护

onUnmounted(() => {
  clearTimeout(timeout)
  timeout = null
})

3. CSS动画硬件加速

使用transform而非直接修改DOM,让GPU参与渲染,丝滑如德芙。

🎮 实战应用:让数据活起来

基础用法:极简调用

<template>
  <div>
    <ani-num :init-value="0" :value="realTimeData" :duration="800" />
  </div>
</template>

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

const realTimeData = ref(0)

// 模拟实时数据更新
setInterval(() => {
  realTimeData.value = Math.random() * 10000
}, 2000)
</script>

高级应用:销售大屏

<template>
  <div class="dashboard">
    <div class="metric-card">
      <h3>今日销售额</h3>
      <ani-num 
        :init-value="0" 
        :value="salesAmount" 
        :fontSize="48"
        color="#00ff88"
        :bold="true"
        :duration="1200"
      />
      <span class="unit">元</span>
    </div>
  </div>
</template>

Screenshot_2024_0525_172741.gif

🔮 技术扩展思考

可能的增强功能

  • 🎨 数字颜色渐变动画
  • 🎵 滚动音效支持
  • 📱 触摸手势控制
  • 🌈 多主题皮肤切换
  • ⚡ Web Workers异步计算

适配场景拓展

  • 倒计时组件
  • 抽奖转盘数字
  • 进度条百分比
  • 温度计显示

🎯 总结:小组件,大智慧

这个AniNum组件虽然看似简单,但其中蕴含的技术思考值得深思:

  1. 算法设计:补零对齐算法的巧妙性
  2. 性能优化:CSS3硬件加速的运用
  3. 用户体验:从枯燥数字到生动动画的转变
  4. 代码架构:组件化思维的体现

好的技术不仅解决问题,更要创造价值。让数据有温度,让界面有灵魂,这就是前端工程师的使命。


想要更多酷炫效果?
欢迎在评论区分享你的创意想法! 🚀