iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
生成式 AI

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

Day 22: UI 設計師出手:打造現代化的 Desktop UI 組件

  • 分享至 

  • xImage
  •  

前言:從設計稿到程式碼

Day 04 討論過設計系統,但一直停在概念階段。

今天要動手了。

「設計系統建立了,但怎麼轉成程式碼?」我看著之前的筆記發愁。

Desktop 設計的思考

搜尋 Desktop App 的設計原則

我開始搜尋「desktop app design principles」、「macOS app layout best practices」。

找到幾個重點:

  • Desktop 用戶有大螢幕,可以展示更多資訊
  • 滑鼠精準,可以有更細緻的互動
  • 用戶會長時間使用,視覺不能太累

「幫我分析一下 Desktop 和 Mobile 的差異。」我把搜尋結果給 AI。

AI 整理了一個對比:

場景 Mobile Desktop
螢幕 5-7 吋,觸控優先 13-32 吋,滑鼠精準
操作 手指點擊,區域大 滑鼠 hover,精準互動
使用時長 碎片化,快速瀏覽 長時間專注工作

確實,Desktop 的設計思維完全不同。

實作第一個組件:載入畫面

尋找靈感

載入畫面是用戶第一眼看到的。我想要有魔法感,但不知道怎麼做。

翻了翻 Dribbble,看到一些有趣的載入動畫。大多是旋轉的圓圈,太普通了。

「我想要魔法書的感覺,」我跟 AI 說,「有沒有什麼建議?」

「可以分層設計,」AI 建議:

  1. 背景層 - 夜空或魔法陣
  2. 中間層 - 魔法書
  3. 前景層 - 動畫元素

聽起來不錯。開始寫程式碼:

@Composable
fun LoadingScreen(
    loadingState: LoadingState,
    onLoadingComplete: () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(modifier = modifier.fillMaxSize()) {
        // 背景圖片
        Image(
            painter = painterResource(Res.drawable.night_city_skyline_with_shooting_stars),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize(),
            alpha = 0.8f
        )
        
        // 半透明遮罩
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(GrimoColors.MorningFog.copy(alpha = 0.15f))
        )
        
        // 載入內容
        Column(
            modifier = Modifier.align(Alignment.Center),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            MagicalLoadingAnimation()
            
            Spacer(modifier = Modifier.height(48.dp))
            
            Text(
                text = loadingState.message,
                style = MaterialTheme.typography.bodyLarge
            )
        }
    }
}

調整魔法動畫

第一版太單調,只有旋轉。

我想加點特別的——魔法書和羽毛筆。

@Composable
private fun MagicalLoadingAnimation() {
    Box(contentAlignment = Alignment.Center) {
        val infiniteTransition = rememberInfiniteTransition()
        
        // 外圈旋轉
        val rotation by infiniteTransition.animateFloat(
            initialValue = 0f,
            targetValue = 360f,
            animationSpec = infiniteRepeatable(
                animation = tween(2000, easing = LinearEasing)
            )
        )
        
        // 魔法書圖片
        Image(
            painter = painterResource(Res.drawable.magic_circle_book),
            contentDescription = "魔法陣與書本",
            modifier = Modifier.size(300.dp)
        )
        
        // 羽毛筆寫字動畫
        val writingX by infiniteTransition.animateFloat(
            initialValue = -10f,
            targetValue = 10f,
            animationSpec = infiniteRepeatable(
                animation = tween(800),
                repeatMode = RepeatMode.Reverse
            )
        )
        
        Image(
            painter = painterResource(Res.drawable.magic_quill),
            contentDescription = "羽毛筆",
            modifier = Modifier
                .size(200.dp)
                .offset(x = writingX.dp)
        )
    }
}

跑起來了!羽毛筆在書上寫字的感覺很棒。

建立設計系統

定義顏色

之前的 Liquid Arcana 太理想化,實際寫程式時我改成了更簡潔的命名:

object GrimoColors {
    // 主題色 - 冰川青(低飽和度)
    val GlacierCyan = Color(0xFF8FBFD0)
    
    // 基底色 - 晨霧
    val MorningVeil = Color(0xFFF2F6F9)
    val StarlightMist = Color(0xFFD8E0E7)
    
    // 功能色
    val CinderRed = Color(0xFFD77C7C)  // 錯誤,柔和不刺眼
    
    // 中性色階
    val PureWhite = Color(0xFFFFFFFF)
    val MorningFog = MorningVeil
    val CoolGray = Color(0xFF8A96A3)
    val Midnight = Color(0xFF161B21)
    
    // 玻璃材質
    val GlassTintBrand = Color(0x148FBFD0)  // 8% 透明度的品牌色
    val GlassLightBg = Color(0x29FFFFFF)    // 16% 白色
}

取名很痛苦。

一開始用 primary、secondary,但太無聊。後來想到用自然意象——冰川、晨霧、星光。既有詩意,又能暗示顏色。

動效時長

「動畫要多快?」我問 AI。

「看用途,」AI 回答,「hover 效果要快(100-200ms),頁面轉場可以慢一點(300-500ms)。」

整理成常數:

object GrimoMotion {
    const val QUICK_DURATION = 150      // hover 反饋
    const val STANDARD_DURATION = 300   // 一般過渡
    const val GENTLE_DURATION = 500     // 展開收合
    const val BREATH_DURATION = 2000    // 呼吸循環
}

