189 lines
8.9 KiB
Swift
189 lines
8.9 KiB
Swift
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)
|
||
}
|