| 1 | /** |
| 2 | * MorphingIcon - 循环切换图标动画组件 |
| 3 | * 图标切换时有描边绘制动画(pathLength),整体带呼吸闪烁效果 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type { Variants } from 'framer-motion' |
| 9 | import { AnimatePresence, motion } from 'framer-motion' |
| 10 | import { memo, useEffect, useId, useState } from 'react' |
| 11 | |
| 12 | import { cn } from '@/utils/className' |
| 13 | |
| 14 | // 图标 SVG 路径数据(从 Lucide 提取) |
| 15 | const ICONS = [ |
| 16 | { |
| 17 | name: 'sparkles', |
| 18 | paths: [ |
| 19 | 'M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z', |
| 20 | 'M20 2v4', |
| 21 | 'M22 4h-4', |
| 22 | ], |
| 23 | circles: [{ cx: 4, cy: 20, r: 2, isSmall: true }], |
| 24 | }, |
| 25 | { |
| 26 | name: 'brain', |
| 27 | paths: [ |
| 28 | 'M12 5v13', |
| 29 | 'M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4', |
| 30 | 'M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5', |
| 31 | 'M17.997 5.125a4 4 0 0 1 2.526 5.77', |
| 32 | 'M18 18a4 4 0 0 0 2-7.464', |
| 33 | 'M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517', |
| 34 | 'M6 18a4 4 0 0 1-2-7.464', |
| 35 | 'M6.003 5.125a4 4 0 0 0-2.526 5.77', |
| 36 | ], |
| 37 | }, |
| 38 | { |
| 39 | name: 'rocket', |
| 40 | paths: [ |
| 41 | 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z', |
| 42 | 'm12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z', |
| 43 | 'M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0', |
| 44 | 'M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5', |
| 45 | ], |
| 46 | circles: [{ cx: 10, cy: 14, r: 2, isSmall: true }], |
| 47 | }, |
| 48 | { |
| 49 | name: 'target', |
| 50 | circles: [ |
| 51 | { cx: 12, cy: 12, r: 10 }, |
| 52 | { cx: 12, cy: 12, r: 6 }, |
| 53 | { cx: 12, cy: 12, r: 2 }, |
| 54 | ], |
| 55 | }, |
| 56 | ] |
| 57 | |
| 58 | // 描边绘制动画变体(带交错延迟) |
| 59 | function createDrawVariants(index: number): Variants { |
| 60 | return { |
| 61 | hidden: { |
| 62 | pathLength: 0, |
| 63 | opacity: 1, // 立即可见,避免闪烁 |
| 64 | }, |
| 65 | visible: { |
| 66 | pathLength: 1, |
| 67 | opacity: 1, |
| 68 | transition: { |
| 69 | pathLength: { duration: 0.25, ease: 'easeOut', delay: index * 0.03 }, |
| 70 | }, |
| 71 | }, |
| 72 | exit: { |
| 73 | opacity: 0, |
| 74 | transition: { delay: 0.1, duration: 0.1 }, // 延迟淡出,避免白色闪烁 |
| 75 | }, |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // 小圆点 scale 动画变体 |
| 80 | const smallCircleVariants: Variants = { |
| 81 | hidden: { scale: 0, opacity: 1 }, |
| 82 | visible: { |
| 83 | scale: 1, |
| 84 | opacity: 1, |
| 85 | transition: { type: 'spring', stiffness: 400, damping: 20, delay: 0.05 }, |
| 86 | }, |
| 87 | exit: { opacity: 0, transition: { delay: 0.1, duration: 0.1 } }, // 延迟淡出 |
| 88 | } |
| 89 | |
| 90 | // 大圆(如 target 图标)使用 pathLength 动画 |
| 91 | function createCircleDrawVariants(index: number): Variants { |
| 92 | return { |
| 93 | hidden: { |
| 94 | pathLength: 0, |
| 95 | opacity: 1, |
| 96 | }, |
| 97 | visible: { |
| 98 | pathLength: 1, |
| 99 | opacity: 1, |
| 100 | transition: { |
| 101 | pathLength: { duration: 0.25, ease: 'easeOut', delay: index * 0.05 }, |
| 102 | }, |
| 103 | }, |
| 104 | exit: { |
| 105 | opacity: 0, |
| 106 | transition: { delay: 0.1, duration: 0.1 }, // 延迟淡出 |
| 107 | }, |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | interface MorphingIconProps { |
| 112 | size?: number |
| 113 | color?: string |
| 114 | className?: string |
| 115 | } |
| 116 | |
| 117 | // 切换间隔:1.5 秒 |
| 118 | const SWITCH_INTERVAL = 1500 |
| 119 | |
| 120 | export const MorphingIcon = memo(({ size = 16, color, className }: MorphingIconProps) => { |
| 121 | const rawGradientId = useId() |
| 122 | const gradientId = `morphing-icon-gradient-${rawGradientId.replace(/:/g, '')}` |
| 123 | const iconPaint = color || `url(#${gradientId})` |
| 124 | const [currentIndex, setCurrentIndex] = useState(0) |
| 125 | |
| 126 | useEffect(() => { |
| 127 | const timer = setInterval(() => { |
| 128 | setCurrentIndex(prev => (prev + 1) % ICONS.length) |
| 129 | }, SWITCH_INTERVAL) |
| 130 | return () => clearInterval(timer) |
| 131 | }, []) |
| 132 | |
| 133 | const currentIcon = ICONS[currentIndex] |
| 134 | |
| 135 | return ( |
| 136 | <div |
| 137 | className={cn('relative inline-flex items-center justify-center', className)} |
| 138 | style={{ width: size, height: size }} |
| 139 | > |
| 140 | {/* 呼吸闪烁动画容器 */} |
| 141 | <motion.div |
| 142 | className="absolute inset-0" |
| 143 | animate={{ |
| 144 | opacity: [0.5, 1, 0.5], |
| 145 | }} |
| 146 | transition={{ |
| 147 | opacity: { |
| 148 | duration: 2, |
| 149 | repeat: Infinity, |
| 150 | ease: 'easeInOut', |
| 151 | }, |
| 152 | }} |
| 153 | > |
| 154 | <motion.svg |
| 155 | viewBox="0 0 24 24" |
| 156 | fill="none" |
| 157 | stroke={iconPaint} |
| 158 | strokeWidth={2} |
| 159 | strokeLinecap="round" |
| 160 | strokeLinejoin="round" |
| 161 | className="absolute inset-0" |
| 162 | style={{ width: size, height: size }} |
| 163 | > |
| 164 | {!color && ( |
| 165 | <defs> |
| 166 | <linearGradient id={gradientId} x1="2" y1="12" x2="22" y2="12" gradientUnits="userSpaceOnUse"> |
| 167 | <stop stopColor="var(--brand-purple)" /> |
| 168 | <stop offset="1" stopColor="var(--brand-cyan)" /> |
| 169 | </linearGradient> |
| 170 | </defs> |
| 171 | )} |
| 172 | |
| 173 | {/* 不使用 mode="wait",新旧图标重叠过渡避免闪烁 */} |
| 174 | <AnimatePresence> |
| 175 | <motion.g key={currentIcon.name} initial="hidden" animate="visible" exit="exit"> |
| 176 | {/* 路径元素 - 描边绘制动画 */} |
| 177 | {currentIcon.paths?.map((d, i) => ( |
| 178 | <motion.path |
| 179 | key={`path-${i}`} |
| 180 | d={d} |
| 181 | fill="none" |
| 182 | variants={createDrawVariants(i)} |
| 183 | /> |
| 184 | ))} |
| 185 | |
| 186 | {/* 圆形元素 */} |
| 187 | {currentIcon.circles?.map((c, i) => { |
| 188 | const isSmall = 'isSmall' in c && c.isSmall |
| 189 | return isSmall ? ( |
| 190 | // 小圆点使用 scale 动画 |
| 191 | <motion.circle |
| 192 | key={`circle-${i}`} |
| 193 | cx={c.cx} |
| 194 | cy={c.cy} |
| 195 | r={c.r} |
| 196 | fill={iconPaint} |
| 197 | stroke="none" |
| 198 | variants={smallCircleVariants} |
| 199 | /> |
| 200 | ) : ( |
| 201 | // 大圆使用 pathLength 描边动画 |
| 202 | <motion.circle |
| 203 | key={`circle-${i}`} |
| 204 | cx={c.cx} |
| 205 | cy={c.cy} |
| 206 | r={c.r} |
| 207 | fill="none" |
| 208 | variants={createCircleDrawVariants(i)} |
| 209 | /> |
| 210 | ) |
| 211 | })} |
| 212 | </motion.g> |
| 213 | </AnimatePresence> |
| 214 | </motion.svg> |
| 215 | </motion.div> |
| 216 | </div> |
| 217 | ) |
| 218 | }) |
| 219 | |
| 220 | MorphingIcon.displayName = 'MorphingIcon' |
| 221 |