初探 Compose for Wear OS:實現一個簡易選擇APP

語言: CN / TW / HK

theme: channing-cyan

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第10天,點選檢視活動詳情

前言

俗話說,人生有三大難題:早上吃啥、中午吃啥、晚上吃啥。 \ 這個問題一度困擾著無數的人,直到一款幫你選擇吃什麼的神器《今天吃啥》出現,人們再也不用為了每天吃啥而犯愁了。

哈哈,以上純屬抖機靈。

最近訪問谷歌開發者官網時發現首頁 Banner 改成了 Wear OS 專題,其中有一項就是 Compose for Wear OS,恰好最近在學習 Compose ,於是我就摩拳擦掌躍躍欲試。但是我的學習風格是在做中學,以實際專案作為載體來學習,那麼這次做一個什麼呢?

想了想,可以做一個吃什麼選擇器,這種東西沒什麼難度,而且也兼具實用與玩樂,最關鍵的是這種型別APP如果做成手機APP會略顯臃腫,更適合做小程式或網頁。但是,如果把APP裝到手錶上,那感覺也不一樣了,畢竟誰不想一擡手就能選好吃的呢?

說幹就幹,咱們這就開始學習。

老規矩,在開始前先看看預覽效果:

s6.gif

禁用效果(僅開啟了前面兩個,剩下的全部禁用):

s7.gif

開始學習

Wear OS 簡介與開發原則

Wear OS 同樣是基於 Android 系統,只不過它為手錶或者說可穿戴裝置做了專門的優化。

正因為 Wear OS 基於 Android ,所以我們甚至可以直接將原本移動應用的程式碼直接複用到 Wear OS 上,但是,Wear OS 不適合,也不應該用於處理繁重的任務。這就是 Wear OS 的開發原則之一:只針對關鍵任務進行設計

由於 Wear OS 搭載的裝置都是可穿戴裝置,所以使用者可能無法長時間舒適的去操作裝置。所以我們在開發應用時應該充分考慮到這一特性,儘可能簡化應用操作,讓使用者只需要幾秒鐘就能完成操作。此即 針對腕部佩戴進行優化

其他還有諸如 支援離線場景提供相關的內容 等等開發原則,我們就不在這裡過多講述。可以自行檢視文件:Principles of Wear OS development

Compose for Wear OS

Wear OS 上的 Compose 與標準 Compose 幾乎別無二致,他們也擁有相同的 API 和 用法。

只是 Wear OS 上多了一些特定的元件,例如: ScalingLazyColumnChip 等。

另外,雖然他們擁有幾乎一致的 API,但是實際上他們使用的依賴和包名有所不同,例如:

| Weao OS 依賴 | 標準依賴 | |-------------|----------| | androidx.wear.compose:compose-material | androidx.compose.material:material | | androidx.wear.compose:compose-navigation | androidx.navigation:navigation-compose | | androidx.wear.compose:compose-foundation | androidx.compose.foundation:foundation |

當然,這並不意味著我們需要自己手動更改依賴,因為 Android Studio 建立專案模板中已經包含了 Wear OS 的模板,我們只需要在建立時選擇這個模板即可:

s1.png

設計頁面

整體佈局

我們的目標是做一個吃什麼選擇APP,但是 Wear OS 的螢幕不同於手機螢幕,表現於螢幕一般偏小,可容納元件也少。

而且螢幕可能甚至都不是一個矩形螢幕,很可能是一塊圓形的螢幕,這就意味著我們需要妥善處理元件UI溢位螢幕範圍的情況。

好在 Compose 已經為我們提供了現成的佈局結構框架: Scaffold 這個框架為我們提供了很多可用的 “插槽” 我們只需要把對應的東西“插”進去即可。

其實這個 Scaffold 在標準 Compose 中也有提供,不過在標準 Compose 中,提供的槽位是用來放頂部標題欄(topBar)、底部導航欄(bottomBar)、懸浮按鈕(floatingActionButton)、抽屜導航(drawerContent)等等內容。

而在 Wear OS 的 Scaffold 中有以下槽位:

kotlin @Composable public fun Scaffold( modifier: Modifier = Modifier, vignette: @Composable (() -> Unit)? = null, positionIndicator: @Composable (() -> Unit)? = null, pageIndicator: @Composable (() -> Unit)? = null, timeText: @Composable (() -> Unit)? = null, content: @Composable () -> Unit )

vignette 表示的是為螢幕新增模糊效果,例如為螢幕的底部和頂部新增模糊效果,以對中心顯示內容表示強調:

c1.png

positionIndicator 表示在螢幕邊緣(一般是右側)新增一個位置指示UI,例如為這個垂直滾動列表新增的位置指示:

c2.png

pageIndicator 表示新增一個頁面指示UI,因為在 Wear OS 中,通常通過左右滑動來切換不同的頁面,所以可以用這個槽新增一個當前頁面位置:

c3.png

timeText 表示新增一個位於介面頂部的時間指示UI,因為設計原則中要求最好在需要長時間停留的介面新增時間指示,畢竟 Wear OS 大多數時候都是手錶,如果一個手錶連時間都不能看,那還有什麼用呢?

c4.png

確定了使用 Scaffold 後的佈局結構,我們大概也知道我們的 APP 整體的 UI 佈局應該是什麼樣的了。

大致就是分為兩個頁面:

第一個頁面使用可滾動佈局顯示主要UI(開始按鈕和菜名文字)、以及向下滾動後應該可以選擇禁用菜名列表中的某些菜。

第二個頁面依舊使用可滾動佈局顯示設定選項,主要用於增刪改查菜名列表內容以及選擇使用哪個菜名列表,由於這個功能需要和手機連線來同步資料,而我的表還沒發貨,所以暫時不做這個頁面了,等手錶到了再寫。

兩個頁面之間可以通過左右滑動切換。

實現主頁

首先寫出基礎框架:

```kotlin @Composable fun WearApp() { WearOScomposetestTheme { val listState = rememberScalingLazyListState()

    Scaffold(
        timeText = {
            if (!listState.isScrollInProgress) {
                TimeText()
            }
        },
        vignette = {
            Vignette(vignettePosition = VignettePosition.TopAndBottom)
        },
        positionIndicator = {
            PositionIndicator(
                scalingLazyListState = listState
            )
        }
    ) {
        ScalingLazyColumn(
            modifier = Modifier.fillMaxSize(),
            state = listState,
            autoCentering = AutoCenteringParams(itemIndex = 0)
        ) {
            // 內容列表
            // ……
        }
    }

}

} ```

上面程式碼中使用 TimeText() 顯示當前實時時間,另外我們還加了一個判斷,如果正在滾動時則不顯示。

使用 Vignette(vignettePosition = VignettePosition.TopAndBottom) 模糊螢幕上下邊緣。

使用 PositionIndicator(scalingLazyListState = listState) 指示當前 ScalingLazyColumn item 的位置。

主要頁面使用 ScalingLazyColumn 作為父佈局。

ScalingLazyColumn 類似於標準 Compose 中的 LazyColumn 。但是有一點不同,那就是會自動縮放 item 以適配當前螢幕。

因為我們上面說過搭載 Wear OS 的裝置有很多螢幕是圓的,這就意味著高度不同的元件可顯示的寬度是不同的,而 ScalingLazyColumn 會通過縮放和淡入淡出的方式自動幫我們處理不同寬度顯示:

c5.gif

不知道看到這裡讀者們有沒有一個疑問,既然圓形螢幕寬度不一致,且越遠離螢幕中心寬度越小,那在滾動佈局中豈不是意味著前幾個 item (例如第一個),永遠也無法被移動到最中間實現最大寬度顯示了?

沒錯,確實存在這個問題,所以 ScalingLazyColumn 為我們提供了一個引數 autoCentering 用於解決這個問題。

例如上面程式碼中我們將這個引數設定為了 AutoCenteringParams(itemIndex = 0) 這表示自動為第一個 item 新增填充和偏移量,使得第一個 item 也可以被下拉到最中間。

s2.png

在這個截圖中,中間的按鈕實際上是第一個 item,但是現在由於我們設定了 AutoCenteringParams(itemIndex = 0) 所以它可以被下拉到最中間,如果不能被下拉的話將是這樣:

s3.png

接下來,我們往這個基礎框架中填充內容,首先是開始按鈕:

kotlin @Composable fun StartButton( icon: ImageVector, onClick: () -> Unit ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { Button( modifier = Modifier.size(ButtonDefaults.LargeButtonSize), onClick = onClick ) { Icon( imageVector = icon, contentDescription = icon.name ) } } }

然後是緊跟著的菜名:

kotlin @OptIn(ExperimentalAnimationApi::class) @Composable fun FoodText(text: String) { Text( modifier = Modifier .fillMaxWidth() .padding(8.dp), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary, text = text ) }

因為上面兩個元件和標準 Compose 一樣,所以就不做過多解釋。

最後是可選的菜名:

kotlin @Composable fun FoodChip( text: String, checked: Boolean, onCheckedChange: (checked: Boolean) -> Unit ) { ToggleChip( modifier = Modifier .fillMaxWidth() .padding(4.dp), checked = checked, toggleControl = { Icon( imageVector = ToggleChipDefaults.switchIcon(checked = checked), contentDescription = if (checked) "$text On" else "$text Off" ) }, onCheckedChange = { onCheckedChange(it) }, label = { Text( text = text, maxLines = 1, overflow = TextOverflow.Ellipsis ) } ) }

這個表示的是一個可切換選中狀態的 Chip,其中 toggleControl 用於指示選中狀態,這裡用的是預設的切換圖示樣式。

onCheckedChange 表示選中狀態改變時的回撥。

label 表示主要的顯示文字。

這個控制元件顯示效果如下:

s4.png

將上面三個模組放進 ScalingLazyColumn 中:

```kotlin // ……

item { StartButton(icon = runButtonIcon) { // TODO 點選按鈕 } }

item { FoodText(foodText) }

itemsIndexed(foodList) { index: Int, item: Foods -> FoodChip( text = item.name, checked = item.enable ) { // TODO 菜的選中狀態改變 } }

// …… ```

自此,所有介面完成。

實現主頁邏輯

介面編寫完成後,我們接下來編寫控制邏輯。

因為現在只是初探 Compose for Wear OS 的用法,所以我們就先不用架構設計了,直接把邏輯程式碼和介面程式碼混一起寫吧(捂臉.jpg)。

首先定義好幾個狀態:

```kotlin var isRunning = remember { false } // 標記是否正在選菜中

val listState = rememberScalingLazyListState() // ScalingLazyList 的 State var runButtonIcon by remember { mutableStateOf(Icons.Rounded.PlayArrow) } // 開始執行按鈕的圖示 var foodText by remember { mutableStateOf("吃啥") } // 菜名 val foodList = remember { mutableStateListOf() } // 可選菜列表

val coroutine = rememberCoroutineScope() // 協程 ```

然後直接寫死一個菜名列表吧:

```kotlin data class Foods( val name: String, var enable: Boolean = true )

fun getFoodsList(): Array = arrayOf( Foods("刀削麵"), Foods("牛肉粉"), Foods("羊肉粉"), Foods("包子"), Foods("饅頭"), Foods("泡麵"), Foods("手抓餅"), Foods("牛肉泡饃"), Foods("蛋炒飯"), Foods("飯炒蛋"), Foods("餓著"), Foods("烤雞腿"), Foods("烤肉拌飯"), Foods("怪嚕飯"), Foods("糯米飯"), Foods("蛋包飯"), Foods("飯包蛋"), Foods("包蛋飯"), ) ```

WearApp() 中將菜名新增進去:

kotlin DisposableEffect(key1 = Unit) { foodList.addAll(getFoodsList()) onDispose { } }

在這裡我們選擇了在副作用中新增菜名,因為這個副作用只會執行一次,那就是在這個 composable 第一次組合的時候,這樣可以避免重組導致重複新增資料。

然後,處理菜名列表的選中狀態改變事件:

```kotlin // ……

FoodChip( text = item.name, checked = item.enable ) { foodList[index] = foodList[index].copy(enable = it) }

// …… ```

