云链智安app
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

index.uts 13KB

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