cl-rolling-number.uvue 4.7 KB

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