cl-rolling-number.uvue 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. <template>
  2. <cl-text
  3. :color="pt.color"
  4. :pt="{
  5. className: parseClass(['cl-rolling-number', pt.className])
  6. }"
  7. >{{ displayNumber }}</cl-text
  8. >
  9. </template>
  10. <script setup lang="ts">
  11. import { ref, watch, onMounted, computed } from "vue";
  12. import { parseClass, parsePt } from "@/cool";
  13. const props = defineProps({
  14. // 透传样式对象
  15. pt: {
  16. type: Object,
  17. default: () => ({})
  18. },
  19. // 目标数字
  20. value: {
  21. type: Number,
  22. default: 0
  23. },
  24. // 动画持续时间(毫秒)
  25. duration: {
  26. type: Number,
  27. default: 1000
  28. },
  29. // 显示的小数位数
  30. decimals: {
  31. type: Number,
  32. default: 0
  33. }
  34. });
  35. // 定义透传类型,仅支持className
  36. type PassThrough = {
  37. className?: string;
  38. color?: string;
  39. };
  40. // 计算pt样式,便于组件内使用
  41. const pt = computed(() => parsePt<PassThrough>(props.pt));
  42. // 当前动画显示的数字
  43. const currentNumber = ref<number>(0);
  44. // 当前格式化后显示的字符串
  45. const displayNumber = ref<string>("0");
  46. // requestAnimationFrame动画ID,用于取消动画
  47. let animationId: number = 0;
  48. // setTimeout定时器ID,用于兼容模式
  49. let timerId: number = 0;
  50. // 动画起始值
  51. let startValue: number = 0;
  52. // 动画目标值
  53. let targetValue: number = 0;
  54. // 动画起始时间戳
  55. let startTime: number = 0;
  56. // 缓动函数,ease out cubic,动画更自然
  57. function easeOut(t: number): number {
  58. return 1 - Math.pow(1 - t, 3);
  59. }
  60. // 格式化数字,保留指定小数位
  61. function formatNumber(num: number): string {
  62. if (props.decimals == 0) {
  63. return Math.round(num).toString();
  64. }
  65. return num.toFixed(props.decimals);
  66. }
  67. // 动画主循环,每帧更新currentNumber和displayNumber
  68. function animate(timestamp: number): void {
  69. // 首帧记录动画起始时间
  70. if (startTime == 0) {
  71. startTime = timestamp;
  72. }
  73. // 计算已用时间
  74. const elapsed = timestamp - startTime;
  75. // 计算动画进度,最大为1
  76. const progress = Math.min(elapsed / props.duration, 1);
  77. // 应用缓动函数
  78. const easedProgress = easeOut(progress);
  79. // 计算当前动画值
  80. const currentValue = startValue + (targetValue - startValue) * easedProgress;
  81. currentNumber.value = currentValue;
  82. displayNumber.value = formatNumber(currentValue);
  83. // 动画未结束,继续下一帧
  84. if (progress < 1) {
  85. animationId = requestAnimationFrame((t) => animate(t));
  86. } else {
  87. // 动画结束,确保显示最终值
  88. currentNumber.value = targetValue;
  89. displayNumber.value = formatNumber(targetValue);
  90. animationId = 0;
  91. }
  92. }
  93. /**
  94. * 基于setTimeout的兼容动画实现
  95. * 适用于不支持requestAnimationFrame的环境
  96. */
  97. function animateWithTimeout(): void {
  98. const frameRate = 60; // 60fps
  99. const frameDuration = 1000 / frameRate; // 每帧时间间隔
  100. const totalFrames = Math.ceil(props.duration / frameDuration); // 总帧数
  101. let currentFrame = 0;
  102. function loop(): void {
  103. currentFrame++;
  104. // 计算动画进度,最大为1
  105. const progress = Math.min(currentFrame / totalFrames, 1);
  106. // 应用缓动函数
  107. const easedProgress = easeOut(progress);
  108. // 计算当前动画值
  109. const currentValue = startValue + (targetValue - startValue) * easedProgress;
  110. currentNumber.value = currentValue;
  111. displayNumber.value = formatNumber(currentValue);
  112. // 动画未结束,继续下一帧
  113. if (progress < 1) {
  114. // @ts-ignore
  115. timerId = setTimeout(() => loop(), frameDuration);
  116. } else {
  117. // 动画结束,确保显示最终值
  118. currentNumber.value = targetValue;
  119. displayNumber.value = formatNumber(targetValue);
  120. timerId = 0;
  121. }
  122. }
  123. loop();
  124. }
  125. // 外部调用,停止动画
  126. function stop() {
  127. if (animationId != 0) {
  128. cancelAnimationFrame(animationId);
  129. animationId = 0;
  130. }
  131. if (timerId != 0) {
  132. clearTimeout(timerId);
  133. timerId = 0;
  134. }
  135. }
  136. /**
  137. * 启动动画,从from到to
  138. * @param from 起始值
  139. * @param to 目标值
  140. */
  141. function startAnimation(from: number, to: number): void {
  142. // 若有未完成动画,先取消
  143. stop();
  144. startValue = from;
  145. targetValue = to;
  146. startTime = 0;
  147. // #ifdef MP
  148. animateWithTimeout();
  149. // #endif
  150. // #ifndef MP
  151. // 启动动画
  152. animationId = requestAnimationFrame(animate);
  153. // #endif
  154. }
  155. // 外部调用,重头开始动画
  156. function start() {
  157. startAnimation(0, props.value);
  158. }
  159. // 监听value变化,自动启动动画
  160. watch(
  161. computed(() => props.value),
  162. (newValue: number, oldValue: number) => {
  163. // 只有值变化时才启动动画
  164. if (newValue != oldValue) {
  165. startAnimation(currentNumber.value, newValue);
  166. }
  167. },
  168. { immediate: false }
  169. );
  170. // 组件挂载时,初始化动画
  171. onMounted(() => {
  172. start();
  173. });
  174. defineExpose({
  175. start,
  176. stop
  177. });
  178. </script>