昨天搞定了基礎組件和 GrimoColors 設計系統。
但總覺得少了什麼。
載入畫面有背景圖、有魔法書,但還是太靜態。我開始搜尋「magical loading animation」、「breathing effect UI」,想找靈感。
找到一些有趣的概念——動畫不該只是旋轉,而是要有生命感。
看了幾個參考後,我發現傳統的 CircularProgressIndicator 真的太機械了。
「幫我研究一下呼吸效果要怎麼做。」我把找到的參考影片給 AI 看。
AI 分析了影片:「這些動畫都有膨脹收縮的節奏,透明度也會跟著變化。重點是時間要錯開,不要所有東西同步。」
有道理。我決定試試看。
@Composable
fun MagicBreathingIndicator() {
val infiniteTransition = rememberInfiniteTransition()
// 旋轉動畫 - 試了幾個數值,1600ms 看起來比較順
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1600,
easing = LinearEasing
)
)
)
// 呼吸效果 - 透明度變化
val breath by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 2000,
easing = CubicBezierEasing(0.4f, 0.0f, 0.6f, 1.0f)
),
repeatMode = RepeatMode.Reverse
)
)
// 外圈光暈
val glowRadius by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 8f,
animationSpec = infiniteRepeatable(
animation = tween(3000),
repeatMode = RepeatMode.Reverse
)
)
Canvas(modifier = Modifier.size(48.dp)) {
// 主圈
drawCircle(
color = GrimoColors.GlacierCyan.copy(alpha = breath),
radius = size.minDimension / 2,
style = Stroke(width = 2.dp.toPx())
)
// 金色光暈點綴
drawCircle(
color = Color(0xFFC6A563).copy(alpha = breath * 0.3f), // 金色
radius = size.minDimension / 2 + glowRadius
)
}
}
第一次跑起來,效果還不錯。但總覺得太規律了。
我問 AI:「有沒有辦法讓動畫看起來更自然一點?」
「試試不同元素用不同速度,」AI 建議,「比如旋轉 1.6 秒,呼吸 2 秒,光暈 3 秒。這樣它們不會同時到達起點。」
確實,錯開節奏後感覺活了起來。
背景圖片雖然是夜空,但沒有動態感。
我想起芙莉蓮的片頭常有流星劃過的畫面。那種偶爾出現、一閃而過的感覺很棒。
「我想加流星效果,」我跟 AI 說,「像芙莉蓮片頭那樣,偶爾劃過就好。」
「流星要考慮幾個要素,」AI 幫我整理:「軌跡、速度、頻率、透明度變化。」
開始寫程式碼:
@Composable
fun ShootingStars() {
var meteors by remember { mutableStateOf(listOf<Meteor>()) }
LaunchedEffect(Unit) {
while (true) {
// 一次生成 1-4 顆
val count = (1..4).random()
// 錯開生成時間,不要同時出現
repeat(count) {
launch {
delay(Random.nextLong(0, 600))
meteors = meteors + createRandomMeteor()
}
}
// 等待下一波,2-10 秒隨機
delay(Random.nextLong(2000, 10000))
}
}
}
第一版流星太直了,看起來像畫線。
我找了一些流星的參考影片,發現真實的流星有漸變的尾巴。
「怎麼畫出流星的尾巴效果?」我問 AI。
「用漸層,」AI 回答,「從流星頭部的白色漸變到透明。」
private fun DrawScope.drawMeteor(meteor: Meteor) {
val progress = meteor.progress.coerceIn(0f, 1f)
// 算出當前位置
val currentX = lerp(meteor.startX, meteor.endX, progress)
val currentY = lerp(meteor.startY, meteor.endY, progress)
// 尾巴要在反方向
val tailLength = 50f
val tailX = currentX - normalizedX * tailLength
val tailY = currentY - normalizedY * tailLength
// 頭尾淡入淡出
val alpha = when {
progress < 0.1f -> progress * 10f // 剛開始淡入
progress > 0.8f -> (1f - progress) * 5f // 快結束淡出
else -> 1f
}
// 畫出漸層軌跡
drawLine(
brush = Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = alpha * 0.9f),
Color.Transparent
),
start = Offset(currentX, currentY),
end = Offset(tailX, tailY)
),
strokeWidth = 3f,
cap = StrokeCap.Round
)
}
跑起來後,效果好多了。偶爾劃過的流星讓背景活了起來。
昨天的按鈕有了,但想加點特別的 hover 效果。
在 CodePen 上看到一個星光閃爍的效果,很符合魔法主題。
「幫我看看這個星光效果的實現原理。」我把連結給 AI。
AI 分析:「重點是隨機生成、生命週期管理,還有透明度的淡入淡出。」
我決定建立一個 SparkleData 類別來管理每個星光:
data class SparkleData(
val x: Float,
val y: Float,
val size: Float,
val rotation: Float,
val lifeTime: Float
) {
companion object {
fun random(sizeRange: ClosedFloatingPointRange<Float>) = SparkleData(
x = Random.nextFloat() * 0.9f + 0.05f,
y = Random.nextFloat() * 0.9f + 0.05f,
size = Random.nextFloat() * (sizeRange.endInclusive - sizeRange.start) + sizeRange.start,
rotation = Random.nextFloat() * 360f,
lifeTime = 0f
)
}
fun update(maxLifetime: Float): SparkleData? {
val newLifeTime = lifeTime + 0.015f
val newRotation = rotation + 2f // 緩慢旋轉
return if (newLifeTime < maxLifetime) {
copy(lifeTime = newLifeTime, rotation = newRotation)
} else null
}
fun calculateAlpha(): Float = when {
lifeTime < 0.15f -> lifeTime / 0.15f // 淡入
lifeTime > 0.6f -> (1f - lifeTime) / 0.4f // 淡出
else -> 1f
}
}
然後實作星光效果的 Modifier:
fun Modifier.sparkleEffect(
enabled: Boolean = true,
sparkleColor: Color = Color(0xFFFECA57) // 金色
): Modifier = composed {
var sparkles by remember { mutableStateOf(listOf<SparkleData>()) }
// 生成星光
LaunchedEffect(enabled) {
if (enabled) {
// 立即生成初始星光
repeat(2) {
delay(100)
val newSparkle = SparkleData.random(3f..6f)
sparkles = (sparkles + newSparkle).takeLast(4)
}
// 持續生成新星光
while (enabled) {
val newSparkle = SparkleData.random(3f..6f)
sparkles = (sparkles + newSparkle).takeLast(4)
delay(Random.nextLong(300L, 800L))
}
}
}
// 繪製星光
drawWithContent {
drawContent() // 先繪製原始內容
sparkles.forEach { sparkle ->
drawSparkle(sparkle, sparkleColor)
}
}
}
光有 hover 不夠,按下去也要有反應。
記得芙莉蓮施法時會有魔法陣展開的效果嗎?我想做類似的。
「按鈕按下時,能不能有圓圈展開的效果?」我問 AI。
「可以用動畫控制半徑和旋轉,」AI 建議,「按下時展開,放開時收回。」
開始實作:
@Composable
fun Modifier.expandOnPress(
isPressed: Boolean,
color: Color = Color(0xFF9C88FF)
): Modifier {
var progress by remember { mutableStateOf(0f) }
LaunchedEffect(isPressed) {
if (isPressed) {
// 按下時展開
while (progress < 1f) {
progress = (progress + 0.05f).coerceAtMost(1f)
delay(16)
}
} else {
// 放開時收回
while (progress > 0f) {
progress = (progress - 0.05f).coerceAtLeast(0f)
delay(16)
}
}
}
return drawBehind {
val radius = size.minDimension / 2 * progress
val rotation = progress * 180f
rotate(rotation) {
// 畫兩個圈,營造層次感
drawCircle(
color = color.copy(alpha = progress * 0.3f),
radius = radius * 1.2f,
style = Stroke(width = 1.dp.toPx())
)
drawCircle(
color = color.copy(alpha = progress * 0.4f),
radius = radius * 0.6f,
style = Stroke(width = 1.dp.toPx())
)
}
}
}
效果還不錯,按下時有魔法陣展開的感覺。
在測試 sweepGradient 時,意外發現可以做出彩虹效果。
雖然跟 Liquid Arcana 的設計語言不太搭,但效果很酷,決定留著當彩蛋。
「sweepGradient 能做旋轉的彩虹圈嗎?」我問 AI。
「可以,把彩虹色放進去,然後旋轉整個圈就行。」
試試看:
@Composable
fun Modifier.rainbowRing(isHovered: Boolean): Modifier {
if (!isHovered) return this
val rotation by rememberInfiniteTransition().animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(3000, easing = LinearEasing)
)
)
val rainbowColors = listOf(
Color.Red, Color(0xFFFF7F00), Color.Yellow,
Color.Green, Color.Blue, Color(0xFF4B0082),
Color(0xFF9400D3), Color.Red // 頭尾接起來
)
return drawBehind {
rotate(rotation) {
drawCircle(
brush = Brush.sweepGradient(rainbowColors),
radius = size.minDimension / 2 + 10.dp.toPx(),
style = Stroke(width = 3.dp.toPx()),
alpha = 0.7f,
blendMode = BlendMode.Plus
)
}
}
}
效果太炫了!雖然最後沒用在正式版,但學到了新技巧。
第一版動畫跑起來有點卡。
「為什麼動畫會卡?」我問 AI。
AI 幫我分析:「可能是觸發了重組。試試用 graphicsLayer。」
原來 Modifier.offset 會觸發重組,改用 graphicsLayer 就順多了:
// 卡頓版本
Box(modifier = Modifier.offset(y = animatedValue.dp))
// 流暢版本
Box(modifier = Modifier.graphicsLayer {
translationY = animatedValue
})
粒子效果很酷,但生成太多會卡。
限制最大數量和控制幀率:
LaunchedEffect(particles) {
while (particles.isNotEmpty()) {
updateParticles()
delay(16) // 控制在 60 FPS
}
}
測試了不同的混合模式:
// Plus:讓光點更亮
drawCircle(
color = glowColor,
blendMode = BlendMode.Plus
)
// Screen:柔和一點的效果
drawCircle(
color = softGlow,
blendMode = BlendMode.Screen
)
Plus 效果比較魔法感,最後選了它。
試了很多不同的時長,最後整理出這套(延續昨天的 GrimoMotion):
object GrimoMotion {
const val QUICK_DURATION = 150 // hover 反饋
const val STANDARD_DURATION = 300 // 一般過渡
const val GENTLE_DURATION = 500 // 展開收合
const val BREATH_DURATION = 2000 // 呼吸循環
}
太快會突兀,太慢會拖沓。這些數值是反覆調整後的平衡點。
不同狀態用不同動畫,避免混亂:
data class InteractionState(
val isHovered: Boolean = false,
val isPressed: Boolean = false,
val isFocused: Boolean = false
) {
fun selectAnimation() = when {
isPressed -> AnimationType.EXPAND // 按下展開
isHovered -> AnimationType.PARTICLES // 懸停粒子
isFocused -> AnimationType.PULSE // 焦點脈動
else -> AnimationType.NONE
}
}
把效果模組化,方便組合使用:
@Composable
fun Modifier.interactiveAnimations(
state: InteractionState
): Modifier = this
.then(if (state.isHovered) levitation() else this)
.then(if (state.isPressed) expand() else this)
.then(if (state.isFocused) pulse() else this)
一開始什麼效果都想加。
後來發現太花俏反而分散注意力。最後保留呼吸效果和流星,其他都拿掉了。
專注做好幾個核心動畫,比什麼都想要來得好。
粒子往上飄時,加了一點左右擺動:
// 不只是往上
val newY = particle.y - particle.speed * 0.03f
// 加點擺動更自然
val newX = particle.x + (Random.nextFloat() - 0.5f) * 0.005f
這種小細節用戶可能說不出來,但會覺得「感覺比較順」。
完全隨機太亂,完全固定太死板:
// 有範圍的隨機
val offset = Random.nextFloat() * 0.2f + 0.9f // 0.9-1.1 之間
val delay = Random.nextLong(100, 300) // 100-300ms 之間
控制在一個範圍內的隨機,既有變化又不失控。
動畫太快看不清楚時,我會把速度調慢:
// 把動畫放慢 10 倍
animationSpec = tween(
durationMillis = 2000 * 10, // 暫時放慢
easing = LinearEasing
)
看清楚問題後再調回正常速度。
粒子路徑不對時,先畫出來看看:
// 暫時畫出所有粒子的路徑
Canvas(Modifier.fillMaxSize()) {
particles.forEach { particle ->
drawCircle(
color = Color.Red.copy(alpha = 0.5f),
radius = 5f,
center = Offset(particle.x * size.width, particle.y * size.height)
)
}
}
視覺化幫助很大,一眼就能看出問題。
延續昨天的 UI 開發,今天深入到動畫系統。
從呼吸效果、流星軌跡、粒子系統到魔法陣展開,每個動畫都經過無數次調整。最耗時的不是寫程式碼,而是調參數——1600ms 還是 2000ms?透明度 0.3 還是 0.5?
動畫設計真的是細節的藝術。
AI 在這個過程中幫了很多忙,特別是分析參考資料和解釋原理。但最終的美感判斷——這個效果「對不對」,還是要靠自己的眼睛。
明天會繼續完善其他組件,讓整個 Grimo 的介面更有一致性。
「動畫是時間的雕塑,每一幀都在述說故事。」
關於作者:Sam,一人公司創辦人。正在打造 Grimo,智能任務管理和分配平台。
專案連結:GitHub - grimostudio