入坑 Jetpack Compose :寫一個簡單的計算器
我正在參加「掘金·啟航計劃」
本文是一個綜合的Compose小例子,涉及動畫、自定義佈局、列表等主題。本文並非教程,只是展示展示Compose開發應用是什麼感覺,並試圖拉人入坑。如果你還沒接觸過,不妨進來掃一掃程式碼,讀一讀單詞,感受感受~
本文所展示的思路僅為個人想法,並不代表最優解,也歡迎一起探討
前言
8月份的時候,我關注了 fundroid 大佬的公眾號,看到歷史推文中有這麼一篇,內容是Compose學習挑戰賽,要求為“實現一個計算器 App”。正好自己對Compose有過一點經驗 (這個可以點開頭像看歷史文章),抱著試試看的態度,我花大概4-5h完成並提交了作品。
儘管作品比較簡單,但結果還是~~不錯的~~(補充:看了看評論區大佬的圖,發現這是個參與紀念獎 hhh):幾天前,我收到了Google發來的這封郵件:
~~既然文章都寫完了,那還是厚著臉皮留著吧~~
所以就簡單介紹下吧,或許也可以當做非常入門的小案例,說不定能幫到些人、拉入點坑。
本文原始碼地址見文末
效果
可以看到,儘管開發的時間並不長,但是基本的小功能也還是有的。計算的時候也會有點簡單的小動畫,還適配了橫屏的佈局。
順帶一提,由於Compose天然的特性,專案還自動適配了深色模式,如下:
實現
以豎屏的佈局為例,它主要包括這幾個部分
或許我們可以分別叫它們:歷史記錄區、表示式區和輸入區
輸入區
之所以先看輸入區,是因為這是頁面的主體部分。從佈局來看,整體為均勻的網格狀。在Compose中,想實現這樣的網格佈局也有幾種選擇,比如使用Lazy系列的LazyGrid
(可以參考我的 Jetpack Compose LazyGrid使用全解)。不過,某種程度上,出於教程的目的,我在這裡用的是自定義佈局+For迴圈
。
自定義佈局?
你可能比較疑惑:這裡為啥需要自定義佈局?這就要從我自己的資料結構說起了。為了表示按鍵的佈局,我用了個二維字元資料
Kotlin
val symbols = arrayOf(
charArrayOf('C','(',')','/'),
charArrayOf('7','8','9','*'),
charArrayOf('4','5','6','-'),
charArrayOf('1','2','3','+'),
charArrayOf('⌫','0','.','=')
)
我希望的效果是呢,每個按鍵都是正方形,因此,輸入區的長寬比需要和二維陣列的行列比一致。也就是,豎屏的時候寬度固定,計算高度;橫屏則反過來。
整個輸入區由一個Box
包裹,因此只需要動態調整它自己的寬高即可。因此,此處使用Modifier.layout
修飾自己。程式碼如下:
kotlin
// 每個正方形的寬度
var l by remember {
mutableStateOf(0)
}
Box(
modifier
.layout { measurable, constraints ->
val w: Int
val h: Int
if (isVertical) {
// 豎屏的時候寬度固定,計算高度
w = constraints.maxWidth
l = w / symbols[0].size
h = l * symbols.size
} else {
// 橫屏的時候高度固定,計算寬度
h = constraints.maxHeight
l = h / symbols.size
w = l * symbols[0].size
}
val placeable = measurable.measure(
constraints.copy(
minWidth = w, // 寬度最大最小值相同,即為確定值
maxWidth = w,
minHeight = h, // 高度也是
maxHeight = h
)
)
// 呼叫 layout 擺放自己
layout(w, h) {
placeable.placeRelative(0, 0)
}
}) {
/*省略Childen,見下文*/
}
如果你沒有接觸過自定義佈局,可以參考如下文章:
- 深入Jetpack Compose——佈局原理與自定義佈局(一) - 掘金 (juejin.cn)
- 深入Jetpack Compose——佈局原理與自定義佈局(二) - 掘金 (juejin.cn)
- 深入Jetpack Compose——佈局原理與自定義佈局(三) - 掘金 (juejin.cn)
- 深入Jetpack Compose——佈局原理與自定義佈局(四)ParentData - 掘金 (juejin.cn)
回到文章,上面已經正確的設定了Box
的大小,接下來往裡面放內容就好。在這裡就是簡單的雙重for迴圈:
Kotlin
symbols.forEachIndexed { i, array ->
array.forEachIndexed { j, char ->
Box(modifier = Modifier
.offset { IntOffset(j * l, i * l) }
.size(with(LocalDensity.current) { l.toDp() })
.padding(16.dp)
.clickable {
vm.click(char)
}) {
Text(modifier = Modifier.align(Alignment.Center), text = char.toString(), fontSize = 24.sp, color = contentColorFor(backgroundColor = MaterialTheme.colors.background))
}
}
}
Box
類似於View
,是最基本的@Composable
。在Compose中,各Composable
的樣式由Modifier
修飾,以鏈式呼叫的方式設定。此處使用.size
修飾符確定了每個按鍵的大小,offset
確定了它們的位置(偏移)。這裡有趣的地方是,因為padding
先於clickable
設定,所以點選的波紋是在padding
區域內的(這是我希望的效果,不然有點醜)。這也是初學者需要注意的一點:Modifier的順序很重要
表示式區域
這個區域很簡單,有趣的地方在於,它是有動畫的。實現這樣的效果或許在xml
裡略顯繁瑣,但在Compose
裡卻相當簡單
```kotlin
@Composable
fun CalcText(
modifier: Modifier,
formulaTextProvider: () -> String,
resultTextProvider: () -> String,
) {
val animSpec = remember {
TweenSpec
val resultText = resultTextProvider()
// 根據 progress 的值計算字型大小(與上面那個變化方向相反)
if (resultText != "") {
Text(text = resultText, (36 - 18 * progress).sp, ...)
}
LaunchedEffect(resultText) {
if (resultText != "") progressAnim.animateTo(0f, animationSpec = animSpec)
else progressAnim.animateTo(1f, animationSpec = animSpec)
}
}
}
``
對,就這麼點!這裡的整體思路是,用
Column(縱向佈局)放置兩個
Text,並在
resultText(也就是計算結果)改變時執行動畫,改變二者的字型大小。
這樣的過程類似於
View體系下的
屬性動畫,但在Compose宣告式
UI=f(State)` 的理念下,寫出的程式碼更自然。這或許是Compose開發上的另一有趣之處。
歷史記錄區
這個區域就更簡單了,就是個列表唄。對於View
使用者,這時候就要開始建xml、寫ViewHolder、設定Adapter
一條龍了。但在Compose
下,一切只需要交給LazyColumn
kotlin
LazyColumn(modifier, state = listState) {
items(vm.histories) { item ->
Text(modifier = Modifier.fillMaxWidth(), text = item.toString())
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
Compose的列表就是這麼簡單,不用花裡胡哨,不用幾個檔案來回跳。告訴它資料來源以及每個item長什麼樣就好。
為了更好看一些,我還順便給它加上了個Item進入動畫:從右往左飛入。程式碼也很簡單
kotlin
items(vm.histories) { item ->
// 偏移量
val offset = remember { Animatable(100f) }
LaunchedEffect(Unit) {
offset.animateTo(0f)
}
Text(modifier = Modifier
...
.offset { IntOffset(offset.value.toInt(), 0) }
...)
}
上面的程式碼裡出現了不少remember
,可以理解為“記住”。Compose的重新整理類似於在重新呼叫函式,於是為了讓某個值能被儲存下來,就得放在remember
裡。
LaunchedEffect
則為副作用的一種,當首次進入Composition
或括號裡的值(key)改變時才執行裡面的內容,在這裡用於啟動動畫。
三個部分介紹完,接下來就是把它們合在一起啦
合在一起
豎屏狀態下,合在一起似乎還有點困難:我們需要先擺放底部的輸入區,等計算完它的寬高後,再在它上面放上歷史記錄和表示式。
要解決這個問題也有挺多方法,比如Column
+weight
修飾符應該就可以。同樣的,出於教程的目的,我這裡還是換了個花裡胡哨的做法:自定義佈局。
```kotlin
/*
* 縱向佈局,先擺放Bottom再擺放,
* @param modifier Modifier
* @param bottom 底部的Composable,單個
* @param other 在它上面的Composable,單個
/
@Composable
fun SubcomposeBottomFirstLayout(modifier: Modifier, bottom: @Composable () -> Unit, other: @Composable () -> Unit) {
SubcomposeLayout(modifier) { constraints: Constraints ->
var bottomHeight = 0
val bottomPlaceables = subcompose("bottom", bottom).map {
val placeable = it.measure(constraints.copy(minWidth = 0, minHeight = 0))
bottomHeight = placeable.height
placeable
}
// 計算完底部的高度後把剩餘空間給other
val h = constraints.maxHeight - bottomHeight
val otherPlaceables = subcompose("other", other).map {
it.measure(constraints.copy(minHeight = 0, maxHeight = h))
}
layout(constraints.maxWidth, constraints.maxHeight) {
// 底部的從 h 的高度開始放置
bottomPlaceables[0].placeRelative(0, h)
otherPlaceables[0].placeRelative(0, 0)
}
}
}
``
程式碼中使用到了
SubcomposeLayout,可以參考
ComposeMuseum`的教程:SubcomposeLayout | 你好 Compose (jetpackcompose.cn)
計算
由於不是重點,所以本文直接跳過了。程式碼裡直接使用的 JarvisJin/fin-expr: A expression evaluator for Java. Focus on precision, can be used in financial system. (github.com) 。
如果需要自己實現,可以參考資料結構-棧
以及BigDecimal
類
狀態儲存
為了實現橫豎屏切換時的狀態儲存,資料放在了ViewModel
裡。在Compose中,使用ViewModel
非常簡單。只需要引入androidx.activity:activity-compose:{version}
包並在@Composable
中如下獲得對應ViewModel
:
kotlin
val vm: CalcViewModel = viewModel()
其他
狀態列
如果你仔細觀察,上面的圖中,為了更好的沉浸式,是沒有狀態列的。這是藉助的accompanist/systemuicontroller 庫。
accompanist
是Google官方提供的一系列Compose輔助library
,幫助快速實現一些常用功能,比如Pager
、WebView
、SwipeToRefresh
等。
使用起來也很簡單:
kotlin
val systemUiController = rememberSystemUiController()
val isDark = isSystemInDarkTheme()
LaunchedEffect(systemUiController){
systemUiController.isSystemBarsVisible = false
// 設定狀態列顏色
// systemUiController.setStatusBarColor(Color.Transparent, !isDark)
}
橫豎屏判斷
此處判斷的依據非常簡單:當前螢幕的“寬度”。通過最外層的BoxWithConstraints
獲取到的constraints.maxWidth
做判斷依據,程式碼如下:
kotlin
BoxWithConstraints(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)) { // 小於720dp當豎屏
if (constraints.maxWidth / LocalDensity.current.density < 720) {
CalcScreenVertical(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp))
} else { // 否則當橫屏
CalcScreenHorizontal(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp))
}
}
通過if
語句就能展示不同的佈局,這也是Compose宣告式UI的有趣之處。
最後
本文程式碼:FunnySaltyFish/ComposeCalculator: A Simple But Not Simple Calculator built by Jetpack Compose (github.com)
(廣告)我寫的另一個更完整的專案:FunnySaltyFish/FunnyTranslation: 基於Jetpack Compose開發的翻譯軟體,支援多引擎、外掛化~ | Jetpack Compose+MVVM+協程+Room (github.com)
- Jetpack Compose 上新:瀑布流佈局、下拉載入、DrawScope.drawText
- 入坑 Jetpack Compose :寫一個簡單的計算器
- 寫出優雅的Kotlin程式碼:聊聊我認為的 "Kotlinic"
- 如何在 Jetpack Compose 中除錯重組
- Kotlin 1.7.0 正式釋出!主要新特性一覽
- Jetpack Compose LazyGrid使用全解
- 【雜談】我用 Jetpack Compose 的這一年
- Jetpack Compose 自定義佈局 物理引擎 = ?
- Jetpack Compose 自定義繪製——高仿Keep周運動資料頁面
- 深入Jetpack Compose——佈局原理與自定義佈局(四)ParentData
- 深入Jetpack Compose——佈局原理與自定義佈局(三)
- Jetpack Compose 通用載入微件的實現
- 基於Jetpack Compose打造一款翻譯APP 【開源】
- Material You :輕輕地我來了
- Python3.10正式版釋出!新特性速覽