Jetpack Compose 近期推出了全新的 **Styles API**,用於客製化 UI 元素和元件的樣式。
在不久之前(好吧,我不得不承認現在 API 更新太快了),這類事情通常依賴 Modifier 或元件上的 padding、color 等參數;Styles API 則把這些樣式收攏到統一的 Style 參數或 Modifier.styleable 中。
本文會先整理它的基本概念、使用方式,後續大概會有兩三篇文章繼續學習這個 API,同時也會和大家一起探討 Styles API 的具體用法。
注意: Styles API 目前處於
@Experimental階段,後續版本可能會有變化,Material Design 的樣式支援也將在未來版本中提供。
Compose 已經有 Modifier 了,為什麼還要再引入一個 Style?
我相信,不是只有我第一次看到這個 API 時會這麼想!
在我深入學習且已經應用了一段時間之後,我自己的看法簡單來說是:Modifier 仍然負責很多事情,但它不一定適合承載所有「樣式設定」。
當元件有大量顏色、間距、形狀、字型、狀態樣式參數時,這些設定會分散在參數、Modifier 和狀態判斷裡,元件 API 也會越來越臃腫。
你想想,如果你做了一個複雜的控制項,裡面有很多地方可以自訂樣式,你會怎麼辦?
傳統的 Modifier 用法更適合控制最外層控制項;至於控制項內部可自訂的部分,我們一般不會繼續傳遞 Modifier,而是透過函式參數暴露出來。
Styles 想解決的,是把這類外觀規則集中起來,讓元件更容易被主題、設計系統和互動狀態統一驅動。
這個問題如果要展開,其實會涉及 Modifier 的職責邊界、元件 API 設計、狀態樣式和設計系統複用,今天這篇文章我們先不展開。
把 Styles API 的基本概念和用法看完,我們再回頭討論「它到底該放在什麼位置」、「它應該怎麼用」。
為了我少打字,後續我會用 Styles 或 Style 來表示 Styles API。
Styles 主要解決幾類問題:
hover、focus、pressed 等狀態的樣式,大幅減少模板程式碼;animateColorAsState 帶來的重組問題;Style 參數取代大量樣式參數,介面更清晰;Styles 並不是要取代 Modifier,而是更適合替代樣式參數(如 padding、colors)。對於互動、自訂繪製、屬性堆疊、精準事件控制等行為,仍需使用 Modifier。
Google 也貼心地提供了一個 agent skill 來幫助開發者在應用中使用新的 Styles API。
開始之前,我們先簡單看一下部分概念:
概念說明Style定義 UI 元素外觀的介面,類似 CSS 樣式。可在本地自訂或透過主題統一配置。同一屬性重複設定時,後者覆蓋前者。StyleScopeStyle 內部的接收者作用域,提供 background()、padding()、border() 等屬性定義函式,以及對目前 StyleState 的存取。StyleState追蹤元素的互動狀態(如 isEnabled、isPressed、isChecked),支援自訂狀態,用於實現條件樣式。當然,這些概念不用背,還是趕快看程式碼!
要使用這些 API,需要使用最新的 Compose foundation alpha 版本。
在 libs.versions.toml 或 app/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()、width、height、size(支援 Dp、DpSize、Float 分數)否定位left/top/right/bottom 偏移否視覺外觀填充background、foreground(支援 Color 或 Brush)否邊框borderWidth、borderColor、borderBrush否形狀shape(clip 和 border 會使用此形狀)否陰影dropShadow、innerShadow否變換空間移動translationX、translationY、scaleX/scaleY、rotationX/rotationY/rotationZ否控制alpha、zIndex(堆疊順序)、transformOrigin(樞軸點)否排版樣式textStyle、fontSize、fontWeight、fontStyle、fontFamily是著色contentColor、contentBrush(也用於圖示樣式)是段落lineHeight、letterSpacing、textAlign、textDirection、lineBreak、hyphens是裝飾textDecoration、textIndent、baselineShift是其中排版相關屬性支援繼承——父元件設定後會傳播到所有子 Text 元件。
可以這麼理解,和文字相關的,都支援繼承。
這和 CSS 何其相似!
使用 Styles 大概有三種方式!
Style 參數暴露 Style 參數的元件可以直接在 lambda 中設定樣式屬性:
kotlin 代碼解讀複製代碼BaseButton(
onClick = { },
style = { }
) {
BaseText("Click me")
}
在樣式 lambda 內,可以設定各種屬性,例如 contentPadding 或 background:
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)
}

