學不動了,入門 Compose Styles API

![00.png](https://i.imgur.com/GDqghON.jpeg)Jetpack Compose 近期推出了全新的 **Styles API**,用於客製化 UI 元素和元件的樣式。

在不久之前(好吧,我不得不承認現在 API 更新太快了),這類事情通常依賴 Modifier 或元件上的 paddingcolor 等參數;Styles API 則把這些樣式收攏到統一的 Style 參數或 Modifier.styleable 中。

本文會先整理它的基本概念、使用方式,後續大概會有兩三篇文章繼續學習這個 API,同時也會和大家一起探討 Styles API 的具體用法。

注意: Styles API 目前處於 @Experimental 階段,後續版本可能會有變化,Material Design 的樣式支援也將在未來版本中提供。

為什麼需要 Styles API

Compose 已經有 Modifier 了,為什麼還要再引入一個 Style

我相信,不是只有我第一次看到這個 API 時會這麼想!

在我深入學習且已經應用了一段時間之後,我自己的看法簡單來說是:Modifier 仍然負責很多事情,但它不一定適合承載所有「樣式設定」。

當元件有大量顏色、間距、形狀、字型、狀態樣式參數時,這些設定會分散在參數、Modifier 和狀態判斷裡,元件 API 也會越來越臃腫。

你想想,如果你做了一個複雜的控制項,裡面有很多地方可以自訂樣式,你會怎麼辦?

傳統的 Modifier 用法更適合控制最外層控制項;至於控制項內部可自訂的部分,我們一般不會繼續傳遞 Modifier,而是透過函式參數暴露出來。

Styles 想解決的,是把這類外觀規則集中起來,讓元件更容易被主題、設計系統和互動狀態統一驅動。

這個問題如果要展開,其實會涉及 Modifier 的職責邊界、元件 API 設計、狀態樣式和設計系統複用,今天這篇文章我們先不展開。

把 Styles API 的基本概念和用法看完,我們再回頭討論「它到底該放在什麼位置」、「它應該怎麼用」。

為了我少打字,後續我會用 Styles 或 Style 來表示 Styles API。

核心概念

Styles 主要解決幾類問題:

  • 簡化狀態樣式:以宣告式方式定義基於 hoverfocuspressed 等狀態的樣式,大幅減少模板程式碼;
  • 內建動畫支援:狀態切換時的樣式過渡動畫開箱即用,且避免了 animateColorAsState 帶來的重組問題;
  • 元件 API 更簡潔:用單一的 Style 參數取代大量樣式參數,介面更清晰;
  • 效能更優:樣式在 Draw 和 Layout 階段執行,跳過了 Composition 階段,減少重組;
  • 標準化:提供一組統一的樣式屬性,讓元件更容易接入樣式體系。

Styles 並不是要取代 Modifier,而是更適合替代樣式參數(如 paddingcolors)。對於互動、自訂繪製、屬性堆疊、精準事件控制等行為,仍需使用 Modifier

Google 也貼心地提供了一個 agent skill 來幫助開發者在應用中使用新的 Styles API。

開始之前,我們先簡單看一下部分概念:

概念說明Style定義 UI 元素外觀的介面,類似 CSS 樣式。可在本地自訂或透過主題統一配置。同一屬性重複設定時,後者覆蓋前者。StyleScopeStyle 內部的接收者作用域,提供 background()padding()border() 等屬性定義函式,以及對目前 StyleState 的存取。StyleState追蹤元素的互動狀態(如 isEnabledisPressedisChecked),支援自訂狀態,用於實現條件樣式。當然,這些概念不用背,還是趕快看程式碼!

開始

要使用這些 API,需要使用最新的 Compose foundation alpha 版本。

libs.versions.tomlapp/build.gradle.kts 中將 Compose 版本設定為官方文件當前範例中的 alpha 版本;實際使用時以最新 alpha 為準。

toml 代碼解讀複製代碼compose = "1.12.0-alpha03"
toml 代碼解讀複製代碼androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "compose" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "compose" }

