NumberRoll.vue 6.42 KB
<template>
  <view class="number-roll-container">
    <view
      v-for="(digit, index) in digits"
      :key="index"
      class="digit-container"
    >
      <view class="digit-wrapper">
        <view
          class="digit-column"
          :style="{ 
            transform: `translateY(-${digit.currentValue * 10}%) translateZ(0)`,
            willChange: isAnimating ? 'transform' : 'auto'
          }"
        >
          <view
            v-for="num in 10"
            :key="num - 1"
            class="digit-item"
          >
            {{ num - 1 }}
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, computed, watch, defineExpose } from 'vue'

// Props
const props = defineProps({
  // 目标数值
  targetValue: {
    type: Number,
    default: 0
  },
  // 数字位数
  digitCount: {
    type: Number,
    default: 4
  },
  // 滚动速度(毫秒)
  duration: {
    type: Number,
    default: 2000
  },
  // 每位数字之间的延迟(毫秒)
  digitDelay: {
    type: Number,
    default: 200
  }
})

// 响应式数据
const isAnimating = ref(false)
const isPaused = ref(false)
const animationTimers = ref([])

// 初始化数字数组
const digits = ref([])

// 初始化数字位
const initDigits = () => {
  digits.value = Array.from({ length: props.digitCount }, (_, index) => ({
    currentValue: 0,
    targetValue: 0,
    animationId: null,
    startTime: 0,
    pausedTime: 0
  }))
}

// 将数字转换为数组
const parseTargetValue = (value) => {
  const str = value.toString().padStart(props.digitCount, '0')
  return str.split('').map(Number)
}

// 开始滚动动画
const startAnimation = () => {
  if (isAnimating.value && !isPaused.value) return

  isAnimating.value = true
  isPaused.value = false

  const targetDigits = parseTargetValue(props.targetValue)

  // 为每个数字位设置目标值
  digits.value.forEach((digit, index) => {
    digit.targetValue = targetDigits[index]
  })

  // 依次启动每个数字位的动画
  digits.value.forEach((digit, index) => {
    const delay = index * props.digitDelay

    setTimeout(() => {
      if (!isAnimating.value || isPaused.value) return
      animateDigit(digit, index)
    }, delay)
  })
}

// 单个数字位的动画
const animateDigit = (digit, index) => {
  const startValue = digit.currentValue
  const endValue = digit.targetValue
  const startTime = Date.now()

  digit.startTime = startTime

  const animate = () => {
    if (!isAnimating.value || isPaused.value) return

    const currentTime = Date.now()
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / props.duration, 1)

    // 使用更平滑的缓动函数
    const easeProgress = easeOutQuart(progress)

    // 计算当前值,使用更精确的计算
    const currentValue = startValue + (endValue - startValue) * easeProgress
    
    // 减少不必要的更新,只在值有明显变化时更新
    const roundedValue = Math.round(currentValue * 100) / 100
    if (Math.abs(digit.currentValue - roundedValue) > 0.01) {
      digit.currentValue = roundedValue
    }

    if (progress < 1) {
      digit.animationId = requestAnimationFrame(animate)
    } else {
      digit.currentValue = endValue
      checkAnimationComplete()
    }
  }

  digit.animationId = requestAnimationFrame(animate)
}

// 更平滑的缓动函数
const easeOutQuart = (t) => {
  return 1 - Math.pow(1 - t, 4)
}

// 检查动画是否完成
const checkAnimationComplete = () => {
  const allComplete = digits.value.every(digit =>
    Math.abs(digit.currentValue - digit.targetValue) < 0.01
  )

  if (allComplete) {
    isAnimating.value = false
    // 确保所有数字都是整数
    digits.value.forEach(digit => {
      digit.currentValue = digit.targetValue
    })
  }
}

// 暂停动画
const pauseAnimation = () => {
  if (!isAnimating.value || isPaused.value) return

  isPaused.value = true

  digits.value.forEach(digit => {
    if (digit.animationId) {
      cancelAnimationFrame(digit.animationId)
      digit.animationId = null
      digit.pausedTime = Date.now()
    }
  })
}

// 恢复动画
const resumeAnimation = () => {
  if (!isAnimating.value || !isPaused.value) return

  isPaused.value = false

  digits.value.forEach((digit, index) => {
    if (digit.currentValue !== digit.targetValue) {
      // 调整开始时间以补偿暂停的时间
      const pausedDuration = digit.pausedTime - digit.startTime
      digit.startTime = Date.now() - pausedDuration
      animateDigit(digit, index)
    }
  })
}

// 重置动画
const resetAnimation = () => {
  isAnimating.value = false
  isPaused.value = false

  // 取消所有动画
  digits.value.forEach(digit => {
    if (digit.animationId) {
      cancelAnimationFrame(digit.animationId)
      digit.animationId = null
    }
    digit.currentValue = 0
    digit.targetValue = 0
    digit.startTime = 0
    digit.pausedTime = 0
  })
}

// 监听目标值变化
watch(() => props.targetValue, () => {
  if (isAnimating.value) {
    resetAnimation()
  }
})

// 初始化
initDigits()

// 暴露方法
defineExpose({
  start: startAnimation,
  pause: pauseAnimation,
  resume: resumeAnimation,
  reset: resetAnimation,
  isAnimating: computed(() => isAnimating.value),
  isPaused: computed(() => isPaused.value)
})
</script>

<style lang="less">
.number-roll-container {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4rpx;
}

.digit-container {
  position: relative;
  width: 40rpx;
  height: 60rpx;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 8rpx;
  border: 1rpx solid rgba(255, 255, 255, 0.2);
  // 启用硬件加速
  transform: translateZ(0);
  backface-visibility: hidden;
  perspective: 1000px;
}

.digit-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  // 启用硬件加速
  transform: translateZ(0);
}

.digit-column {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  // 移除CSS transition,完全由JS控制动画
  // transition: transform 0.1s ease-out;
  // 启用硬件加速和优化
  transform-style: preserve-3d;
  backface-visibility: hidden;
}

.digit-item {
  width: 100%;
  height: 60rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32rpx;
  font-weight: bold;
  color: #fff;
  font-family: 'Courier New', monospace;
  // 文本渲染优化
  text-rendering: optimizeSpeed;
  -webkit-font-smoothing: antialiased;
  // 启用硬件加速
  transform: translateZ(0);
}
</style>