合同小程序前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

533 lines
13 KiB

3 months ago
  1. // 引入颜色处理库
  2. import { tinyColor } from '@/uni_modules/lime-color';
  3. // ===================== 类型定义 =====================
  4. /**
  5. * 加载动画类型
  6. * circular: 环形加载动画
  7. * spinner: 旋转器加载动画
  8. * failed: 失败状态动画
  9. */
  10. export type LoadingType = 'circular' | 'spinner' | 'failed';
  11. /**
  12. * 操作类型
  13. * play: 开始动画
  14. * failed: 显示失败状态
  15. * clear: 清除动画
  16. * destroy: 销毁实例
  17. */
  18. export type TickType = 'play' | 'failed' | 'clear' | 'destroy'
  19. /**
  20. * 加载组件配置选项
  21. * @property type - 初始动画类型
  22. * @property strokeColor - 线条颜色
  23. * @property ratio - 尺寸比例
  24. * @property immediate - 是否立即启动
  25. */
  26. export type UseLoadingOptions = {
  27. type : LoadingType;
  28. strokeColor : string;
  29. ratio : number;
  30. immediate ?: boolean;
  31. };
  32. /**
  33. * 加载组件返回接口
  34. */
  35. export type UseLoadingReturn = {
  36. // state : Ref<boolean>;
  37. // setOptions: (options: UseLoadingOptions) => void
  38. ratio : 1;
  39. type : LoadingType;
  40. color : string;//Ref<string>;
  41. play : () => void;
  42. failed : () => void;
  43. clear : () => void;
  44. destroy : () => void;
  45. }
  46. /**
  47. * 画布尺寸信息
  48. */
  49. export type Dimensions = {
  50. width : number;
  51. height : number;
  52. size : number
  53. }
  54. /**
  55. * 线段坐标点
  56. */
  57. type Point = {
  58. x1 : number
  59. y1 : number
  60. x2 : number
  61. y2 : number
  62. }
  63. /**
  64. * 画布上下文信息
  65. */
  66. type LoadingCanvasContext = {
  67. ctx : Ref<DrawableContext | null>;
  68. dimensions : Ref<Dimensions>;
  69. updateDimensions : (el : UniElement) => void;
  70. };
  71. /**
  72. * 动画参数配置
  73. */
  74. type AnimationParams = {
  75. width : number
  76. height : number
  77. center : number[] // 元组类型,明确表示两个数值的坐标
  78. color : string // 使用Ref类型包裹字符串
  79. size : number // 数值类型尺寸
  80. }
  81. // ===================== 动画管理器 =====================
  82. type AnimationFrameHandler = () => boolean;
  83. /**
  84. * 动画管理类
  85. * 封装动画的启动/停止逻辑
  86. */
  87. export class AnimationManager {
  88. time : number = 1000 / 60 // 默认帧率60fps
  89. private timer : number = -1;// 定时器ID
  90. private isDestroyed : boolean = false; // 销毁状态
  91. private drawFrame : AnimationFrameHandler// 帧绘制函数
  92. constructor(drawFrame : AnimationFrameHandler) {
  93. this.drawFrame = drawFrame
  94. }
  95. /** 启动动画循环 */
  96. start() {
  97. let animate : (() => void) | null = null
  98. animate = () => {
  99. if (this.isDestroyed) return;
  100. const shouldContinue : boolean = this.drawFrame();
  101. if (shouldContinue && animate != null) {
  102. this.timer = setTimeout(animate!, this.time);
  103. }
  104. };
  105. animate();
  106. }
  107. /** 停止动画并清理资源 */
  108. stop() {
  109. clearTimeout(this.timer);
  110. this.isDestroyed = true;
  111. }
  112. }
  113. // ===================== 工具函数 =====================
  114. /**
  115. * 缓动函数 - 三次缓入缓出
  116. * @param t 时间系数 (0-1)
  117. * @returns 计算后的进度值
  118. */
  119. function easeInOutCubic(t : number) : number {
  120. return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
  121. }
  122. // ===================== 画布管理 =====================
  123. /**
  124. * 获取画布上下文信息
  125. * @param element 画布元素引用
  126. * @returns 包含画布上下文和尺寸信息的对象
  127. */
  128. //_element : Ref<UniElement | null>
  129. export function useCanvas() : LoadingCanvasContext {
  130. const ctx = shallowRef<DrawableContext | null>(null);
  131. const dimensions = ref<Dimensions>({
  132. width: 0,
  133. height: 0,
  134. size: 0
  135. });
  136. const updateDimensions = (el: UniElement) => {
  137. const rect = el.getBoundingClientRect();
  138. ctx.value = el.getDrawableContext() as DrawableContext;
  139. dimensions.value.width = rect.width;
  140. dimensions.value.height = rect.height;
  141. dimensions.value.size = Math.min(rect.width, rect.height);
  142. };
  143. return {
  144. ctx,
  145. dimensions,
  146. updateDimensions
  147. } as LoadingCanvasContext
  148. }
  149. // ===================== 动画创建函数 =====================
  150. /**
  151. * 创建环形加载动画
  152. * @param ctx 画布上下文
  153. * @param animationParams 动画参数
  154. * @returns 动画管理器实例
  155. */
  156. function createCircularAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager {
  157. const { size, color, width, height } = animationParams
  158. let startAngle = 0; // 起始角度
  159. let endAngle = 0; // 结束角度
  160. let rotate = 0; // 旋转角度
  161. // 动画参数配置
  162. const MIN_ANGLE = 5; // 最小保持角度
  163. const ARC_LENGTH = 359.5 // 最大弧长(避免闭合)
  164. const PI = Math.PI / 180 // 角度转弧度系数
  165. const SPEED = 0.018 // 动画速度
  166. const ROTATE_INTERVAL = 0.09 // 旋转增量
  167. const lineWidth = size / 10; // 线宽计算
  168. const x = width / 2 // 中心点X
  169. const y = height / 2 // 中心点Y
  170. const radius = size / 2 - lineWidth // 实际绘制半径
  171. /** 帧绘制函数 */
  172. const drawFrame = () : boolean => {
  173. ctx.reset();
  174. // 绘制圆弧
  175. ctx.beginPath();
  176. ctx.arc(
  177. x,
  178. y,
  179. radius,
  180. startAngle * PI + rotate,
  181. endAngle * PI + rotate
  182. );
  183. ctx.lineWidth = lineWidth;
  184. ctx.strokeStyle = color;
  185. ctx.stroke();
  186. // 角度更新逻辑
  187. if (endAngle < ARC_LENGTH) {
  188. endAngle = Math.min(ARC_LENGTH, endAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED);
  189. } else if (startAngle < ARC_LENGTH) {
  190. startAngle = Math.min(ARC_LENGTH, startAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED);
  191. } else {
  192. // 重置时保留最小可见角度
  193. startAngle = 0;
  194. endAngle = MIN_ANGLE;
  195. }
  196. rotate = (rotate + ROTATE_INTERVAL) % 360; // 持续旋转并限制范围
  197. ctx.update()
  198. return true
  199. }
  200. return new AnimationManager(drawFrame)
  201. }
  202. /**
  203. * 创建旋转器动画
  204. * @param ctx 画布上下文
  205. * @param animationParams 动画参数
  206. * @returns 动画管理器实例
  207. */
  208. function createSpinnerAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager {
  209. const { size, color, center } = animationParams
  210. const steps = 12; // 旋转线条数量
  211. let step = 0; // 当前步数
  212. const lineWidth = size / 10; // 线宽
  213. const length = size / 4 - lineWidth; // 线长
  214. const offset = size / 4; // 距中心偏移
  215. const [x, y] = center // 中心坐标
  216. /** 生成颜色渐变数组 */
  217. function generateColorGradient(hex : string, steps : number) : string[] {
  218. const colors : string[] = []
  219. const _color = tinyColor(hex)
  220. for (let i = 1; i <= steps; i++) {
  221. _color.setAlpha(i / steps);
  222. colors.push(_color.toRgbString());
  223. }
  224. return colors
  225. }
  226. // 计算颜色渐变
  227. let colors = computed(() : string[] => generateColorGradient(color, steps))
  228. /** 帧绘制函数 */
  229. const drawFrame = () : boolean => {
  230. ctx.reset();
  231. for (let i = 0; i < steps; i++) {
  232. const stepAngle = 360 / steps; // 单步角度
  233. const angle = stepAngle * i; // 当前角度
  234. const index = (steps + i - (step % steps)) % steps // 颜色索引
  235. // 计算线段坐标
  236. const radian = angle * Math.PI / 180;
  237. const cos = Math.cos(radian);
  238. const sin = Math.sin(radian);
  239. // 绘制线段
  240. ctx.beginPath();
  241. ctx.moveTo(x + offset * cos, y + offset * sin);
  242. ctx.lineTo(x + (offset + length) * cos, y + (offset + length) * sin);
  243. ctx.lineWidth = lineWidth;
  244. ctx.lineCap = 'round';
  245. ctx.strokeStyle = colors.value[index];
  246. ctx.stroke();
  247. }
  248. step += 1
  249. ctx.update()
  250. return true
  251. }
  252. return new AnimationManager(drawFrame)
  253. }
  254. /**
  255. * 计算圆周上指定角度的点的坐标
  256. * @param centerX 圆心的 X 坐标
  257. * @param centerY 圆心的 Y 坐标
  258. * @param radius 圆的半径
  259. * @param angleDegrees 角度(以度为单位)
  260. * @returns 包含 X 和 Y 坐标的对象
  261. */
  262. function getPointOnCircle(
  263. centerX : number,
  264. centerY : number,
  265. radius : number,
  266. angleDegrees : number
  267. ) : number[] {
  268. // 将角度转换为弧度
  269. const angleRadians = (angleDegrees * Math.PI) / 180;
  270. // 计算点的 X 和 Y 坐标
  271. const x = centerX + radius * Math.cos(angleRadians);
  272. const y = centerY + radius * Math.sin(angleRadians);
  273. return [x, y]
  274. }
  275. /**
  276. * 创建失败状态动画(包含X图标和外围圆圈)
  277. * @param ctx 画布上下文
  278. * @param animationParams 动画参数
  279. * @returns 动画管理器实例
  280. */
  281. function createFailedAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager {
  282. const { width, height, size, color } = animationParams
  283. const innerSize = size * 0.8 // 内圈尺寸
  284. const lineWidth = innerSize / 10; // 线宽
  285. const lineLength = (size - lineWidth) / 2 // X长度
  286. const centerX = width / 2;
  287. const centerY = height / 2;
  288. const [startX1, startY] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 45)
  289. const [startX2] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 90 + 45)
  290. const angleRadians1 = 45 * Math.PI / 180
  291. const angleRadians2 = (45 - 90) * Math.PI / 180
  292. const radius = (size - lineWidth) / 2
  293. const totalSteps = 36; // 总动画步数
  294. function generateSteps(stepsCount : number) : Point[][] {
  295. const halfStepsCount = stepsCount / 2;
  296. const step = lineLength / halfStepsCount
  297. const steps : Point[][] = []
  298. for (let i = 0; i < stepsCount; i++) {
  299. const sub : Point[] = []
  300. const index = i % 18 + 1
  301. if (i < halfStepsCount) {
  302. const x2 = Math.sin(angleRadians1) * step * index + startX1
  303. const y2 = Math.cos(angleRadians1) * step * index + startY
  304. const start1 = {
  305. x1: startX1,
  306. y1: startY,
  307. x2,
  308. y2,
  309. } as Point
  310. sub.push(start1)
  311. } else {
  312. sub.push(steps[halfStepsCount - 1][0])
  313. const x2 = Math.sin(angleRadians2) * step * index + startX2
  314. const y2 = Math.cos(angleRadians2) * step * index + startY
  315. const start2 = {
  316. x1: startX2,
  317. y1: startY,
  318. x2,
  319. y2,
  320. } as Point
  321. sub.push(start2)
  322. }
  323. steps.push(sub)
  324. }
  325. return steps
  326. }
  327. const steps = generateSteps(totalSteps);
  328. const drawFrame = () : boolean => {
  329. const drawStep = steps.shift()!
  330. ctx.reset()
  331. ctx.lineWidth = lineWidth;
  332. ctx.strokeStyle = color;
  333. // 绘制逐渐显示的圆
  334. ctx.beginPath();
  335. ctx.arc(centerX, centerY, radius, 0, (2 * Math.PI) * (totalSteps - steps.length) / totalSteps);
  336. ctx.lineWidth = lineWidth;
  337. ctx.strokeStyle = color;
  338. ctx.stroke();
  339. // 绘制X
  340. ctx.beginPath();
  341. drawStep.forEach(item => {
  342. ctx.beginPath();
  343. ctx.moveTo(item.x1, item.y1)
  344. ctx.lineTo(item.x2, item.y2)
  345. ctx.stroke();
  346. })
  347. ctx.update()
  348. return steps.length != 0
  349. }
  350. return new AnimationManager(drawFrame)
  351. }
  352. // ===================== 主Hook函数 =====================
  353. /**
  354. * 加载动画组合式函数
  355. * @param element 画布元素引用
  356. * @returns 加载控制器实例
  357. */
  358. export function useLoading(
  359. element : Ref<UniElement | null>,
  360. // options : UseLoadingOptions
  361. ) : UseLoadingReturn {
  362. const ticks = ref<TickType[]>([]);
  363. const currentTick = ref<TickType>('clear');
  364. const state = reactive<UseLoadingReturn>({
  365. color: '#000',
  366. type: 'circular',
  367. ratio: 1,
  368. play: () => {
  369. ticks.value.length = 0
  370. ticks.value.push('play')
  371. },
  372. failed: () => {
  373. ticks.value.length = 0
  374. ticks.value.push('failed')
  375. },
  376. clear: () => {
  377. ticks.value.length = 0
  378. ticks.value.push('clear')
  379. },
  380. destroy: () => {
  381. ticks.value.length = 0
  382. ticks.value.push('destroy')
  383. },
  384. })
  385. const { ctx, dimensions, updateDimensions } = useCanvas();
  386. const resizeObserver : UniResizeObserver = new UniResizeObserver((_entries : UniResizeObserverEntry[])=>{
  387. updateDimensions(element.value!)
  388. });
  389. const currentAnimation = shallowRef<AnimationManager | null>(null);
  390. // 计算动画参数
  391. const animationParams = computed(() : AnimationParams => {
  392. return {
  393. width: dimensions.value.width,
  394. height: dimensions.value.height,
  395. center: [dimensions.value.width / 2, dimensions.value.height / 2],
  396. color: state.color,
  397. size: state.ratio > 1 ? state.ratio : dimensions.value.size * state.ratio
  398. } as AnimationParams
  399. })
  400. const startAnimation = (type : LoadingType) => {
  401. currentAnimation.value?.stop();
  402. if (type == 'circular') {
  403. currentAnimation.value = createCircularAnimation(ctx.value!, animationParams.value)
  404. currentAnimation.value!.time = 1000 / 30
  405. currentAnimation.value!.start()
  406. return
  407. }
  408. if (type == 'spinner') {
  409. currentAnimation.value = createSpinnerAnimation(ctx.value!, animationParams.value)
  410. currentAnimation.value!.time = 1000 / 10
  411. currentAnimation.value!.start()
  412. return
  413. }
  414. if (type == 'failed') {
  415. currentAnimation.value = createFailedAnimation(ctx.value!, animationParams.value)
  416. currentAnimation.value?.start()
  417. return
  418. }
  419. }
  420. const failed = () => {
  421. startAnimation('failed')
  422. }
  423. const play = () => {
  424. startAnimation(state.type)
  425. }
  426. const clear = () => {
  427. currentAnimation.value?.stop();
  428. ctx.value?.reset();
  429. ctx.value?.update();
  430. }
  431. const destroy = () => {
  432. clear();
  433. resizeObserver.disconnect();
  434. }
  435. watch(animationParams, () => {
  436. if (['clear', 'destroy'].includes(currentTick.value)) return
  437. startAnimation(state.type)
  438. })
  439. watchEffect(() => {
  440. if (ctx.value == null) return
  441. const tick = ticks.value.pop()
  442. if(tick != null) {
  443. currentTick.value = tick
  444. }
  445. if (tick == 'play') {
  446. play()
  447. return
  448. }
  449. if (tick == 'failed') {
  450. failed()
  451. return
  452. }
  453. if (tick == 'clear') {
  454. clear()
  455. return
  456. }
  457. if (tick == 'destroy') {
  458. destroy()
  459. return
  460. }
  461. })
  462. watch(element, (el : UniElement | null) => {
  463. if (el == null) return
  464. resizeObserver.observe(el);
  465. // #ifdef APP-IOS
  466. setTimeout(()=>{
  467. updateDimensions(el)
  468. },50)
  469. // #endif
  470. });
  471. onUnmounted(destroy);
  472. return state
  473. }