屬性一覽

樣式支援的屬性涵蓋面很廣,但並非所有 Modifier 功能都能用樣式替代。以下是完整的屬性分類:

分組屬性子元件可繼承版面配置與尺寸內邊距contentPadding(內部)、externalPadding(外部),支援方向/水平/垂直/全方向否尺寸fillWidth/fillHeight/fillSize()widthheightsize(支援 DpDpSizeFloat 分數)否定位left/top/right/bottom 偏移否視覺外觀填充backgroundforeground(支援 ColorBrush)否邊框borderWidthborderColorborderBrush否形狀shapeclipborder 會使用此形狀)否陰影dropShadowinnerShadow變換空間移動translationXtranslationYscaleX/scaleYrotationX/rotationY/rotationZ否控制alphazIndex(堆疊順序)、transformOrigin(樞軸點)否排版樣式textStylefontSizefontWeightfontStylefontFamily是著色contentColorcontentBrush(也用於圖示樣式)是段落lineHeightletterSpacingtextAligntextDirectionlineBreakhyphens是裝飾textDecorationtextIndentbaselineShift是其中排版相關屬性支援繼承——父元件設定後會傳播到所有子 Text 元件。

可以這麼理解,和文字相關的,都支援繼承。

這和 CSS 何其相似!

使用 Styles

使用 Styles 大概有三種方式!

1. 直接使用 Style 參數

暴露 Style 參數的元件可以直接在 lambda 中設定樣式屬性:

kotlin 代碼解讀複製代碼BaseButton(
    onClick = { },
    style = { }
) {
    BaseText("Click me")
}

在樣式 lambda 內,可以設定各種屬性,例如 contentPaddingbackground

kotlin 代碼解讀複製代碼BaseButton(
    onClick = {},
    style = { background(Color(0xFF1976D2)) }
) {
    Text("Blue", color = Color.White)
}

BaseButton(
    onClick = {},
    style = {
        background(Color(0xFF388E3C))
        contentPadding(horizontal = 24.dp, vertical = 12.dp)
    },
) {
    Text("Green (padded)", color = Color.White)
}

1.png

2. 透過 Modifier.styleable 套用樣式

對於沒有內建 style 參數的元件,可以使用 Modifier.styleable

kotlin 代碼解讀複製代碼Row(
    modifier = Modifier.styleable { }
) {
    BaseText("Content")
}

style 參數類似,你可以在 lambda 內包含 backgroundshape 等屬性:

kotlin 代碼解讀複製代碼Row(
    modifier = Modifier
        .styleable {
            background(Color(0xFFE3F2FD))
            shape(RoundedCornerShape(12.dp))
            contentPadding(16.dp)
        }
        .fillMaxWidth(),
) {
    Text("styled via Modifier.styleable")
}

2.png

多個 Modifier.styleable 鏈式呼叫時,非繼承屬性會像多個 Modifier 一樣作用在目前元件上;繼承屬性則由鏈中最後一個 styleable 決定。

使用 Modifier.styleable 時,你可能還需要建立並提供一個 StyleState,以套用基於狀態的樣式。

3. 定義獨立樣式複用

將樣式抽為變數,可在多個元件間共享:

kotlin 代碼解讀複製代碼val style = Style { background(Color.Blue) }

// 透過 style 參數使用
BaseButton(onClick = { }, style = style) {
    BaseText("Button")
}

// 透過 Modifier.styleable 使用(需配合 StyleState)
val styleState = remember { MutableStyleState(null) }
Column(Modifier.styleable(styleState, style)) {
    BaseText("Column content")
}

也可以將同一個樣式傳遞給多個元件:

kotlin 代碼解讀複製代碼val sharedStyle = Style {
    background(Color(0xFF6A1B9A))
    shape(RoundedCornerShape(8.dp))
    contentPadding(horizontal = 16.dp, vertical = 10.dp)
}