實作玻璃質感按鈕

研究 glassmorphism

看了很多 glassmorphism 的文章。關鍵是:

  • 半透明背景
  • 模糊效果
  • 細微的邊框
  • 光影變化

Compose Desktop 沒有模糊效果,只能用透明度模擬:

@Composable
fun GlassButton(
    text: String,
    onClick: () -> Unit
) {
    var isHovered by remember { mutableStateOf(false) }
    
    val glassAlpha by animateFloatAsState(
        targetValue = if (isHovered) 0.18f else 0.12f,
        animationSpec = tween(GrimoMotion.QUICK_DURATION)
    )
    
    Box(
        modifier = Modifier
            .clip(RoundedCornerShape(12.dp))
            .background(Color.Black.copy(alpha = glassAlpha))
            .border(
                width = 1.dp,
                color = Color.White.copy(alpha = 0.08f),
                shape = RoundedCornerShape(12.dp)
            )
            .clickable { onClick() }
            .onHover { isHovered = it }
            .padding(horizontal = 24.dp, vertical = 12.dp)
    ) {
        Text(
            text = text,
            color = GrimoColors.GlacierCyan
        )
    }
}

加入魔法效果

按鈕太素了,想加點星光效果。

「怎麼做星光閃爍?」我問 AI。

「在按鈕上隨機生成小光點,」AI 建議,「控制生命週期,讓它們淡入淡出。」

寫了個星光動畫:

@Composable
fun Modifier.starSparkle(
    enabled: Boolean = true,
    sparkleColor: Color = Color(0xFFFECA57)  // 金色
): Modifier {
    if (!enabled) return this
    
    var sparkles by remember { mutableStateOf(listOf<Sparkle>()) }
    
    // 生成星光
    LaunchedEffect(enabled) {
        while (enabled) {
            val newSparkle = Sparkle(
                x = Random.nextFloat(),
                y = Random.nextFloat(),
                size = Random.nextFloat() * 5f + 3f,
                lifeTime = 0f
            )
            sparkles = (sparkles + newSparkle).takeLast(6)
            delay(Random.nextLong(200, 800))
        }
    }
    
    return this.drawWithContent {
        drawContent()  // 先畫按鈕
        
        // 再畫星光
        sparkles.forEach { sparkle ->
            val alpha = when {
                sparkle.lifeTime < 0.15f -> sparkle.lifeTime * 6.67f
                sparkle.lifeTime > 0.6f -> (1f - sparkle.lifeTime) * 2.5f
                else -> 1f
            }
            
            drawStar(
                center = Offset(
                    size.width * sparkle.x,
                    size.height * sparkle.y
                ),
                size = sparkle.size,
                color = sparkleColor.copy(alpha = alpha)
            )
        }
    }
}

效果很夢幻!hover 時星光閃爍,有魔法的感覺。

踩坑與優化

效能問題

動畫一多就卡。

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

「可能觸發了重組,」AI 分析,「試試 graphicsLayer。」

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

// 流暢版本  
Modifier.graphicsLayer {
    translationY = animatedValue
}

改完順多了。原來 offset 會觸發重組,graphicsLayer 只更新渲染層。

圖片載入

大圖片載入很慢,加個淡入效果:

@Composable
fun OptimizedImage(resource: DrawableResource) {
    var isLoaded by remember { mutableStateOf(false) }
    
    AnimatedVisibility(
        visible = isLoaded,
        enter = fadeIn()
    ) {
        Image(
            painter = painterResource(resource),
            contentDescription = null,
            onSuccess = { isLoaded = true }
        )
    }
}

實戰心得

設計系統的演進

從最初的 Liquid Arcana 到實際的 GrimoColors,經歷了很多調整。

理想很豐滿,現實很骨感。太複雜的命名反而難用,簡潔清晰最重要。

問對問題很重要

跟 AI 對話,具體的問題比籠統的需求有用:

❌ 「幫我設計按鈕」
✅ 「怎麼實現 glassmorphism 效果?」

❌ 「動畫怎麼做」
✅ 「星光閃爍的生命週期怎麼控制?」

迭代優於完美

第一版載入畫面只有旋轉圈。
加了背景圖。
加了魔法書。
加了羽毛筆動畫。
優化了效能。

每次一小步,最後的效果遠超最初的想像。

總結

今天從設計理念到實際程式碼,走了一遍完整的 UI 開發流程。

最大的收穫是理解了 Desktop UI 的特點——不是放大版的 Mobile,而是完全不同的體驗。大螢幕、精準操作、長時間使用,每個特點都影響設計決策。

AI 在這個過程中像個顧問,我找資料、提問題,它幫我分析、給建議。真正的實作和決策,還是要自己來。

明天繼續深入動畫系統,讓介面更有生命力。

今日金句

「優秀的 UI 不是讓介面漂亮,而是讓使用者舒適。」

關於作者:Sam,一人公司創辦人。正在打造 Grimo,智能任務管理平台。

專案連結GitHub - grimostudio


上一篇
Day 21: 創辦人週報:好的流程讓一人公司也能高效運轉
系列文
30 天一人公司的 AI 開發實戰22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言