需要注意的是,這裡不能直接使用 foodList[index].enable = it 修改列表狀態,這樣 Compose 將無法及時的感知到列表變化,具體表現為點選時無反應,但是滑出屏幕後再滑回來卻又成功更新了:

s5.gif

我們應該使用 foodList[index] = foodList[index].copy(enable = it) 直接重新建立一個 Foods 物件。

詳見:Android Compose lazycolumn does not update when livedata is changed

最後處理一下點選開始按鈕回撥。

```kotlin private const val RunTimeInterval = 150L

// ……

if (isRunning) { isRunning = false // coroutine.cancel() // coroutine.coroutineContext.cancelChildren() runButtonIcon = Icons.Rounded.Refresh } else { isRunning = true coroutine.launch(Dispatchers.IO) {

    runButtonIcon = Icons.Rounded.Pause

    var index = 0
    while (isRunning) {
        val food = foodList[index]
        if (food.enable) {
            foodText = food.name
            delay(RunTimeInterval)
        }

        index++
        if (index >= foodList.size) index = 0
    }
}

}

// …… ```

處理邏輯非常簡單,首先判斷現在是否正在執行,如果正在執行就停止執行,並恢復按鈕圖示。

如果沒有在執行就開始執行,開啟一個協程後在協程中迴圈讀取菜名列表,然後顯示啟用的所有的菜名。

這裡有一點需要注意一下,就是在停止執行時,可以看到我註釋掉了兩行程式碼。

一開始我想的是,停止執行最好還是把協程停止掉吧(其實並不需要主動停止,因為執行時的迴圈條件是 isRunning),所以我加了 coroutine.cancel() 語句。

然而,加了這個之後,程式只能執行一次了,第二次無論如何也無法執行,查閱資料才得知,原來直接呼叫 CoroutineScope.cancel() 不僅會取消所有子協程,還會把自己這個 CoroutineScope 也幹掉,所以當然沒法再用這個 Scope 啟動新的協程了。

如果我們想要取消的話應該使用取消子協程而不是全部幹掉: coroutine.coroutineContext.cancelChildren()

或者更精細一點,應該自己控制每個 Job:

kotlin val job = coroutine.launch { // …… } job.cancel()

對了,為了好看一點,再給顯示菜名的 Text() 加個簡單的動畫吧:

```kotlin @Composable fun FoodText(text: String) { AnimatedContent( targetState = text, transitionSpec = { fadeIn(animationSpec = tween(100, delayMillis = 40)) + scaleIn(initialScale = 0.92f, animationSpec = tween(100, delayMillis = 40)) with fadeOut(animationSpec = tween(40)) } ) { Text( modifier = Modifier .fillMaxWidth() .padding(8.dp), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary, text = it ) }

} ```

最後,還記得我們前面說過的嗎?在列表中第一項的寬度非常小,顯示出來非常難看,雖然我們添加了 AutoCenteringParams(itemIndex = 0) 使其自動填充,但是第一次開啟時的預設位置還是處於最頂部,顯然不符合我們的UI設計。

所以我們需要在第一次啟動時手動移動第一項到中間來:

kotlin // 移動到第一個 item 確保按鈕在中間 LaunchedEffect(key1 = Unit) { listState.scrollToItem(0) }

完整程式碼

因為程式碼很簡單,所以就不上傳到程式碼託管了,直接全部貼上來吧。

```kotlin private const val RunTimeInterval = 150L

@Composable fun WearApp() { WearOScomposetestTheme { var isRunning = remember { false } // 標記是否正在選菜中

    val listState = rememberScalingLazyListState() // ScalingLazyList 的 State
    var runButtonIcon by remember { mutableStateOf(Icons.Rounded.PlayArrow) } // 開始執行按鈕的圖示
    var foodText by remember { mutableStateOf("吃啥") } // 菜名
    val foodList = remember { mutableStateListOf<Foods>() }  // 可選菜列表

    val coroutine = rememberCoroutineScope() // 協程

    DisposableEffect(key1 = Unit) {
        foodList.addAll(getFoodsList())
        onDispose {  }
    }

    Scaffold(
        timeText = {
            if (!listState.isScrollInProgress) {
                TimeText()
            }
        },
        vignette = {
            Vignette(vignettePosition = VignettePosition.TopAndBottom)
        },
        positionIndicator = {
            PositionIndicator(
                scalingLazyListState = listState
            )
        }
    ) {
        ScalingLazyColumn(
            modifier = Modifier.fillMaxSize(),
            state = listState,
            autoCentering = AutoCenteringParams(itemIndex = 0)
        ) {
            item {
                StartButton(icon = runButtonIcon) {

                    if (isRunning) {
                        isRunning = false
                        //coroutine.cancel()
                        //coroutine.coroutineContext.cancelChildren()

                        runButtonIcon = Icons.Rounded.Refresh
                    }
                    else {
                        isRunning = true
                        coroutine.launch(Dispatchers.IO) {

                            runButtonIcon = Icons.Rounded.Pause

                            var index = 0
                            while (isRunning) {
                                val food = foodList[index]
                                if (food.enable) {
                                    foodText = food.name
                                    delay(RunTimeInterval)
                                }

                                index++
                                if (index >= foodList.size) index = 0
                            }
                        }
                    }
                }
            }

            item { FoodText(foodText) }

            itemsIndexed(foodList) { index: Int, item: Foods ->
                FoodChip(
                    text = item.name,
                    checked = item.enable
                ) {
                    // foodList[index].enable = it // 直接修改將無法觸發 重組 see: https://stackoverflow.com/questions/70071194/android-compose-lazycolumn-does-not-update-when-livedata-is-changed
                    foodList[index] = foodList[index].copy(enable = it)
                }
            }
        }
    }

    // 移動到第一個 item 確保按鈕在中間
    LaunchedEffect(key1 = Unit) {
        listState.scrollToItem(0)
    }
}

}

@Composable fun StartButton( icon: ImageVector, onClick: () -> Unit ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { Button( modifier = Modifier.size(ButtonDefaults.LargeButtonSize), onClick = onClick ) { Icon( imageVector = icon, contentDescription = icon.name ) } } }

@OptIn(ExperimentalAnimationApi::class) @Composable fun FoodText(text: String) { AnimatedContent( targetState = text, transitionSpec = { fadeIn(animationSpec = tween(100, delayMillis = 40)) + scaleIn(initialScale = 0.92f, animationSpec = tween(100, delayMillis = 40)) with fadeOut(animationSpec = tween(40)) } ) { Text( modifier = Modifier .fillMaxWidth() .padding(8.dp), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary, text = it ) }

}

@Composable fun FoodChip( text: String, checked: Boolean, onCheckedChange: (checked: Boolean) -> Unit ) { ToggleChip( modifier = Modifier .fillMaxWidth() .padding(4.dp), checked = checked, toggleControl = { Icon( imageVector = ToggleChipDefaults.switchIcon(checked = checked), contentDescription = if (checked) "$text On" else "$text Off" ) }, onCheckedChange = { onCheckedChange(it) }, label = { Text( text = text, maxLines = 1, overflow = TextOverflow.Ellipsis ) } ) }

fun getFoodsList(): Array = arrayOf( Foods("刀削麵"), Foods("牛肉粉"), Foods("羊肉粉"), Foods("包子"), Foods("饅頭"), Foods("泡麵"), Foods("手抓餅"), Foods("牛肉泡饃"), Foods("蛋炒飯"), Foods("飯炒蛋"), Foods("餓著"), Foods("烤雞腿"), Foods("烤肉拌飯"), Foods("怪嚕飯"), Foods("糯米飯"), Foods("蛋包飯"), Foods("飯包蛋"), Foods("包蛋飯"), )

data class Foods( val name: String, var enable: Boolean = true )

@Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true) @Composable fun DefaultPreview() { WearApp() } ```

ps: 裡面的預覽程式碼沒刪,可以直接複製後預覽。

總結

自此,我們已經大致瞭解了 Compose for Wear OS 的使用方法,也簡單的寫了一個小 demo 來親自體驗了一番。

不過受限於我現在手頭沒有裝置,沒法深入的去體驗。

所以等我的手錶到了後我們再繼續完成尚未完成的功能吧。

參考資料

  1. Compose for Wear OS Codelab
  2. Use Jetpack Compose on Wear OS