初探 Jetpack Compose — Slot API

YANBIN HUNG
PicCollage Company Blog
15 min readJan 31, 2021

--

在上一篇中介紹了 Jetpack Compose 中最簡單的用法,也收到了很多回饋,其中最多人回饋的是:這根本跟 flutter 超像的啊!到底有什麼不一樣?很可惜的是,因為我還沒有深入研究過 flutter,所以很難回答這問題。但是今天我將會介紹 Compose 的核心概念,還有他為什麼叫做 Compose 。希望在看完這些介紹後,大家會有不同的啟發或感想!

在介紹核心觀念前,先讓我們看看開發 Android 的一個共同的痛:

客製化 Toolbar

在 App 的開發中,一定少不了 Toolbar 這個元件,大家一定都碰過各種不同客製化的需求,像是最基本的標題置左加上漢堡選單 (下圖第一張),這是 Android 預設的 Toolbar 風格。

但是我最常碰到的需求是標題放中間,像是下面第二張圖,遇到這樣的需求時候,就無法使用內建的 setTitle 來設定文字,而是要另外放一個 TextView 進去,使得預設的 API 無用武之地。還有更複雜的,像下圖第三張的 Google map ,右邊還多了兩個按鈕, 然而Android 提供的原生方式卻不是非常的直覺,需要使用到 res/menu當中的 xml 檔案另外進行設定!另人更崩潰的是遇到特殊需求,像是點選搜尋按鈕之後,搜尋按鈕還要有一個動畫從右邊移到左邊,接著播放 fade in fade out 動畫,還要顯示出文字輸入框!

相信各位一定也遇過各種天馬行空的客製化需求,那我們是否有辦法讓這些客製化需求更容易的去實作出來呢?原來的 Android 設計究竟是有什麼問題呢?在 Jetpack Compose 中,又是怎麼改進的?

組合 V.S. 繼承

如果我們使用的不是 Toolbar 而是 LinearLayout 或是 ConstraintLayout 呢? 如圖中所示,這樣的版面配置,很容易的能夠使用 LinearLayout 拉出來。除此之外,文字要對齊哪一邊也不再是個問題,要做怎樣的 Transition 或是動畫也變得很簡單,這樣的設計,就是應用到了組合的概念。

另一方面,試著想一下,客製化 Toolbar 通常的做法是什麼?建立一個繼承 Toolbar 的類別,對吧?尤其是當你需要將標題置中,使用特定字體時,這時候就會選用下面這種方式來達成需求:

class MyToolbar: Toolbar {
constructor(context: Context?): super(context)
//other constructors
fun initParam(attr: AttributeSet?) {
// 設定自定義參數
// 或是設定字體
}
}

然而,一個 MyToolbar 可能不夠,總會有遇到不同畫面需求的時候,搞不好還需要 MySearchToolbar,MyToolbar2 …等等,而這些類別搞不好還只被使用一次!多浪費啊。

其實大部分的案例來說,組合是優於繼承的而這個“組合取代繼承”是物件導向設計中的一個基本概念,應該很多人不陌生才對,如果你真得不知道這個概念的話,強烈建議你先去了解!網路上有非常多資源在做相關的介紹,這邊就不再說明了。接下來,就來看看 Jetpack Compose 如何實現組合的吧!

在 Jackpack Compose 中,已經找不到 Toolbar 這個名稱的元件了,取而代之的是 TopAppBar 。所以,接下來我將會稱呼這個元件為 TopAppBar 。

標題置中的 TopAppBar

TopAppBar 的用法其實跟 Row 是一樣的,需要把指定的元件放到大括弧中:

可能有人覺得說,這樣不是比較麻煩了嗎?現在沒辦法直接使用 setTitle 設定標題就好,還要先建立一個 Text ,然後才有辦法將標題顯示出來,真的有比較好用嗎?但是別忘了 Jetpack Compose 有另一個非常強大的設計,就是任何 UI 元件都是一個 function !任何設計的重用都是非常間單的!像上面的例子,我只要將標題設定為這個 function 的輸入參數,任何呼叫他的人,可以設定任何標題,所以可以很簡單的重用它。那如果是以前的作法呢?抱歉,我必須要使用繼承的方式,寫一大堆建構子,還有複寫 setTitle 這個 function ,才能達到一樣的目的。

另外,這個範例中出現了另一個新的 Modifier:fillMaxWidth ,基本上他的用途就跟 xml 的 match_parent 一樣,有了它,我們的文字才能在 TopAppBar 置中,以下是本段程式碼的效果:

