import SwiftUI import Foundation /// 「聊着记」里的动画小形象「康康」——纯 SwiftUI Shape 拼出的圆脸小生物,零图片资源、零第三方库。 /// 全屏语音记录界面用它给「正在听 / 正在想 / 正在说」这几个抽象状态一个有生命感、可爱的落点。 /// /// 为什么所有连续动画都由「时间 t」纯函数驱动(而非 @State + withAnimation.repeatForever): /// 参照本目录外 `DesignSystem/AIFlowBar.swift` 的先例——这个形象所处的场景(语音流式吐字、每 0.5s /// 刷 tok/s)父视图在高频重绘,隐式 repeatForever 动画会被反复打断/重置 → 看起来「几乎不动」。 /// 改用 `TimelineView(.animation)` 按屏幕刷新率直接从时间算出每一帧的形变,与父视图重绘彻底解耦, /// 任何场景下都匀速呼吸/眨眼/说话。 /// /// 呼吸缩放、眨眼、嘴形、思考点这四种连续形变全部抽成 `nonisolated static` 纯函数(只依赖 t 与 mood, /// 无任何共享状态),既方便单测逐帧断言,也让 body 只负责把算好的数值贴到 Shape 上。mood 之间的离散 /// 差异(腮红透明度、眼睛上移、声波环有无)则用 `.animation(.snappy, value: mood)` 平滑过渡。 struct CompanionAvatarView: View { /// nonisolated:Equatable 合成实现要在 nonisolated static 纯函数里比较 mood, /// 不标会被默认 MainActor 隔离推成主 actor 隔离(Swift 6 报错)。 nonisolated enum Mood: Equatable { case idle, listening, thinking, speaking } var mood: Mood var size: CGFloat = 156 var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in let t = timeline.date.timeIntervalSinceReferenceDate ZStack { // 1 背景光环:最底层的一圈暖沙色,给小家伙一个「站台」 Circle() .fill(Tj.Palette.sand2) .frame(width: size, height: size) // 2 倾听声波环 ×2:仅 .listening 出现,由内向外扩散淡出,第二环相位 +0.8s 错开 if mood == .listening { Group { soundRing(t: t, phase: 0, color: Tj.Palette.teal) soundRing(t: t, phase: 0.8, color: Tj.Palette.tealSoft) } .transition(.opacity) } // 3-6 脸部整体(身体 + 腮红 + 眼 + 嘴):作为一个整体一起呼吸、一起歪头 face(t: t) .frame(width: size, height: size) .scaleEffect(Self.breathScale(t: t, mood: mood), anchor: .bottom) .rotationEffect(.degrees(headTilt(t: t)), anchor: .bottom) // 7 思考点 ×3:仅 .thinking 出现,头顶右上斜排,循环点亮 if mood == .thinking { thinkingDots(t: t) .transition(.opacity) } } .frame(width: size, height: size) .animation(.snappy(duration: 0.25), value: mood) } } // MARK: - 子视图(实例方法,默认 MainActor;只把纯函数算出的数值贴到 Shape 上) /// 脸:身体 Ellipse 打底,再叠腮红 / 眼睛 / 嘴。整组由外层统一做呼吸缩放与歪头旋转。 private func face(t: Double) -> some View { let blushOpacity: Double = (mood == .listening || mood == .speaking) ? 1.0 : 0.7 let eyeLift: CGFloat = (mood == .thinking) ? -0.02 * size : 0 // 思考时眼睛上移「往上看」 let mouthWidth: CGFloat = (mood == .thinking) ? 0.05 * size : 0.16 * size return ZStack { // 身体:柔和纸色椭圆 + 细描边 + 极浅暖影 Ellipse() .fill(Tj.Palette.paper) .frame(width: 0.78 * size, height: 0.72 * size) .overlay(Ellipse().strokeBorder(Tj.Palette.line, lineWidth: 1.5)) .shadow(color: Tj.Palette.shadow.opacity(0.08), radius: 0.05 * size, x: 0, y: 0.018 * size) // 腮红 ×2 ForEach([-1.0, 1.0], id: \.self) { side in Circle() .fill(Tj.Palette.brickSoft) .frame(width: 0.09 * size, height: 0.09 * size) .opacity(blushOpacity) .offset(x: CGFloat(side) * 0.24 * size, y: 0.075 * size) } // 眼睛 ×2:竖长胶囊,眨眼 = 竖向压扁;.thinking 时整体上移 ForEach([-1.0, 1.0], id: \.self) { side in Capsule() .fill(Tj.Palette.ink) .frame(width: 0.055 * size, height: 0.11 * size) .scaleEffect(x: 1, y: Self.blinkScaleY(t: t, mood: mood)) .offset(x: CGFloat(side) * 0.155 * size, y: -0.03 * size + eyeLift) } // 嘴:横胶囊,高度随状态开合;.thinking 时收成 0.05·size 小圆点 Capsule() .fill(Tj.Palette.ink) .frame(width: mouthWidth, height: Self.mouthHeight(t: t, mood: mood, size: size)) .offset(y: 0.135 * size) } } /// 单个声波环:base 直径 0.82·size,随相位小数部分从 ×1 放大到 ×1.35 并线性淡出。 private func soundRing(t: Double, phase: Double, color: Color) -> some View { let f = Self.frac((t + phase) / 1.6) return Circle() .strokeBorder(color, lineWidth: 2) .frame(width: 0.82 * size, height: 0.82 * size) .scaleEffect(1 + 0.35 * CGFloat(f)) .opacity(1 - f) } /// 思考点 ×3:直径 0.05·size,沿右上 45° 斜线排开,逐个点亮成「思考中」气泡。 private func thinkingDots(t: Double) -> some View { ZStack { ForEach(0..<3, id: \.self) { i in Circle() .fill(Tj.Palette.amber) .frame(width: 0.05 * size, height: 0.05 * size) .opacity(Self.thinkingDotOpacity(t: t, index: i)) .offset(x: (0.29 + 0.085 * CGFloat(i)) * size, y: -(0.29 + 0.085 * CGFloat(i)) * size) } } } /// 歪头角度(度):仅 .thinking 时以 2.6s 为周期在 ±2° 间轻摆,其余恒 0。 private func headTilt(t: Double) -> Double { mood == .thinking ? 2 * sin(2 * .pi * t / 2.6) : 0 } // MARK: - 纯函数(nonisolated,供单测逐帧断言;只依赖 t 与 mood,无共享状态) /// 呼吸缩放:各状态不同周期/幅度的正弦。idle 最慢最柔,speaking 最快最促。 nonisolated static func breathScale(t: Double, mood: Mood) -> CGFloat { switch mood { case .idle: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 3.2)) case .listening: return CGFloat(1 + 0.03 * sin(2 * .pi * t / 1.6)) case .thinking: return CGFloat(1 + 0.015 * sin(2 * .pi * t / 2.6)) case .speaking: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 0.6)) } } /// 眨眼竖向缩放:约每 3.4s 眨一次(压到 0.15 持续 0.12s),.thinking 恒 0.85 凝神不眨。 nonisolated static func blinkScaleY(t: Double, mood: Mood) -> CGFloat { if mood == .thinking { return 0.85 } var phase = t.truncatingRemainder(dividingBy: 3.4) if phase < 0 { phase += 3.4 } // 兜住负 t,让单测传任意时刻都成立 return phase <= 0.12 ? 0.15 : 1 } /// 嘴高度:idle 微笑一条线,listening/thinking 微张,speaking 随 0.28s 周期开合。 nonisolated static func mouthHeight(t: Double, mood: Mood, size: CGFloat) -> CGFloat { switch mood { case .idle: return 0.035 * size case .listening: return 0.05 * size case .thinking: return 0.05 * size case .speaking: return 0.03 * size + 0.05 * size * CGFloat(abs(sin(2 * .pi * t / 0.28))) } } /// 思考点透明度:三点各错相位 120° 的正弦,负半周压到 0,形成「跑马灯」逐个点亮。 nonisolated static func thinkingDotOpacity(t: Double, index: Int) -> Double { 0.25 + 0.75 * max(0.0, sin(2 * .pi * t / 0.9 - Double(index) * 2 * .pi / 3)) } /// 取小数部分(声波环相位用)。 private static func frac(_ x: Double) -> Double { x - floor(x) } } #Preview("四种情绪") { HStack(spacing: 18) { ForEach(0..<4, id: \.self) { i in VStack(spacing: 12) { CompanionAvatarView( mood: [.idle, .listening, .thinking, .speaking][i], size: 116 ) Text(["idle", "listening", "thinking", "speaking"][i]) .font(.tjScaled(12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) } } } .padding(36) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Tj.Palette.sand) }