BaseButton(onClick = {}, style = sharedStyle) {
    Text("Shared on button", color = Color.White)
}

val columnState = remember { MutableStyleState(null) }
Column(
    modifier = Modifier
        .styleable(columnState, sharedStyle)
        .fillMaxWidth(),
) {
    Text("Same Style on a Column", color = Color.White)
}

3.png

屬性覆蓋與合併規則

樣式屬性不是累加的,而是以最後一次設定為準。這與 Modifier 的行為不同。可以在每行設定不同的屬性來添加多個樣式屬性:

kotlin 代碼解讀複製代碼BaseButton(
    onClick = { },
    style = {
        background(Color.Blue)
        contentPaddingStart(16.dp)
    }
) {
    BaseText("Button")
}

同一屬性重複設定時,後者覆蓋前者:

kotlin 代碼解讀複製代碼val overrideStyle = Style {
    background(Color(0xFFD32F2F))
    background(Color(0xFF008080)) // final background
    contentPadding(64.dp)
    contentPaddingTop(16.dp)
}
BaseButton(onClick = {}, style = overrideStyle) {
    Text("Override", color = Color.White)
}

4.png

發現了嗎?contentPaddingTop 會把上方內邊距改成 16.dp,其餘方向仍沿用 contentPadding(64.dp) 的設定。

多個 Style 物件可以透過 then 合併,後者覆蓋前者:

kotlin 代碼解讀複製代碼val style1 = Style { background(TealColor) }
val style2 = Style { contentPaddingTop(16.dp) }

BaseButton(
    style = style1 then style2,
    onClick = { },
) {
    BaseText("Click me!")
}

當多個樣式指定相同屬性時,最後設定的屬性會被選中。因為樣式中的屬性不是累加的,最後傳入的內邊距會覆蓋由初始 contentPadding 設定的水平內邊距,最後一個背景色也會覆蓋初始樣式設定的背景色:

kotlin 代碼解讀複製代碼val s1 = Style {
    background(Color(0xFFD32F2F))
    contentPadding(32.dp)
}
val s2 = Style {
    contentPaddingHorizontal(8.dp)
    background(Color(0xFFBDBDBD))
}
BaseButton(onClick = {}, style = s1 then s2) {
    Text("關注 RockByte 公眾號", color = Color.Black)
}

4_5.png

樣式繼承

排版和著色相關屬性支援從父元件向下繼承,覆蓋優先級從高到低為:

優先級方式範例1(最高)元件直接參數Text(color = Color.Red)2Style 參數Text(style = Style { contentColor(Color.Red) })3Modifier.styleable``Modifier.styleable { contentColor(Color.Red) }4(最低)父元件繼承父元件設定的排版/顏色屬性父元件設定的 contentColor 等屬性會自動傳播到所有子 Text 元件,子元件也可以透過自身樣式覆蓋繼承值。以下是一個父元件設定文字屬性的例子:

kotlin 代碼解讀複製代碼val styleState = remember { MutableStyleState(null) }
Column(
    modifier = Modifier.styleable(styleState) {
        background(Color.LightGray)
        val blue = Color(0xFF4285F4)
        val purple = Color(0xFFA250EA)
        val colors = listOf(blue, purple)
        contentBrush(Brush.linearGradient(colors))
    },
) {
    BaseText("Children inherit", style = { width(60.dp) })
    BaseText("certain properties")
    BaseText("from their parents")
}

子元件也可以透過自身樣式覆蓋父元件的繼承值:

kotlin 代碼解讀複製代碼Column(
    modifier = Modifier.styleable {
        val blue = Color(0xFF4285F4)
        val purple = Color(0xFFA250EA)
        val colors = listOf(blue, purple)
        background(Brush.linearGradient(colors))
        contentPadding(32.dp)
    },
) {
    Box(
        modifier = Modifier.styleable(
            style = Style {
                background(Brush.linearGradient(listOf(Color.Red, Color.Blue)))
            },
        ),
    ) {
        BasicText("Children can override properties")
    }
    BasicText("set by their parents")
}

