初探 Compose for Wear OS:實現一個簡易選擇APP
theme: channing-cyan
持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第10天,點選檢視活動詳情
前言
俗話說,人生有三大難題:早上吃啥、中午吃啥、晚上吃啥。 \ 這個問題一度困擾著無數的人,直到一款幫你選擇吃什麼的神器《今天吃啥》出現,人們再也不用為了每天吃啥而犯愁了。
哈哈,以上純屬抖機靈。
最近訪問谷歌開發者官網時發現首頁 Banner 改成了 Wear OS 專題,其中有一項就是 Compose for Wear OS,恰好最近在學習 Compose ,於是我就摩拳擦掌躍躍欲試。但是我的學習風格是在做中學,以實際專案作為載體來學習,那麼這次做一個什麼呢?
想了想,可以做一個吃什麼選擇器,這種東西沒什麼難度,而且也兼具實用與玩樂,最關鍵的是這種型別APP如果做成手機APP會略顯臃腫,更適合做小程式或網頁。但是,如果把APP裝到手錶上,那感覺也不一樣了,畢竟誰不想一擡手就能選好吃的呢?
說幹就幹,咱們這就開始學習。
老規矩,在開始前先看看預覽效果:
禁用效果(僅開啟了前面兩個,剩下的全部禁用):
開始學習
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 上多了一些特定的元件,例如: ScalingLazyColumn
、 Chip
等。
另外,雖然他們擁有幾乎一致的 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 的模板,我們只需要在建立時選擇這個模板即可:
設計頁面
整體佈局
我們的目標是做一個吃什麼選擇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
表示的是為螢幕新增模糊效果,例如為螢幕的底部和頂部新增模糊效果,以對中心顯示內容表示強調:
positionIndicator
表示在螢幕邊緣(一般是右側)新增一個位置指示UI,例如為這個垂直滾動列表新增的位置指示:
pageIndicator
表示新增一個頁面指示UI,因為在 Wear OS 中,通常通過左右滑動來切換不同的頁面,所以可以用這個槽新增一個當前頁面位置:
timeText
表示新增一個位於介面頂部的時間指示UI,因為設計原則中要求最好在需要長時間停留的介面新增時間指示,畢竟 Wear OS 大多數時候都是手錶,如果一個手錶連時間都不能看,那還有什麼用呢?
確定了使用 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
會通過縮放和淡入淡出的方式自動幫我們處理不同寬度顯示:
不知道看到這裡讀者們有沒有一個疑問,既然圓形螢幕寬度不一致,且越遠離螢幕中心寬度越小,那在滾動佈局中豈不是意味著前幾個 item (例如第一個),永遠也無法被移動到最中間實現最大寬度顯示了?
沒錯,確實存在這個問題,所以 ScalingLazyColumn
為我們提供了一個引數 autoCentering
用於解決這個問題。
例如上面程式碼中我們將這個引數設定為了 AutoCenteringParams(itemIndex = 0)
這表示自動為第一個 item 新增填充和偏移量,使得第一個 item 也可以被下拉到最中間。
在這個截圖中,中間的按鈕實際上是第一個 item,但是現在由於我們設定了 AutoCenteringParams(itemIndex = 0)
所以它可以被下拉到最中間,如果不能被下拉的話將是這樣:
接下來,我們往這個基礎框架中填充內容,首先是開始按鈕:
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
表示主要的顯示文字。
這個控制元件顯示效果如下:
將上面三個模組放進 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
在 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 將無法及時的感知到列表變化,具體表現為點選時無反應,但是滑出屏幕後再滑回來卻又成功更新了:
我們應該使用 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
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 來親自體驗了一番。
不過受限於我現在手頭沒有裝置,沒法深入的去體驗。
所以等我的手錶到了後我們再繼續完成尚未完成的功能吧。
參考資料
- 安卓與串列埠通訊-校驗篇
- 安卓與串列埠通訊-實踐篇
- 為 Kotlin 的函式新增作用域限制(以 Compose 為例)
- 安卓與串列埠通訊-基礎篇
- Compose For Desktop 實踐:使用 Compose-jb 做一個時間水印助手
- 初探 Compose for Wear OS:實現一個簡易選擇APP
- Compose太香了,不想再寫傳統 xml View?教你如何在已有View專案中混合使用Compose
- 在安卓中壓縮GIF的幾種方法(附例項程式碼)
- 魔改車鑰匙實現遠端控車:(1)整體思路及控制方案實現
- 跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(3)狀態與遊戲控制邏輯
- 跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(2)介面佈局
- 以不同的形式在安卓中建立GIF動圖
- 跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(4)移植到compose-jb實現跨平臺
- 羨慕大勞星空頂?不如跟我一起使用 Jetpack compose 繪製一個星空背景(帶流星動畫)
- 魔改車鑰匙實現遠端控車:(4)基於compose和經典藍芽編寫一個控制APP