Icon + 標題 TopAppBar

上面有說過,TopAppBar 的用法跟 Row 是一樣的,所以如果我同時要有 Icon 跟標題,只要將他們寫在同一個區塊內就好了,如下:

在這邊我拿掉了 Text 的 fillMaxWidth ,並且除了 Icon 與 Text 之外還多了一個 Spacer,各位猜猜看這會得到怎樣的結果呢?

結果竟然是除了右邊有預期中的空白之外,標題跟 Icon 之間也有空白了!這是怎麼回事呢?在答案揭曉之前,來做幾個簡單的小實驗吧!

  1. 如果把 Spacer 拿掉會發生什麼事呢?
  2. 如果最後加的是 Icon 又會發生什麼事?

以下是實驗的結果:

左邊是 1, 右邊是 2

我們發現到, TopAppBar 會試著把所有元件平均分配在他的 View 的範圍裡面。既然實驗做完了,是時候來追一下原因了,查看 TopAppBar 的原始碼,將會發現他呼叫了 AppBar ,再繼續追下去,就發現一個叫做 horizontalArrangement 的屬性:

@Composable
private fun AppBar(
backgroundColor: Color,
contentColor: Color,
elevation: Dp,
shape: Shape,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
elevation = elevation,
shape = shape,
modifier = modifier
) {
Row(
Modifier.fillMaxWidth()
.padding(start = AppBarHorizontalPadding, end = AppBarHorizontalPadding)
.preferredHeight(AppBarHeight),
horizontalArrangement = Arrangement.SpaceBetween,
content = content
)
}
}

正是因為這個屬性設定為 SpaceBetween, TopAppBar 裡的元件才會平均分佈,其他的屬性還有 Center, SpaceEvenly, SpaceAround 等等,詳細情形可以查看 Arrangement.kt 這個檔案中的原始碼。

說到這裡,問問看大家一個問題,如果我一樣要做一個標題置中的 TopAppBar ,可是不能用 fillMaxWidth 這個 Modifier ,可以做得到嗎?那要怎麼做到呢?還有,要將標題放在 Icon 的正右邊,而不是有一些空白的話,又要怎麼做到呢?在文章的最後將會揭曉答案!

Slot APIs

相信大家都有一定的概念了,像這樣組合元件的設計,讓我們的 layout 更有彈性。Jetpack Compose 稱呼這樣的設計為 Slot APIs,Slot 是一個可以讓你自由塞入各種元件的空間,像是 Row 就是擁有一個水平排列的 Slot ,而 Column 則是擁有一個垂直排列的 Slot,甚至還有可能擁有多個 Slot ,像是 Scaffold 就是其中一個例子:

Scaffold 除了最後一個參數的 bodyContent 之外,還有 topBarbottomBarsnackbarHostfloatingActionButtondrawerContent 這幾種不同的 Slot。

這時候大家可以看到 Slot APIs 其實是一個 Composable function,在這邊是以 Function type 的形式出現,正是因為如此,基本元件像是 Text 或是 Icon 才有辦法被放在裡面( 上一篇有提到 Composable function 只能被其他 Composable function 呼叫,就跟 suspend function 一樣)。

而且依據 Kotlin 的特性,在 function 中最後一個參數是 lambda 的話,就可以省略小括弧。所以雖然全部都是 function ,但卻因為這特性可以很神奇的寫出一層一層洋蔥式的程式碼:

@Composable
fun nestedRow() {
Row() { // 這其實是一個 lambda
Row() { // 又是一個 lambda
Text(text = "Haha")
}
}
}

自己的 Slot 自己做

當然我們也可以在自己的 Composable function 上加上 Slot ! 在 Material Design 中,有一個叫做 Chip 的元件,現在我們來嘗試看看做一個 Chip 出來,並幫他加上 Slot 吧!首先看下方的程式碼:

@Preview(showBackground = true)
@Composable
fun ChipPreview() {
MyChip()
}

@Composable
fun MyChip() {
Surface(
shape = RoundedCornerShape(50),
modifier = Modifier.padding(8.dp)
.size(width = 60.dp, height = 30.dp)
) {
Box(
Modifier.fillMaxSize()
.background(Color.Magenta)
) {
Text(
text = "Dog",
modifier = Modifier.align(Alignment.Center),
textAlign = TextAlign.Center,
color = Color.White
)
}
}
}