6.png

封裝樣式函式與 CompositionLocal

可以透過 StyleScope 的擴充函式封裝常用樣式組合:

kotlin 代碼解讀複製代碼fun StyleScope.outlinedBackground(color: Color) {
    border(2.dp, color)
    background(color.copy(alpha = 0.4f))
}

val customStyle = Style {
    outlinedBackground(Color.Red)
    contentPadding(horizontal = 20.dp, vertical = 12.dp)
    shape(RoundedCornerShape(8.dp))
}
BaseButton(onClick = {}, style = customStyle) {
    Text("outlinedBackground(Color.Red)", color = Color.White)
}

7.png

這裡我不得不提一句,Styles 有一個我非常喜歡的地方:當我已經設定了 shape 之後,再使用 borderbackground 時,就不用分別給它們傳遞 shape。這一點在 Modifier 呼叫鏈裡沒這麼直接。

樣式也支援讀取 CompositionLocal 中的設計 token:

kotlin 代碼解讀複製代碼val buttonStyle = Style {
    contentPadding(12.dp)
    shape(RoundedCornerShape(50))
    background(Brush.verticalGradient(LocalCustomColors.currentValue.background))
}

互動狀態處理

Styles API 內建了對常見互動狀態的支援:PressedHoveredFocusedSelectedEnabledToggled

StyleState 是穩定的唯讀介面,用來追蹤元素目前是否 enabledpressedfocused 等;在 StyleScope 中可以基於這些狀態宣告條件樣式。

style 區塊中可以直接宣告各狀態下的樣式:

kotlin 代碼解讀複製代碼val lightBlue = Color(0xFFBBDEFB)
val lightRed = Color(0xFFFFCDD2)

val interactiveStyle = Style {
    background(Color.White)
    border(1.dp, Color(0xFF9E9E9E))
    contentPadding(12.dp)
    focused {
        background(lightBlue)
    }

    pressed {
        background(lightRed)
        border(2.dp, Color(0xFFD32F2F))
    }
}
BaseButton(
    onClick = {},
    style = interactiveStyle,
) {
    Text(
        "Hover / Focus / Press me",
        style = TextStyle(color = Color.Black, fontSize = 16.sp),
    )
}

8.gif

狀態還可以巢狀組合,例如同時處理懸停+按下的場景:

kotlin 代碼解讀複製代碼hovered {
    background(lightPurple)
    pressed {
        background(lightOrange)  // 懸停時按下
    }
}
pressed {
    background(lightRed)         // 非懸停時按下
}

自訂元件的狀態支援

建立自訂 styleable 元件時,需要將 interactionSource 連接到 styleState,並把同一個 interactionSource 傳給相關的互動 Modifier,例如 clickablefocusable

kotlin 代碼解讀複製代碼@Composable
private fun GradientButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: Style = Style,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable RowScope.() -> Unit,
) {
    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
    val styleState = rememberUpdatedStyleState(interactionSource) {
        it.isEnabled = enabled
    }
    Row(
        modifier = modifier
            .clickable(
                onClick = onClick,
                enabled = enabled,
                interactionSource = interactionSource,
                indication = null,
            )
            .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

基於此基礎元件,可以方便地建立具有互動效果的衍生元件:

kotlin 代碼解讀複製代碼@Composable
fun LoginButton() {
    val loginButtonStyle = Style {
        pressed {
            background(Brush.linearGradient(listOf(Color.Magenta, Color.Red)))
        }
    }
    GradientButton(onClick = { }, style = loginButtonStyle) {
        BaseText("Login")
    }
}

樣式動畫

狀態切換時的樣式變化支援內建動畫。只需在狀態區塊內用 animate 包裹屬性即可。

不僅如此,animate 還支援自訂 animationSpec,例如補間動畫和彈簧動畫:

kotlin 代碼解讀複製代碼val animatingStyle = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)
    transformOrigin(TransformOrigin.Center)
    pressed {
        animate(tween(durationMillis = 400)) { // 補間動畫
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
        animate(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) { // 彈簧動畫
            scale(1.2f)
        }
    }
}

val interactionSource = remember { MutableInteractionSource() }
val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
Box(
    modifier = Modifier
        .fillMaxWidth()
        .height(200.dp),
    contentAlignment = Alignment.Center,
) {
    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = null,
                onClick = {},
            )
            .styleable(styleState, animatingStyle),
    )
}

