Jetpack Compose 的那一兩件事
前言
Jetpack Compose 這玩意從 2018 還 2019 年的 Google IO 就出現了,當時還是 Alpha ,而且台上 Demo 的人還出包,教會了我說一句在 Live debug 的出狀況可以說的一句話:「It work until now.」。過了一些時日,這個 Library 總算進 beta 了,自稱 Forerunner 的我,當然就感興趣地玩了(雖然說真的還是拖了些時日)。
Compose 的意思是創作,不過在語句上,更常代表的是創作歌曲、樂曲等,所以我覺得有些網站在表達 Jetpack Compose 有一個意象不錯,就是 Android 的人偶頭戴捲毛白髮,手拿著指揮棒和比撰寫著樂譜。
Declarative UI
其實一路過來我們都可以發現,Compose 原則上就是參照同為 Google 出品的孿生(?兄弟 Flutter ,Flutter 最廣為人知的地方就是,他是 Declarative UI ,中文就是宣告式 UI。
不過說真的宣告式程設(Declarative Programming)這有甚麼好處,Emm…我們一般寫程式,通常會是指令式程設(Imperative Programming)。
題外話一下 , imperative 在形容語言的中文意思還蠻有趣的,指令式我覺得翻譯有點折衷的感覺,imperatvie 中文譯作祈使語氣,起初看到這個詞彙有 J 殺小的感覺,但祈使語氣其實就是命令或請求的語氣,通俗點講就是台語的:「叫狗」語氣。
那指令式程設和宣告式程設有啥差別呢?恩,指令式程設比較像是告訴程式要怎麼跑,宣告式程設比較偏向告訴什麼要跑,但比起用 How 和 What 去區別,我比較喜歡這個說法:指令式程設就好比你請你朋友來維護、修車,你告訴他要從引擎開始修、換上什麼輪胎要用、甚麼機具、用幾號螺絲、在甚麼時候去把它拆卸下來、怎麼染色、怎麼換機油等等;而宣告式程設則是你請你朋友來修車,你只告訴他你要甚麼顏色,要用甚麼機油,但具體他要怎麼修、怎麼保養你並不在乎。
那麼在 Android 上頭目前最通用的 xml + Java/Kotlin ,如果從指令式/宣告式程設的角度來看的話,我認為會是一半宣告式(xml)一半指令式(Java/Kotlin),畢竟 xml 是標記式語言,並不能做計算,定義上不算程式語言,但總是會有需要修改定義好物件狀態的時候,所以這時候需要透過 Java ,也因此,我們在寫 xml + Java/Kotlin 常常會有低聚合的碎片感(改一下 xml 要跳回 Java/Kotlin 再跳回去 xml),從這個角度來看 Jetpack Compose 收束了這個結構的優勢就出現了。
上述這段是我個人的見解,也是我說服我自己選擇使用 Jetpack Compose 的一個理由。但 xml + Java/Kotlin 還是目前相當主流的作法,也必然有它延續存在的理由。
欸…這邊要說一下,雖然我上面說到 Java ,但 Jetpack Compose 得用 Kotlin ,用 Java 理論上跟 Coroutines 一樣,會寫到崩潰。
前置設定
雖說 Jetpack compose 已經 beta 了,但有幾項 Library 仍在 alpha (e.g. ConstraintLayout compose),且 Preview 的工具需要到 Android Studio Arctic fox(北極狐) 才有。所以建議上還是去裝 Android Studio 目前的 Beta channel來玩(2021/06/14)。
如果只是單純要玩 Compose 的話,在北極狐版本,直接 New 一個新 Project 然後選 Empty Compose Activity 就可以開始寫 Code 了。
只是更多時候,我們是需要在舊有的專案加入 Compose 那就會需要在 Gradle 做點設定。
那你會需要在 gradle 的 dependencies 加上:
implementation "androidx.compose.ui:ui:1.0.0-beta08"
implementation "androidx.compose.material:material:1.0.0-beta08"
implementation "androidx.compose.ui:ui-tooling:1.0.0-beta08"implementation 'androidx.activity:activity-compose:1.3.0-beta01'
此外還要在 Gradle 中的 android 加上
buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.0.0-beta08'
}
不然 AS 會不知道你的 @Composeable
是想幹嘛。
相關的工具
Preview
寫 UI 最需要的就是 Preview 惹,沒有 Preview 很多東西都得通靈,不然一次寫完 build 出來噴了一大串錯誤,結果不知道錯誤在哪裡,這樣的感受實在不好,身為 Android 工程師最討厭 xml 了,但是因為如果純手工用 Java/Kotlin code 硬幹,沒有 Preview 每寫完就要 build 去看結果實在很難受,所以最後還是去用 xml 了。
那 Compose 是有 Preview 可以用的,利用 @preview
可以像用 Xml 一樣把畫面標記在右邊。
那 Preview 畫面只能有一個嗎?否定,可以有好幾個:
這在換 theme 和改變自型或者是要改甚麼狀態的話會很方便。
Color Picker
另外常常會有需要調色的狀況,在北極狐 AS 上,用 Compose 的 Color 可以直接調色。
Image resource picker
那當然地也可以直接換 Icon 用 code。
Deploy preview
另外也是我很愛的一個功能,雖然目前號稱是 Live Edit ,但我覺得狀況還是很多…
所以有 Deploy preview 的功能:
那就可以單獨把 Compose 的內容直接 deploy 到手機上。
Animation and interactive
此外還有個酷東西,但目前在北極狐預設是實驗,所以好像沒開,要先去設定把它打開:
沒錯,它可以 Preview 動畫:
身為一個動畫迷看到塊高潮惹(只是我上頭其他的 Preview 在我開啟動畫的同時通通不見了…我在想放多個動畫應該會出事)。
另外這個 preview 也可以呈現一些簡單的 View 互動(只是 bug bug 的)
Layout
說了一點 Compose 工具的故事,該說點 Compose 自身的故事了。
那根據自古以來的傳統,我們從 Hello World 開始
//...ignore
import androidx.activity.compose.setContent
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text("Hello World")
}
}
}
那我把 Text(“Hello World”)
抽出去變成一個 method ,好方便用在 Preview 上頭。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen()
}
}
}
@Composable
fun MainScreen() {
Text("Hello World")
}
@Preview(showBackground = true, backgroundColor = 0xFFF, heightDp = 240, widthDp = 320)
@Composable
fun PreviewScreen() {
MainScreen()
}
可以看到 Preview 的結果:
那我這邊開心地加上第二行 Text
@Composable
fun MainScreen() {
Text("Hello World")
Text("Second line")
}
阿杯,出事了阿杯。
寫 Android 多年的看到這個可能就會很直覺反應:阿不就是把兩個 TextView 放在 FrameLayout 的樣子嗎?
恩,還真是,雖然 Compose 說是 Compose ,但概念還是離不開這佈局的,那麼要讓兩個 Text
離遠一點,那會需要一個叫 Column
的東西
@Composable
fun MainScreen() {
Column {
Text("Hello World")
Text("Second line")
}
}
那結果就會像這樣
當然如果要橫向當然沒問題,那叫 Row
:
@Composable
fun MainScreen() {
Row {
Text("Hello World")
Text("Second line")
}
}
這跟 Android 的 LinearLayout 根本一個樣(當然更不用說攣生兄弟 Flutter 有個行為一模一樣的東西惹)。
那問題來了,如果我要給小弄一個 Grid ,那會怎麼用呢?先撇開 Experimental 有一個叫做 LazyVerticalGrid
的東西,我們單純用 Column 和 Row 要怎麼弄呢?
@Composable
fun MainScreen() {
Column {
Row {
Text("first line")
Text("Second line")
}
Row {
Text("third line")
Text("firth line")
}
}
}
沒錯,拿過往 Android 的寫法就是 vertical 的 LinearLayout 裏頭有個 Horizontal 的 LinearLayout。
那樣是不是可以加個 Weight?恩還真可以。
@Composable
fun MainScreen() {
Column {
Row {
Text("first line", modifier = Modifier.weight(1f))
Text("Second line", modifier = Modifier.weight(1f))
}
Row {
Text("third line", modifier = Modifier.weight(1f))
Text("firth line", modifier = Modifier.weight(1f))
}
}
}
咦?這樣不會…有層級堆疊導致時間複雜度平方的問題嗎?原則上這跟問 Flutter 有沒有層級堆疊的問題一樣, Compose 使用上的概念和 xml 的 LinearLayout 很像,但兩者在背後的實現上是不同的。 Compose 他所產生出來的 View (就是 ComposeView
)就是很單純的一個 View Group 底下有一個 Child,設定上就會是一層佈局,所以理論上不會有層級堆疊的問題,只是不易外的會意外的有其他問題。
真要探討這個問題可能要另外寫一篇文章了,那這邊就先到這唄。
Modifier
上面已經看到我使用了 Modifier.weight(1f)
,看上面的樣子,身為一個前端可能各種不自在了,那個好貼邊阿?為什麼不置中啊?上下兩個字怎麼貼的這麼近啊?
在過往我們使用 XML 會需要使用 padding 、 margin 這些東西來做調整,那在 Compose 就是利用 modifier 了。
那比如說,剛剛那四行字,看得不是很順眼,是不是能給它們加上一點 padding ?
@Composable
fun MainScreen() {
Column {
Row(modifier = Modifier.weight(1f)) {
Text(
"first line", modifier = Modifier
.weight(1f)
.padding(4.dp)
)
Text(
"Second line", modifier = Modifier
.weight(1f)
.padding(4.dp)
)
}
Row(modifier = Modifier.weight(1f)) {
Text(
"third line", modifier = Modifier
.weight(1f)
.padding(4.dp)
)
Text(
"firth line", modifier = Modifier
.weight(1f)
.padding(4.dp)
)
}
}
}
阿, Padding 出來了,至少不會貼得那麼難受惹。
那會發現重複的 Code 多起來了,這裡其實可以把 Row 提出去變成一個 function:
@Composable
fun MainScreen() {
Column {
RowTwoText(
"first line", "Second line", modifier =
Modifier.weight(1f)
)
RowTwoText(
"third line", "firth line", modifier =
Modifier.weight(1f)
)
}
}
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
Text(fistText, modifier = rowModifier)
Text(secondText, modifier = rowModifier)
}
}
PS: 在這邊 Modifier 的 weight 都是屬於 Column 或者是 Row 的 Extension ,所以得寫在 Column 或者是 Row 的 content Scope 底下才行,直接寫在 Composable function 底下會認不出來。
那我希望他可以置中呢?
這裡可能直覺得會想到 gravity ,不過對 Column 和 Row 而言,並沒有 gravity 這概念,況且對於 Text 來說,他只知道 Row ,Row 對 Column 還得另外設定,沒關係就一步一步來:
首先我要讓我的 Text 對上 Row 的中間,那我可以在 Row 裡面用 Modifier 的 align
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
.align(CenterVertically)
Text(fistText, modifier = rowModifier)
Text(secondText, modifier = rowModifier)
}
}
那可以發現垂直置中了,那水平呢?說到水平可能會很直覺地想到去改 Column ,但別忘記 Text 在這邊是有 Weight 且佔滿的,況且他是 Row 的 Child ,Column 動不了他,所以要改的是 Text 自己內部的字,那 Text 本身有一個 field 叫做 textAlign:
那這邊用 Center 就行了。
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
.align(CenterVertically)
Text(
fistText, modifier = rowModifier,
textAlign = TextAlign.Center
)
Text(
secondText, modifier = rowModifier,
textAlign = TextAlign.Center
)
}
}
看起來不錯。
那麼假如說,我要給這字一點 Click event 呢?
一樣的,在 Modifier 加上 Clickable
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
.align(CenterVertically)
.clickable {
}
Text(
fistText, modifier = rowModifier,
textAlign = TextAlign.Center
)
Text(
secondText, modifier = rowModifier,
textAlign = TextAlign.Center
)
}
}
只是這樣設定很單純就是點了會反灰,一點動感沒有,身為 Android 工程師,我們最愛 Material Design 了,所以我希望他能夠有一點 ripple 反應。
其實有更簡單的方法,但那是我下一個階段要講的東西,這邊就先不劇透了。
在這裡你要告訴 clickable 他的 interaction 的 source 還有 interaction 本身的形式,
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
.align(CenterVertically)
.clickable(
onClick = {
},
interactionSource =
remember { MutableInteractionSource() },
indication =
rememberRipple(bounded = true),
)
Text(
fistText, modifier = rowModifier,
textAlign = TextAlign.Center
)
Text(
secondText, modifier = rowModifier,
textAlign = TextAlign.Center
)
}
}
只是你會很訝異的發現,為什麼我點了 first line ,second line 也跟著反應了?
關鍵是在於那兩個 remember 。
remember 為何物呢?
我們先看一下 composable 的 lifecycle:
原則上就是進 -> 重製 -> 出,進和出問題不大,問題在 recompose 的部分。
試著想一下,在 Composable 之下,有個 TextField 的 text content 為 1 ,若要把 TextField 的 text content 更新為 2 ,那麼方法只有再呼叫一次 composable 的 function 。如果 UI 是單純的 Stateless 那沒啥問題,但好巧不巧, TextField 是可供使用者輸入的元件,那一個畫面更新,把使用者輸入的資料洗掉了,豈不把使用者氣死了?
所以我們會需要一個方法可以保留這種類型的 State,其實方法很多樣化, LiveData、Flow 或者很單純地用 SharedPreference 都是方法,那麼 remember 是一個方法。
remember 其實就是一個跟著 composable 的方法,他會在沒有該值的時候跑後面的 Block ,有值的時候,直接拿對應的 reference 。
這樣就能解釋為什麼點一個會有兩個反應了,因為照上面的寫法,兩個 Text 吃得會是同一個 reference,所以若是要做出區別的話,需要寫成這樣:
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
.align(CenterVertically)
Text(
fistText, modifier = rowModifier
.clickable(
onClick = {
},
interactionSource =
remember { MutableInteractionSource() },
indication =
rememberRipple(bounded = true),
),
textAlign = TextAlign.Center
)
Text(
secondText, modifier = rowModifier
.clickable(
onClick = {
},
interactionSource =
remember { MutableInteractionSource() },
indication =
rememberRipple(bounded = true),
),
textAlign = TextAlign.Center
)
}
}
這樣就能解決同時反應的問題惹。
那個 Ripple 方方的我不喜歡,有沒有辦法解決呢?
直接在 modifier 上加上 clip 就行了。
@Composable
fun RowTwoText(fistText: String, secondText: String, modifier: Modifier) {
Row(modifier = modifier) {
val rowModifier = Modifier
.weight(1f)
.padding(4.dp)
.align(CenterVertically)
.clip(RoundedCornerShape(4.dp))
Text(
fistText, modifier = rowModifier
.clickable(
onClick = {
},
interactionSource =
remember { MutableInteractionSource() },
indication =
rememberRipple(bounded = true),
),
textAlign = TextAlign.Center
)
Text(
secondText, modifier = rowModifier
.clickable(
onClick = {
},
interactionSource =
remember { MutableInteractionSource() },
indication =
rememberRipple(bounded = true),
),
textAlign = TextAlign.Center
)
}
}
List
還蠻有趣的,寫 App 原則上離不開這東西,Android 傳統上的 List 和後續繼承的 RecylcerView 都是用來做這件事情的。但三者原則上就是 List -> Adapter -> item view 這三個相互關係在作用,那 Compose 因為 Layout 的邏輯已經跟原生的 View 不一樣了,所以不需要用到 Adapter 。
@Composable
fun MainScreen() {
Column {
repeat(100) {
Text("Text $it", Modifier.padding(4.dp))
}
}
}
這概念其實還蠻簡單的,就是在一個 Column 裏頭重複 Text 100 次,
看狀況是沒問題,唯一的問題是,這樣寫是不能 Scroll 的。
寫 Android 的人可能會很直覺地想到,外面是不是要包一個 ScrollView,但在 Compose 不是這樣,這很意外(其實也沒啥好意外的)是 Modifier 的職責:
@Composable
fun MainScreen() {
// 這個是滑動的 State ,可以用它保留狀態,也可以用它控制滑動行為
val scrollState = rememberScrollState()
Column(Modifier.verticalScroll(scrollState)) {
repeat(100) {
Text("Text $it", Modifier.padding(4.dp))
}
}
}
不過到這邊大家可能會注意到,我剛用的是 ScrollView ,而非 ListView。
沒錯,這不會 reuse ,在那 repeat 100 之間,已經把全部一百個 Text 都給 render 出來啦!
老問題,這個會有效能問題,因為畫了很多根本沒有看到的東西。那該怎麼辦呢? Compose 有提供 ListView 或是 RecyclerView 嗎?
有的,就叫 LazyColumn (當然也有個 LazyRow ,不過這邊就不提了)。
@Composable
fun MainScreen() {
// 這個是滑動的 State ,可以用它保留狀態,也可以用它控制滑動行為
val scrollState = rememberLazyListState()
val list = mutableListOf<String>().apply {
repeat(100) {
add("Lazy Text $it")
}
}
LazyColumn(state = scrollState) {
items(list.size) {
Text(list[it], Modifier.padding(4.dp))
}
}
}
其實可以發現到 items 後面的 block 可以當作是 RecyclerView 的 onViewHolderBind,
那如果有多種類型的 View 可以寫成這樣:
sealed class LazyItem {
data class LazyItemA(val contentText: String) : LazyItem()
data class LazyItemB(val contentText: String) : LazyItem()
data class LazyItemC(val contentText: String) : LazyItem()
}
@Composable
fun MainScreen() {
// 這個是滑動的 State ,可以用它保留狀態,也可以用它控制滑動行為
val scrollState = rememberLazyListState()
val list = mutableListOf<LazyItem>().apply {
repeat(100) {
when {
it % 3 == 0 -> {
add(LazyItem.LazyItemA("Lazy Text $it"))
}
it % 2 == 0 -> {
add(LazyItem.LazyItemB("Lazy Text $it"))
}
else -> {
add(LazyItem.LazyItemC("Lazy Text $it"))
}
}
}
}
LazyColumn(state = scrollState) {
items(list.size) {
when (val item = list[it]) {
is LazyItem.LazyItemA -> {
Text(
item.contentText,
Modifier.padding(4.dp),
style = MaterialTheme.typography.h6
)
}
is LazyItem.LazyItemB -> {
Text(
item.contentText,
Modifier.padding(4.dp),
style = MaterialTheme.typography.body1
)
}
is LazyItem.LazyItemC -> {
Text(
item.contentText,
Modifier.padding(4.dp),
style = MaterialTheme.typography.subtitle1
)
}
}
}
}
}
這可以做的變化其實很多,總而言之不用 Adapter 可以把寫法簡化很多了。
Theming
Android 的 xml 的 Theme 原則上就是一個巢狀繼承到一個很極致的產物,而且他會是被迫需要寫成這副德性的。
我自己在用 Android 的 Theme 很常碰到的是我可能在某個頁面我需要換掉一個 Secondary 的 Color ,需要新寫一個 Style ,然後繼承既有的 theme (還是 Style ? anyway 都叫做 R.style…)然後換掉該顏色後,實際套用後各種崩壞,接著就要開始往源頭去追,因為巢狀繼承的關係,得花點時間找出究竟是甚麼地方在搞事。
追根究柢我覺得最根本的原因是因為 XML 的 style 定義並不允許計算。以 xml 的寫法就會變成是我這邊一層挖個洞,我那一層補個土,最後出事(可能某個顏色沒對到)要找出問題來就得一層一層往上追。
當然我不否認這玩意有好用的地方,但是總得花上很大的心力去穩定就是了。
那 Compose 的 Theme 呢?我認為雖然會有一樣的狀況,但至少在設定上是可以做判斷的,這樣其實就相對於不能判斷不能計算的 xml 靈活上許多。
先從我們 Android 工程師最愛的 Material Design 開始唄
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
MainScreen()
}
}
}
}
這樣我 MainScreen 就會套用 MaterialTheme 了,另外這也順道把剛才點擊 Ripple 的效果給打包進去了。
但這樣只是用 MaterialTheme ,並沒有到 Custom Theme 的程度呀。不急,這就來自定 Theme。
@Composable
fun CustomTheme(content: @Composable () -> Unit) {
MaterialTheme(content = content)
}class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CustomTheme{
MainScreen()
}
}
}
}
看起來就四行,那能設定啥呢?恩,能設定的可多了,總之先從 Color 開始吧。
Color
Compose 的 Color 很好玩,除了我們最熟的 16 進位標色號外,也可以用 RGB 三原色的十進位來設定:
那我們最常碰到的是,當我們需要設定 Dark Mode 的時候,就得使用 Theme 來做調整。
根據我自身的經驗,用 Xml 設定 Dark mode 的話真的有夠麻煩的,但那是另一個故事,那麼我現在要用 Compose 設定一個 Dark mode 呢?
總之先把對應的 Preview 先給寫出來:
@Composable
fun MainScreen() {
Column {
Text(
"subtitle1",
Modifier.padding(4.dp),
style = MaterialTheme.typography.subtitle1
)
Text(
"subtitle2",
Modifier.padding(4.dp),
style = MaterialTheme.typography.subtitle2
)
Text(
"body1",
Modifier.padding(4.dp),
style = MaterialTheme.typography.body1
)
Text(
"body2",
Modifier.padding(4.dp),
style = MaterialTheme.typography.body2
)
Text(
"button",
Modifier.padding(4.dp),
style = MaterialTheme.typography.button
)
Text(
"caption",
Modifier.padding(4.dp),
style = MaterialTheme.typography.caption
)
Text(
"overline",
Modifier.padding(4.dp),
style = MaterialTheme.typography.overline
)
}
}
那我這邊會需要兩個 Preview ,一個 preview light mode 、一個 preview dark mode:
@Preview(showBackground = true, backgroundColor = 0xFFF, heightDp = 200, widthDp = 320)
@Composable
fun PreviewScreen() {
CustomTheme {
MainScreen()
}
}
@Preview(showBackground = true, backgroundColor = 0xF000, heightDp = 200, widthDp = 320)
@Composable
fun PreviewDarkModeScreen() {
CustomTheme {
MainScreen()
}
}
要注意兩者目前的差異只在 background 。
那結果出來是這樣,問題其實很明顯,在 Dark Mode 下,下面完全是國防部,啥都看不見。
那這邊要改一下 CustomTheme 的設定,好讓他可以在 Preview 辨別是否為 dark mode.
@Composable
fun CustomTheme(
isDarkMode: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (isDarkMode) {
darkColors()
} else {
lightColors()
}
MaterialTheme(
colors = colors,
content = content
)
}
這裡再改一下 Preview
@Preview(showBackground = true, backgroundColor = 0xF000, heightDp = 200, widthDp = 320)
@Composable
fun PreviewDarkModeScreen() {
CustomTheme(isDarkMode = true) {
MainScreen()
}
}
那再來替 Text 套上對應的顏色,這邊用 onBackground
@Composable
fun MainScreen() {
Column {
Text(
"subtitle1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle1
)
Text(
"subtitle2",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle2
)
Text(
"body1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body1
)
Text(
"body2",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2
)
Text(
"button",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.button
)
Text(
"caption",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.caption
)
Text(
"overline",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.overline
)
}
}
結果就是這樣,其實可以寫得略短一點。
Typography
我之前一直有 TextView 要換字型很麻煩的印象,但是後來實際看感覺其實也…挺麻煩的。
話說回來,在 Compose 如果有要粗體變換自行等需求其實也是很簡單的,例如說我要斜體:
Text(
"subtitle2",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle2.copy(
fontStyle = FontStyle.Italic
)
)
那就會得到一個斜體的字:
那例如說我希望能夠換個字體:
Text(
"subtitle1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle1.copy(
fontFamily = FontFamily.Cursive
),
)
這是 Android自帶的字體,自然能夠直接用:
那理所當然的 Compose 也是可以換自訂字型,導入 font 的方法原則上跟原本一樣,是個有點麻煩的前置作業。
以 Google 的 Rubik 為例子,那一樣是把三個字形檔放進 font 裏頭:
然後設定 fontFamily ,這裡比較不一樣的是用 Compose 不用 xml 惹。
val Rubik = FontFamily(
Font(R.font.rubik_regular),
)
那就可以在 style 或者是 Text 的地方直接用了:
Text(
"subtitle1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle1.copy(
fontFamily = Rubik,
),
)
那可能會有人好奇像是粗體、斜體、中間畫線這些要怎麼用呢?
像是粗體、斜體是屬於 TextStyle 的 fontWeight ,而穿堂線和畫底線則是 TextDecoration:
Text(
"subtitle1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle1.copy(
fontFamily = Rubik,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.LineThrough
),
)
這些其實都能夠在 Style 直接設定。
Shape
我還記得早期在碰到 image 需要自己畫圓的狀況得自己去 Custom View 自己 clip,有一點搞剛,後來在 Material Design 中有提供一個叫做 ShapeImageView ,切圖畫圓會比較省事,不過我覺得怪的是,為什麼沒有一個 ShapLayout 呢?因為 ImageView 在較早期的版本沒有 Foreground 的支援,所以我在某些需要在圖片上蓋一層 Foreground 的時候就挺麻煩的。
那 Jetpack Compose 有提供 Shape 的設定,可以切的形狀還不少
用法其實也沒很難,可以直接在 Theme 做設定:
@Composable
fun CustomTheme(
isDarkMode: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (isDarkMode) {
darkColors()
} else {
lightColors()
}
val shapes = Shapes(
small = RoundedCornerShape(percent = 50),
medium = RoundedCornerShape(0f),
large = CutCornerShape(
topStart = 16.dp,
topEnd = 0.dp,
bottomEnd = 0.dp,
bottomStart = 16.dp
)
)
MaterialTheme(
colors = colors,
content = content,
shapes = shapes
)
}
然後要用時直接在 Theme 上導用
@Composable
fun MainScreen() {
Column {
Surface(shape = MaterialTheme.shapes.small, color = Color.Gray) {
Text(
"subtitle1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle1.copy(
fontFamily = Rubik,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.LineThrough
),
)
}
Surface(shape = MaterialTheme.shapes.medium, color = Color.Gray) {
Text(
"subtitle2",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.subtitle2.copy(
fontStyle = FontStyle.Italic
)
)
}
Surface(shape = MaterialTheme.shapes.large, color = Color.Gray) {
Text(
"body1",
Modifier.padding(4.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body1.copy(
fontSynthesis = FontSynthesis.Weight,
)
)
}
}
那要蓋在 Surface 上頭:
Graphics
我們在寫 App 的時候偶爾會有需要直接畫 Canvas 的情況,那這個時候需要寫一個 Class 繼承 View 然後在 onDraw 裏頭動手。
那如果在 Compose 碰上的一樣的情況,那總不能寫一個 Class 然後繼承一個 View 然後在把這個 View 放進 Compose 裏頭吧(其實也是可以啦)。
Compose 有提供一個元件叫做 Canvas ,可以做到這件事情:
@Composable
fun MainScreen() {
Canvas(modifier = Modifier.fillMaxWidth()){
}
}
那如果要畫個線就是這樣:
@Composable
fun MainScreen() {
Canvas(modifier = Modifier.fillMaxWidth()){
val canvasWidth = size.width
val canvasHeight = size.height
drawLine(
start = Offset(x = canvasWidth, y = 0f),
end = Offset(x = 0f, y = canvasHeight),
color = Color.Blue
)
}
}
畫面會長這樣:
當然也可以畫兩條線:
@Composable
fun MainScreen() {
Canvas(modifier = Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
drawLine(
start = Offset(x = canvasWidth, y = 0f),
end = Offset(x = 0f, y = canvasHeight),
color = Color.Blue
)
drawLine(
start = Offset(x = 0f, y = 0f),
end = Offset(x = canvasWidth, y = canvasHeight),
color = Color.Blue
)
}
}
另外也能畫個圓:
@Composable
fun MainScreen() {
Canvas(modifier = Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
drawLine(
start = Offset(x = canvasWidth, y = 0f),
end = Offset(x = 0f, y = canvasHeight),
color = Color.Blue
)
drawLine(
start = Offset(x = 0f, y = 0f),
end = Offset(x = canvasWidth, y = canvasHeight),
color = Color.Blue
)
drawCircle(
color = Color.Blue,
center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
radius = size.minDimension / 4
)
}
}
基本畫法跟原本用 View 的畫法相差不大。
值得注意的是 Canvas 沒有辦法直接放在 Layout 底下(因為寬高沒有辦法界定)所以需要加一層 Surface
@Composable
fun MainScreen() {
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
drawCircle(
color = Color.Blue,
center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
radius = size.minDimension / 4
)
}
}
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
drawCircle(
color = Color.Blue,
center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
radius = size.minDimension / 4
)
}
}
}
}
那可能會注意到,所有繪製的動作全部都是在一個叫做 DrawScope 的 lambda method 裏頭畫的:
那原則上就把它當成是 canvas 就可以了。
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
drawRect(
color = Color.Green,
size = size
)
}
}
}
那這裡也可以用一個叫 inset 的玩意,去調整欲繪製的大小:
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
inset(canvasWidth/4, canvasHeight/4) {
drawRect(
color = Color.Green,
size = size
)
}
}
}
}
這 inset 的值比較特別些,分別是叫 Horizontal 和 Vertical ,這分別是左右和上下兩側佔的數值。比方說我在 Horizontal 設 80 ,則左右兩側會有 80 的空隙。
只是有個問題是, Compose 的 Canvas 並沒有提供 DrawText 的 method,所以如果要在 Compose 的 Canvas 上畫字的話,情況會變得有點尷尬:
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
val paint = Paint()
drawContext.canvas.nativeCanvas.drawText(
"Android",
center.x,
center.y,
paint
)
}
}
}
只是我認真地說這樣畫整個寫法要在 Compose 和 Native 之間來回切換,問題有夠多的,應該是因為 Compose 更傾向使用 Text 去做這件事情吧。
那 Compose 一樣有 Rotate,只是過往在 native view 上 rotate,是轉動 Canvas 去 Draw 的,但 Compose 轉動要畫的東西,舉例來說:
@Composable
fun MainScreen() {
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
rotate(50f) {
inset(canvasWidth / 4, canvasHeight / 4) {
drawRect(
color = Color.Green,
size = size
)
}
}
drawRect(
color = Color.DarkGray,
topLeft = Offset(
x = canvasWidth / 2,
y = canvasHeight / 2
),
size = size / 8f
)
}
}
}
}
那可以注意到我 rotate 的部分只有綠色的那個方塊,並不影響暗灰色那塊方塊。那是因為在過往我們使用 Canvas rotate 的會是"開始" draw 的位置,而對 Compose 來說 rotate 是直接 draw 的位置。
所以說若是
@Composable
fun MainScreen() {
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
rotate(50f) {
inset(canvasWidth / 4, canvasHeight / 4) {
drawRect(
color = Color.Green,
size = size
)
}
drawRect(
color = Color.DarkGray,
topLeft = Offset(
x = canvasWidth / 2,
y = canvasHeight / 2
),
size = size / 8f
)
}
}
}
}
}
或者是:
@Composable
fun MainScreen() {
Column {
Surface(Modifier.weight(1f)) {
Canvas(Modifier.fillMaxWidth()) {
val canvasWidth = size.width
val canvasHeight = size.height
rotate(50f) {
inset(canvasWidth / 4, canvasHeight / 4) {
drawRect(
color = Color.Green,
size = size
)
}
}
rotate(50f) {
drawRect(
color = Color.DarkGray,
topLeft = Offset(
x = canvasWidth / 2,
y = canvasHeight / 2
),
size = size / 8f
)
}
}
}
}
}
這兩者繪製出來的結果會是一樣的:
這點正是 Declarative 和 Imperative 的使用差異。
Gestures
其實還有一個 Animation ,但 Animation 很強大,強大的讓人興奮。但我想加進來好像篇幅會不得裡(但哪次就得了惹),我想分出來寫了。
Tapping and pressing
那前面也說過惹點擊事件的使用:
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
ClickableSample()
}
}
@Composable
fun ClickableSample() {
val count = remember { mutableStateOf(0) }
// content that you want to make clickable
Text(
text = count.value.toString(),
modifier = Modifier
.clickable { count.value += 1 }
)
}
Compose 也自帶了很多的點擊事件,大致上是這四種是這樣:
@Composable
fun ClickableSample() {
val content = remember { mutableStateOf("ClickMe") }
// content that you want to make clickable
Text(
text = content.value,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
content.value = "onPress"
},
onDoubleTap = { offset ->
content.value = "onDoubleTap"
},
onLongPress = { offset ->
content.value = "onLongPress"
},
onTap = { offset ->
content.value = "onTap"
}
)
}
)
}
要注意 onPress 和 onTap 是會衝突的,一個是設計給按鈕用的,另一個是純設計給手勢用的,沒事不要把兩個功能會重疊的東西放在一塊:
Scrollable
還記得上面寫得這樣:
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
ScrollBoxesSmooth()
}
}
@Composable
private fun ScrollBoxesSmooth() {
val state = rememberScrollState()
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.padding(horizontal = 8.dp)
.verticalScroll(state)
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
rememberScrollState 是給 List 用的,一樣是可以拿到 offset 的:
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val state = rememberScrollState()
ScrollBoxesSmooth(state)
Text(text = state.value.toString())
}
}
@Composable
private fun ScrollBoxesSmooth(
state: ScrollState = rememberScrollState()
) {
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.padding(horizontal = 8.dp)
.verticalScroll(state)
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
但這個數值其實也是經過 min 和 max 固定了,有些時候我們會需要更確切的數字,那就會需要用到 ScrollableController 了。
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
ScrollBoxesSmooth()
}
}
@Composable
private fun ScrollBoxesSmooth() {
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
那把 Orientation.Veritcal 換成 Orientation.Horizontal 就會變成橫向的。
這裡有點討厭的是這段要自己寫,不然 by 那邊會 build 不過。
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
那其實可以看出來 offset 和 value 很清楚了,所以要弄一個 nestScroll 就不會太困難了。
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
ScrollBoxesSmooth()
}
}
@Composable
private fun ScrollBoxesSmooth() {
val state = rememberScrollState()
Column(
modifier = Modifier
.background(Color.LightGray)
.size(200.dp)
.padding(horizontal = 8.dp)
.verticalScroll(state)
) {
repeat(10) {
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.padding(horizontal = 8.dp)
.verticalScroll(rememberScrollState())
) {
repeat(10) {
Text("Item $it",
modifier = Modifier.padding(2.dp))
}
}
}
}
}
但遺憾的是 LazyColumn 不能用,如果用類似的方式這樣寫就會噴錯:
Dragging
這也是另一個常用的手勢,在 Compose 上實現也不難:
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
DraggableBox()
}
}
@Composable
private fun DraggableBox() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
Multitouch
那 Compose 也是支援多點觸碰的,使用的是 graphicsLayer
@Composable
fun MainScreen() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TransformableSample()
}
}
@Composable
fun TransformableSample() {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState {
zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offset += Offset(dragAmount.x, dragAmount.y)
}
}
.transformable(state = state)
.background(Color.Blue)
.size(150.dp)
)
}
要注意的是 graphicsLayer 只支援多點觸碰,這邊如過要另外設定單點拖曳的話,則還需要加上 drag。
此外需要注意的部分是,因為是指用 transformable ,所以轉動的會是這個 Box 本身,觸碰邏輯沒有轉,也就是說, Box 的方向轉了,但是觸碰事件的方向並沒有跟著轉,所以轉 180 度之後再 drag 弄會有逆佛的現象發生,這點在使用上需要注意。
結語
這篇文章後來斷斷續續寫了三個星期,北極狐 beta 3 都給他寫到 beta 4 了。
其實 2018 年看到這玩意出來有這麼一點不看好,但後來看覺得 Google 花了不小心力在這玩意上頭,本來想去碰的,但想到我當初玩 CameraX 在 Alpha 時代,升級一個版本世界又都不一樣了。
後來硬是等到 beta 這才開始玩,整體進場的時機有點偏晚了,好像都漲到高點了(?,進場晚吃人家魚尾了。
Compose 跟 Flutter 的概念真的很像,畢竟孿生堂親互相參照的,有一樣的概念無可厚非,但老樣的,寫習慣 Android 自己的 Native 轉過來會有點不適應也是真的。
其實過程中我最好奇的(馬的我本來只是想寫個入門,結果還把 RenderObject ← → Element ← → Widget 三者給讀了讀),還是層級堆疊的問題,更甚的是,我從接觸 Flutter 開始就有的疑問:不斷的重複把 Layout instance 放上去這不耗費資源嗎?
這邊講簡單的,其實我們在 Flutter 和 Compose 上寫的 Widget 都是類似一個純藍圖,那這個藍圖會再透過一個物件(在 Flutter 叫做 element)做 diff 後在畫上去,也就是說這藍圖相對的輕盈,某程度上你要怎麼重新 instance 都不會差太多。
這其實就是 Compose (或說是 Flutter) 和 Android Native View 最根本的差異了,所以我幾年前有寫過一篇關於層級效能的問題,把兩者拿來相比,會顯得相對詭異。總之,這是我計劃下一個研究身體健康的目標了,會不會有研究成果出來就看緣分了。
如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。
參考資料
https://developer.android.com/jetpack/compose/documentation
https://developer.android.com/jetpack/compose/mental-model?hl=ru#recomposition
https://developer.android.com/codelabs/jetpack-compose-layouts#0