對於沒有內建 style 參數的元件,可以使用 Modifier.styleable:
kotlin 代碼解讀複製代碼Row(
modifier = Modifier.styleable { }
) {
BaseText("Content")
}
與 style 參數類似,你可以在 lambda 內包含 background 或 shape 等屬性:
kotlin 代碼解讀複製代碼Row(
modifier = Modifier
.styleable {
background(Color(0xFFE3F2FD))
shape(RoundedCornerShape(12.dp))
contentPadding(16.dp)
}
.fillMaxWidth(),
) {
Text("styled via Modifier.styleable")
}

多個 Modifier.styleable 鏈式呼叫時,非繼承屬性會像多個 Modifier 一樣作用在目前元件上;繼承屬性則由鏈中最後一個 styleable 決定。
使用 Modifier.styleable 時,你可能還需要建立並提供一個 StyleState,以套用基於狀態的樣式。
將樣式抽為變數,可在多個元件間共享:
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)
}

樣式屬性不是累加的,而是以最後一次設定為準。這與 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)
}

發現了嗎?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)
}

排版和著色相關屬性支援從父元件向下繼承,覆蓋優先級從高到低為:
優先級方式範例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")
}

可以透過 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)
}

這裡我不得不提一句,Styles 有一個我非常喜歡的地方:當我已經設定了 shape 之後,再使用 border 和 background 時,就不用分別給它們傳遞 shape。這一點在 Modifier 呼叫鏈裡沒這麼直接。
樣式也支援讀取 CompositionLocal 中的設計 token:
kotlin 代碼解讀複製代碼val buttonStyle = Style {
contentPadding(12.dp)
shape(RoundedCornerShape(50))
background(Brush.verticalGradient(LocalCustomColors.currentValue.background))
}
Styles API 內建了對常見互動狀態的支援:Pressed、Hovered、Focused、Selected、Enabled、Toggled。
StyleState 是穩定的唯讀介面,用來追蹤元素目前是否 enabled、pressed、focused 等;在 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),
)
}
狀態還可以巢狀組合,例如同時處理懸停+按下的場景:
kotlin 代碼解讀複製代碼hovered {
background(lightPurple)
pressed {
background(lightOrange) // 懸停時按下
}
}
pressed {
background(lightRed) // 非懸停時按下
}
建立自訂 styleable 元件時,需要將 interactionSource 連接到 styleState,並把同一個 interactionSource 傳給相關的互動 Modifier,例如 clickable 或 focusable:
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),
)
}
除了內建的互動狀態,你還可以定義自訂狀態。以媒體播放器為例,可以根據播放狀態(Stopped / Playing / Paused)套用不同樣式。
kotlin 代碼解讀複製代碼enum class PlayerState {
Stopped,
Playing,
Paused
}
val playerStateKey = StyleStateKey(PlayerState.Stopped)
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 }
}
在組合項中定義 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 用於測試。
在 style 的 lambda 內,使用之前定義的擴充函式為自訂狀態套用基於狀態的樣式:
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,
)
}
Styles API 目前仍是實驗性 API,Material 對 Styles 的支援也還在後續版本中。落地時可以先從自訂設計系統或少量自訂元件開始,不建議直接把現有 Modifier 用法全部遷移過去。
目前看來,它比較適合解決三類問題:元件樣式參數過多、互動狀態樣式分散、設計系統需要複用一組樣式規則。與此同時,Modifier 仍然負責互動、自訂繪製和部分無法由 Style 表達的行為。
我自己比較強烈的感受是,Styles API 在強化「狀態表示 UI」這個概念。過去很多樣式都寫在 Modifier 呼叫鏈裡,呼叫順序會影響最終結果,讀起來多少有一點命令式程式設計的味道,也不像真正的 DSL。
新的 Style 寫法把樣式、狀態和狀態下的樣式變化放在同一個宣告式結構裡,DSL 的感覺更強,也更適合描述 UI 在不同狀態下應該呈現什麼樣子。雖然它現在還很早期,但我預感這種圍繞狀態組織樣式的方式,可能會是 Compose 後續演進的一個趨勢。