iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
生成式 AI

30 天一人公司的 AI 開發實戰系列 第 23

Day 23: UI 設計師做特效:從星光到魔法陣的載入動畫旅程

  • 分享至 

  • xImage
  •  

前言:從靜態到動態的探索

昨天搞定了基礎組件和 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 秒。這樣它們不會同時到達起點。」

確實,錯開節奏後感覺活了起來。

第二站:背景需要流星

從 Frieren 找靈感

背景圖片雖然是夜空,但沒有動態感。

我想起芙莉蓮的片頭常有流星劃過的畫面。那種偶爾出現、一閃而過的感覺很棒。

「我想加流星效果,」我跟 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
    )
}

跑起來後,效果好多了。偶爾劃過的流星讓背景活了起來。

第三站:星光效果的實作

從 CodePen 找靈感

昨天的按鈕有了,但想加點特別的 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
            )
        }
    }
}

效果太炫了!雖然最後沒用在正式版,但學到了新技巧。

效能踩坑與優化

1. 動畫卡頓問題

第一版動畫跑起來有點卡。

「為什麼動畫會卡?」我問 AI。

AI 幫我分析:「可能是觸發了重組。試試用 graphicsLayer。」

原來 Modifier.offset 會觸發重組,改用 graphicsLayer 就順多了:

// 卡頓版本
Box(modifier = Modifier.offset(y = animatedValue.dp))

// 流暢版本
Box(modifier = Modifier.graphicsLayer {
    translationY = animatedValue
})

2. 粒子太多的問題

粒子效果很酷,但生成太多會卡。

限制最大數量和控制幀率:

LaunchedEffect(particles) {
    while (particles.isNotEmpty()) {
        updateParticles()
        delay(16)  // 控制在 60 FPS
    }
}

3. BlendMode 的選擇

測試了不同的混合模式:

// 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)

實戰心得

1. 少即是多

一開始什麼效果都想加。

後來發現太花俏反而分散注意力。最後保留呼吸效果和流星,其他都拿掉了。

專注做好幾個核心動畫,比什麼都想要來得好。

2. 細節決定質感

粒子往上飄時,加了一點左右擺動:

// 不只是往上
val newY = particle.y - particle.speed * 0.03f
// 加點擺動更自然
val newX = particle.x + (Random.nextFloat() - 0.5f) * 0.005f

這種小細節用戶可能說不出來,但會覺得「感覺比較順」。

3. 隨機的藝術

完全隨機太亂,完全固定太死板:

// 有範圍的隨機
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


上一篇
Day 22: UI 設計師出手:打造現代化的 Desktop UI 組件
下一篇
Day 24: 架構師切分邊界:shared vs desktopApp 的架構重構
系列文
30 天一人公司的 AI 開發實戰24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言