// 引入颜色处理库 import { tinyColor } from '@/uni_modules/lime-color'; // ===================== 类型定义 ===================== /** * 加载动画类型 * circular: 环形加载动画 * spinner: 旋转器加载动画 * failed: 失败状态动画 */ export type LoadingType = 'circular' | 'spinner' | 'failed'; /** * 操作类型 * play: 开始动画 * failed: 显示失败状态 * clear: 清除动画 * destroy: 销毁实例 */ export type TickType = 'play' | 'failed' | 'clear' | 'destroy' /** * 加载组件配置选项 * @property type - 初始动画类型 * @property strokeColor - 线条颜色 * @property ratio - 尺寸比例 * @property immediate - 是否立即启动 */ export type UseLoadingOptions = { type : LoadingType; strokeColor : string; ratio : number; immediate ?: boolean; }; /** * 加载组件返回接口 */ export type UseLoadingReturn = { // state : Ref; // setOptions: (options: UseLoadingOptions) => void ratio : 1; type : LoadingType; color : string;//Ref; play : () => void; failed : () => void; clear : () => void; destroy : () => void; } /** * 画布尺寸信息 */ export type Dimensions = { width : number; height : number; size : number } /** * 线段坐标点 */ type Point = { x1 : number y1 : number x2 : number y2 : number } /** * 画布上下文信息 */ type LoadingCanvasContext = { ctx : Ref; dimensions : Ref; updateDimensions : (el : UniElement) => void; }; /** * 动画参数配置 */ type AnimationParams = { width : number height : number center : number[] // 元组类型,明确表示两个数值的坐标 color : string // 使用Ref类型包裹字符串 size : number // 数值类型尺寸 } // ===================== 动画管理器 ===================== type AnimationFrameHandler = () => boolean; /** * 动画管理类 * 封装动画的启动/停止逻辑 */ export class AnimationManager { time : number = 1000 / 60 // 默认帧率60fps private timer : number = -1;// 定时器ID private isDestroyed : boolean = false; // 销毁状态 private drawFrame : AnimationFrameHandler// 帧绘制函数 constructor(drawFrame : AnimationFrameHandler) { this.drawFrame = drawFrame } /** 启动动画循环 */ start() { let animate : (() => void) | null = null animate = () => { if (this.isDestroyed) return; const shouldContinue : boolean = this.drawFrame(); if (shouldContinue && animate != null) { this.timer = setTimeout(animate!, this.time); } }; animate(); } /** 停止动画并清理资源 */ stop() { clearTimeout(this.timer); this.isDestroyed = true; } } // ===================== 工具函数 ===================== /** * 缓动函数 - 三次缓入缓出 * @param t 时间系数 (0-1) * @returns 计算后的进度值 */ function easeInOutCubic(t : number) : number { return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; } // ===================== 画布管理 ===================== /** * 获取画布上下文信息 * @param element 画布元素引用 * @returns 包含画布上下文和尺寸信息的对象 */ //_element : Ref export function useCanvas() : LoadingCanvasContext { const ctx = shallowRef(null); const dimensions = ref({ width: 0, height: 0, size: 0 }); const updateDimensions = (el: UniElement) => { const rect = el.getBoundingClientRect(); ctx.value = el.getDrawableContext() as DrawableContext; dimensions.value.width = rect.width; dimensions.value.height = rect.height; dimensions.value.size = Math.min(rect.width, rect.height); }; return { ctx, dimensions, updateDimensions } as LoadingCanvasContext } // ===================== 动画创建函数 ===================== /** * 创建环形加载动画 * @param ctx 画布上下文 * @param animationParams 动画参数 * @returns 动画管理器实例 */ function createCircularAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager { const { size, color, width, height } = animationParams let startAngle = 0; // 起始角度 let endAngle = 0; // 结束角度 let rotate = 0; // 旋转角度 // 动画参数配置 const MIN_ANGLE = 5; // 最小保持角度 const ARC_LENGTH = 359.5 // 最大弧长(避免闭合) const PI = Math.PI / 180 // 角度转弧度系数 const SPEED = 0.018 // 动画速度 const ROTATE_INTERVAL = 0.09 // 旋转增量 const lineWidth = size / 10; // 线宽计算 const x = width / 2 // 中心点X const y = height / 2 // 中心点Y const radius = size / 2 - lineWidth // 实际绘制半径 /** 帧绘制函数 */ const drawFrame = () : boolean => { ctx.reset(); // 绘制圆弧 ctx.beginPath(); ctx.arc( x, y, radius, startAngle * PI + rotate, endAngle * PI + rotate ); ctx.lineWidth = lineWidth; ctx.strokeStyle = color; ctx.stroke(); // 角度更新逻辑 if (endAngle < ARC_LENGTH) { endAngle = Math.min(ARC_LENGTH, endAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED); } else if (startAngle < ARC_LENGTH) { startAngle = Math.min(ARC_LENGTH, startAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED); } else { // 重置时保留最小可见角度 startAngle = 0; endAngle = MIN_ANGLE; } rotate = (rotate + ROTATE_INTERVAL) % 360; // 持续旋转并限制范围 ctx.update() return true } return new AnimationManager(drawFrame) } /** * 创建旋转器动画 * @param ctx 画布上下文 * @param animationParams 动画参数 * @returns 动画管理器实例 */ function createSpinnerAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager { const { size, color, center } = animationParams const steps = 12; // 旋转线条数量 let step = 0; // 当前步数 const lineWidth = size / 10; // 线宽 const length = size / 4 - lineWidth; // 线长 const offset = size / 4; // 距中心偏移 const [x, y] = center // 中心坐标 /** 生成颜色渐变数组 */ function generateColorGradient(hex : string, steps : number) : string[] { const colors : string[] = [] const _color = tinyColor(hex) for (let i = 1; i <= steps; i++) { _color.setAlpha(i / steps); colors.push(_color.toRgbString()); } return colors } // 计算颜色渐变 let colors = computed(() : string[] => generateColorGradient(color, steps)) /** 帧绘制函数 */ const drawFrame = () : boolean => { ctx.reset(); for (let i = 0; i < steps; i++) { const stepAngle = 360 / steps; // 单步角度 const angle = stepAngle * i; // 当前角度 const index = (steps + i - (step % steps)) % steps // 颜色索引 // 计算线段坐标 const radian = angle * Math.PI / 180; const cos = Math.cos(radian); const sin = Math.sin(radian); // 绘制线段 ctx.beginPath(); ctx.moveTo(x + offset * cos, y + offset * sin); ctx.lineTo(x + (offset + length) * cos, y + (offset + length) * sin); ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.strokeStyle = colors.value[index]; ctx.stroke(); } step += 1 ctx.update() return true } return new AnimationManager(drawFrame) } /** * 计算圆周上指定角度的点的坐标 * @param centerX 圆心的 X 坐标 * @param centerY 圆心的 Y 坐标 * @param radius 圆的半径 * @param angleDegrees 角度(以度为单位) * @returns 包含 X 和 Y 坐标的对象 */ function getPointOnCircle( centerX : number, centerY : number, radius : number, angleDegrees : number ) : number[] { // 将角度转换为弧度 const angleRadians = (angleDegrees * Math.PI) / 180; // 计算点的 X 和 Y 坐标 const x = centerX + radius * Math.cos(angleRadians); const y = centerY + radius * Math.sin(angleRadians); return [x, y] } /** * 创建失败状态动画(包含X图标和外围圆圈) * @param ctx 画布上下文 * @param animationParams 动画参数 * @returns 动画管理器实例 */ function createFailedAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager { const { width, height, size, color } = animationParams const innerSize = size * 0.8 // 内圈尺寸 const lineWidth = innerSize / 10; // 线宽 const lineLength = (size - lineWidth) / 2 // X长度 const centerX = width / 2; const centerY = height / 2; const [startX1, startY] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 45) const [startX2] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 90 + 45) const angleRadians1 = 45 * Math.PI / 180 const angleRadians2 = (45 - 90) * Math.PI / 180 const radius = (size - lineWidth) / 2 const totalSteps = 36; // 总动画步数 function generateSteps(stepsCount : number) : Point[][] { const halfStepsCount = stepsCount / 2; const step = lineLength / halfStepsCount const steps : Point[][] = [] for (let i = 0; i < stepsCount; i++) { const sub : Point[] = [] const index = i % 18 + 1 if (i < halfStepsCount) { const x2 = Math.sin(angleRadians1) * step * index + startX1 const y2 = Math.cos(angleRadians1) * step * index + startY const start1 = { x1: startX1, y1: startY, x2, y2, } as Point sub.push(start1) } else { sub.push(steps[halfStepsCount - 1][0]) const x2 = Math.sin(angleRadians2) * step * index + startX2 const y2 = Math.cos(angleRadians2) * step * index + startY const start2 = { x1: startX2, y1: startY, x2, y2, } as Point sub.push(start2) } steps.push(sub) } return steps } const steps = generateSteps(totalSteps); const drawFrame = () : boolean => { const drawStep = steps.shift()! ctx.reset() ctx.lineWidth = lineWidth; ctx.strokeStyle = color; // 绘制逐渐显示的圆 ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, (2 * Math.PI) * (totalSteps - steps.length) / totalSteps); ctx.lineWidth = lineWidth; ctx.strokeStyle = color; ctx.stroke(); // 绘制X ctx.beginPath(); drawStep.forEach(item => { ctx.beginPath(); ctx.moveTo(item.x1, item.y1) ctx.lineTo(item.x2, item.y2) ctx.stroke(); }) ctx.update() return steps.length != 0 } return new AnimationManager(drawFrame) } // ===================== 主Hook函数 ===================== /** * 加载动画组合式函数 * @param element 画布元素引用 * @returns 加载控制器实例 */ export function useLoading( element : Ref, // options : UseLoadingOptions ) : UseLoadingReturn { const ticks = ref([]); const currentTick = ref('clear'); const state = reactive({ color: '#000', type: 'circular', ratio: 1, play: () => { ticks.value.length = 0 ticks.value.push('play') }, failed: () => { ticks.value.length = 0 ticks.value.push('failed') }, clear: () => { ticks.value.length = 0 ticks.value.push('clear') }, destroy: () => { ticks.value.length = 0 ticks.value.push('destroy') }, }) const { ctx, dimensions, updateDimensions } = useCanvas(); const resizeObserver : UniResizeObserver = new UniResizeObserver((_entries : UniResizeObserverEntry[])=>{ updateDimensions(element.value!) }); const currentAnimation = shallowRef(null); // 计算动画参数 const animationParams = computed(() : AnimationParams => { return { width: dimensions.value.width, height: dimensions.value.height, center: [dimensions.value.width / 2, dimensions.value.height / 2], color: state.color, size: state.ratio > 1 ? state.ratio : dimensions.value.size * state.ratio } as AnimationParams }) const startAnimation = (type : LoadingType) => { currentAnimation.value?.stop(); if (type == 'circular') { currentAnimation.value = createCircularAnimation(ctx.value!, animationParams.value) currentAnimation.value!.time = 1000 / 30 currentAnimation.value!.start() return } if (type == 'spinner') { currentAnimation.value = createSpinnerAnimation(ctx.value!, animationParams.value) currentAnimation.value!.time = 1000 / 10 currentAnimation.value!.start() return } if (type == 'failed') { currentAnimation.value = createFailedAnimation(ctx.value!, animationParams.value) currentAnimation.value?.start() return } } const failed = () => { startAnimation('failed') } const play = () => { startAnimation(state.type) } const clear = () => { currentAnimation.value?.stop(); ctx.value?.reset(); ctx.value?.update(); } const destroy = () => { clear(); resizeObserver.disconnect(); } watch(animationParams, () => { if (['clear', 'destroy'].includes(currentTick.value)) return startAnimation(state.type) }) watchEffect(() => { if (ctx.value == null) return const tick = ticks.value.pop() if(tick != null) { currentTick.value = tick } if (tick == 'play') { play() return } if (tick == 'failed') { failed() return } if (tick == 'clear') { clear() return } if (tick == 'destroy') { destroy() return } }) watch(element, (el : UniElement | null) => { if (el == null) return resizeObserver.observe(el); // #ifdef APP-IOS setTimeout(()=>{ updateDimensions(el) },50) // #endif }); onUnmounted(destroy); return state }