Files
kangkang/康康/Features/Diary/CompanionAvatarView.swift
link2026 198570186e 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
2026-07-14 13:07:15 +08:00

189 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}