入坑 Jetpack Compose :寫一個簡單的計算器

語言: CN / TW / HK

我正在參加「掘金·啟航計劃」


本文是一個綜合的Compose小例子,涉及動畫、自定義佈局、列表等主題。本文並非教程,只是展示展示Compose開發應用是什麼感覺,並試圖拉人入坑。如果你還沒接觸過,不妨進來掃一掃程式碼,讀一讀單詞,感受感受~
本文所展示的思路僅為個人想法,並不代表最優解,也歡迎一起探討

前言

8月份的時候,我關注了 fundroid 大佬的公眾號,看到歷史推文中有這麼一篇,內容是Compose學習挑戰賽,要求為“實現一個計算器 App”。正好自己對Compose有過一點經驗 (這個可以點開頭像看歷史文章,抱著試試看的態度,我花大概4-5h完成並提交了作品。
儘管作品比較簡單,但結果還是~~不錯的~~(補充:看了看評論區大佬的圖,發現這是個參與紀念獎 hhh):幾天前,我收到了Google發來的這封郵件:

image.png

~~既然文章都寫完了,那還是厚著臉皮留著吧~~
所以就簡單介紹下吧,或許也可以當做非常入門的小案例,說不定能幫到些人、拉入點坑。
本文原始碼地址見文末

效果

Screenrecorder.gif
可以看到,儘管開發的時間並不長,但是基本的小功能也還是有的。計算的時候也會有點簡單的小動畫,還適配了橫屏的佈局。
順帶一提,由於Compose天然的特性,專案還自動適配了深色模式,如下:

Screenshot.jpg

實現

以豎屏的佈局為例,它主要包括這幾個部分

image.png

或許我們可以分別叫它們:歷史記錄區、表示式區和輸入區

輸入區

之所以先看輸入區,是因為這是頁面的主體部分。從佈局來看,整體為均勻的網格狀。在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(500) } Column(modifier = modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) { val progressAnim = remember { Animatable(1f, 1f) } // 進度,1為僅有算式,0為結果 val progress by remember { derivedStateOf { progressAnim.value } } // 根據 progress 的值計算字型大小 Text(text = formulaTextProvider(), fontSize = (18 + 18 * progress).sp, ...)

    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中如下獲得對應ViewModelkotlin val vm: CalcViewModel = viewModel()

其他

狀態列

如果你仔細觀察,上面的圖中,為了更好的沉浸式,是沒有狀態列的。這是藉助的accompanist/systemuicontroller 庫。
accompanist是Google官方提供的一系列Compose輔助library,幫助快速實現一些常用功能,比如PagerWebViewSwipeToRefresh等。
使用起來也很簡單: 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)