Preview 這個 Annotation 上一篇有介紹過就不再重新說明了,直接把重點放在 MyChip 這個 function ,這個 function 中一共有三層元件: Surface, Box, TextSurface 在這裡最主要的目的是切出 Chip 的圓角,藉由 shape 這個參數來達到的。其中 RoundedCornerShape(50) 的意思是圓的半徑為邊長的 50 percent,這樣就可以做出左右邊半圓形的效果。至於 Box 則是提供了一個容器可以讓 Text 置中,以上程式碼畫出來的效果如下:

但是這樣的元件可重用性太低,不僅是文字是寫死的,連背景顏色也不能調。恩…好吧,聰明的你一定發現以上這些都很好改,只要把文字跟背景顏色抽取成 function 的參數就好。但是萬一現在多另外一種 Chip ,左邊有一個打勾的 Icon 要怎麼辦?又或是右邊要有一個叉叉的 Icon ?這樣是不是要寫三個不同的 Chip composable funcion 呢?其實不需要,這時候,可以重構上面 function 並改成 Slot API 的形狀,以提高可用性。

首先,將背景顏色往上抽取成 function 的參數:

@Composable
fun MyChip(backgroundColor: Color) {
Surface(
shape = RoundedCornerShape(50),
modifier = Modifier.padding(8.dp)
.size(width = 60.dp, height = 30.dp)
) {
Box(
Modifier.fillMaxSize()
.background(backgroundColor)
) {
Text(
text = "Dog",
modifier = Modifier.align(Alignment.Center),
textAlign = TextAlign.Center,
color = Color.White
)
}
}
}

接著,新增另外一個參數 content (Slot) :

fun MyChip(backgroundColor: Color, content: @Composable () -> Unit) 

這時候會發現 ChipPreview 出現 Compile error ,我們來將大括弧加到 function 的結尾:

@Preview(showBackground = true)
@Composable
fun ChipPreview() {
MyChip(Color.Magenta) {

}
}

然後把 Text 整段貼到 ChipPreview :

在這邊改成截圖是因為紅色看起來比較明顯

又出現 Compile error 了,這是因為 align 不是任何地方都可以用的,必須要在指定的 scope 裡面才可以使用,由於我們使用的是 Box ,所以接下指定 content 的 scope 為 BoxScope:

@Composable
fun MyChip(backgroundColor: Color, content: @Composable BoxScope.() -> Unit) {

對 BoxScope.() 這語法感到陌生的讀者可以去讀讀 Lambda with Receiver 相關的資料 ,像是 kotlin 官網就有提到 :https://kotlinlang.org/docs/reference/lambdas.html

最後在 Box 的 lambda 區塊中呼叫 content ,就可以得到一摸一樣的結果了:

@Preview(showBackground = true)
@Composable
fun ChipPreview() {
MyChip(Color.Magenta) {
Text(
text = "Dog",
modifier = Modifier.align(Alignment.Center),
textAlign = TextAlign.Center,
color = Color.White
)
}
}

@Composable
fun MyChip(backgroundColor: Color, content: @Composable BoxScope.() -> Unit) {
Surface(
shape = RoundedCornerShape(50),
modifier = Modifier.padding(8.dp)
.size(width = 60.dp, height = 30.dp)
) {
Box(
Modifier.fillMaxSize()
.background(backgroundColor)
) {
content()
}
}
}

結語

本篇解釋了 Slot Api 是什麼,以及為什麼需要它,或許大家可以試著去想看看,有沒有除了 Toobar 之外,讓你很痛苦的自定義元件?那在這樣的設計下,是不是有解決到你的問題呢?順帶一提,在問過同事之後,發現 Swift UI 也是一樣的設計,使用 lambda 來進行 UI 元件的組合,所以這並不是 Jetpack Compose 中獨有或是創新的設計,但要說誰抄襲誰也很難說,搞不好這些 idea 都是從 react 來的也說不定呢(希望有人可以給我解答)。

下一篇:初探 Jetpack Compose — 渲染機制(Rendering)

解答區

  • 不能使用 fillMaxWidth 並置中
@Composable
fun MyToolbar2(title: String) {
TopAppBar() {
Spacer(Modifier)
Text(
text = title,
modifier = Modifier
.align(Alignment.CenterVertically),
style = MaterialTheme.typography.h5
)
Spacer(Modifier)
}
}
  • 標題放在 Icon 的正右邊 (沒有置中)
@Composable
fun MyToolbar2(title: String) {
TopAppBar() {
Icon(
imageVector = vectorResource(id = R.drawable.ic_menu),
modifier = Modifier.align(Alignment.CenterVertically)

)
Text(
text = title,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically),
style = MaterialTheme.typography.h5
)

}
}

--

--