9.gif

自訂狀態樣式

除了內建的互動狀態,你還可以定義自訂狀態。以媒體播放器為例,可以根據播放狀態(Stopped / Playing / Paused)套用不同樣式。

1. 定義狀態列舉和鍵

kotlin 代碼解讀複製代碼enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

2. 建立擴充函式

kotlin 代碼解讀複製代碼var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(block: () -> Unit) {
    state(playerStateKey, block) { _, state -> state[playerStateKey] == PlayerState.Playing }
}

fun StyleScope.playerPaused(block: () -> Unit) {
    state(playerStateKey, block) { _, state -> state[playerStateKey] == PlayerState.Paused }
}

3. 在元件中使用

在組合項中定義 styleState 並將 styleState.playerState 設定為傳入的狀態。將 styleState 傳遞到修飾符的 styleable 函式中:

kotlin 代碼解讀複製代碼@OptIn(ExperimentalFoundationStyleApi::class)
@Composable
private fun MediaPlayer(
    url: String,
    modifier: Modifier = Modifier,
    style: Style = Style,
    state: PlayerState = remember { PlayerState.Paused },
) {
    val styleState = remember { MutableStyleState(null) }
    styleState.playerState = state
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(80.dp)
            .styleable(styleState, style),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = "MediaPlayer(${state.name}): $url",
            style = TextStyle(fontSize = 16.sp),
        )
    }
}

這裡我只是做了一個假的 MediaPlayer 用於測試。

4. 定義狀態樣式

stylelambda 內,使用之前定義的擴充函式為自訂狀態套用基於狀態的樣式:

kotlin 代碼解讀複製代碼@Composable
fun StyleStateKeySample() {
    val style = Style {
        borderColor(Color.Gray)
        playerPlaying {
            animate { borderColor(Color.Green) }
        }
        playerPaused {
            animate { borderColor(Color.Blue) }
        }
    }
    // 修改 state 參數即可改變樣式,也可以連接 ViewModel 來動態切換狀態
    MediaPlayer(
        url = "https://example.com/media/video",
        style = style,
        state = PlayerState.Stopped,
    )
}

10.gif

一點想法

Styles API 目前仍是實驗性 API,Material 對 Styles 的支援也還在後續版本中。落地時可以先從自訂設計系統或少量自訂元件開始,不建議直接把現有 Modifier 用法全部遷移過去。

目前看來,它比較適合解決三類問題:元件樣式參數過多、互動狀態樣式分散、設計系統需要複用一組樣式規則。與此同時,Modifier 仍然負責互動、自訂繪製和部分無法由 Style 表達的行為。

我自己比較強烈的感受是,Styles API 在強化「狀態表示 UI」這個概念。過去很多樣式都寫在 Modifier 呼叫鏈裡,呼叫順序會影響最終結果,讀起來多少有一點命令式程式設計的味道,也不像真正的 DSL。

新的 Style 寫法把樣式、狀態和狀態下的樣式變化放在同一個宣告式結構裡,DSL 的感覺更強,也更適合描述 UI 在不同狀態下應該呈現什麼樣子。雖然它現在還很早期,但我預感這種圍繞狀態組織樣式的方式,可能會是 Compose 後續演進的一個趨勢。

未完待續


原文出處:https://juejin.cn/post/7655245911813128238


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝13   💬2   ❤️1
572
🥈
我愛JS
📝1   ❤️1
71
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登