前言
俗话说,人生有三大难题:早上吃啥、中午吃啥、晚上吃啥。
这个问题一度困扰着无数的人,直到一款帮你选择吃什么的神器《今天吃啥》出现,人们再也不用为了每天吃啥而犯愁了。
哈哈,以上纯属抖机灵。
最近访问谷歌开发者官网时发现首页 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
中有以下槽位:
@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(开始按钮和菜名文本)、以及向下滚动后应该可以选择禁用菜名列表中的某些菜。
第二个页面依旧使用可滚动布局显示设置选项,主要用于增删改查菜名列表内容以及选择使用哪个菜名列表,由于这个功能需要和手机连接来同步数据,而我的表还没发货,所以暂时不做这个页面了,等手表到了再写。
两个页面之间可以通过左右滑动切换。
实现主页
首先写出基础框架:
@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)
所以它可以被下拉到最中间,如果不能被下拉的话将是这样:
接下来,我们往这个基础框架中填充内容,首先是开始按钮:
@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) {Text(modifier = Modifier.fillMaxWidth().padding(8.dp),textAlign = TextAlign.Center,color = MaterialTheme.colors.primary,text = text)
}
因为上面两个组件和标准 Compose 一样,所以就不做过多解释。
最后是可选的菜名:
@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
中:
// ……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)。
首先定义好几个状态:
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() // 协程
然后直接写死一个菜名列表吧:
data class Foods(val name: String,var enable: Boolean = true
)fun getFoodsList(): Array<Foods> = arrayOf(Foods("刀削面"),Foods("牛肉粉"),Foods("羊肉粉"),Foods("包子"),Foods("馒头"),Foods("泡面"),Foods("手抓饼"),Foods("牛肉泡馍"),Foods("蛋炒饭"),Foods("饭炒蛋"),Foods("饿着"),Foods("烤鸡腿"),Foods("烤肉拌饭"),Foods("怪噜饭"),Foods("糯米饭"),Foods("蛋包饭"),Foods("饭包蛋"),Foods("包蛋饭"),
)
在 WearApp()
中将菜名添加进去:
DisposableEffect(key1 = Unit) {foodList.addAll(getFoodsList())onDispose { }
}
在这里我们选择了在副作用中添加菜名,因为这个副作用只会运行一次,那就是在这个 composable 第一次组合的时候,这样可以避免重组导致重复添加数据。
然后,处理菜名列表的选中状态改变事件:
// ……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
最后处理一下点击开始按钮回调。
private const val RunTimeInterval = 150L// ……if (isRunning) {isRunning = false// coroutine.cancel()// coroutine.coroutineContext.cancelChildren()runButtonIcon = Icons.Rounded.Refresh
}
else {isRunning = truecoroutine.launch(Dispatchers.IO) {runButtonIcon = Icons.Rounded.Pausevar index = 0while (isRunning) {val food = foodList[index]if (food.enable) {foodText = food.namedelay(RunTimeInterval)}index++if (index >= foodList.size) index = 0}}
}// ……
处理逻辑非常简单,首先判断现在是否正在运行,如果正在运行就停止运行,并恢复按钮图标。
如果没有在运行就开始运行,开启一个协程后在协程中循环读取菜名列表,然后显示启用的所有的菜名。
这里有一点需要注意一下,就是在停止运行时,可以看到我注释掉了两行代码。
一开始我想的是,停止运行最好还是把协程停止掉吧(其实并不需要主动停止,因为运行时的循环条件是 isRunning
),所以我加了 coroutine.cancel()
语句。
然而,加了这个之后,程序只能运行一次了,第二次无论如何也无法运行,查阅资料才得知,原来直接调用 CoroutineScope.cancel()
不仅会取消所有子协程,还会把自己这个 CoroutineScope
也干掉,所以当然没法再用这个 Scope 启动新的协程了。
如果我们想要取消的话应该使用取消子协程而不是全部干掉: coroutine.coroutineContext.cancelChildren()
。
或者更精细一点,应该自己控制每个 Job:
val job = coroutine.launch {// ……
}
job.cancel()
对了,为了好看一点,再给显示菜名的 Text()
加个简单的动画吧:
@Composable
fun FoodText(text: String) {AnimatedContent(targetState = text,transitionSpec = {fadeIn(animationSpec = tween(100, delayMillis = 40)) +scaleIn(initialScale = 0.92f, animationSpec = tween(100, delayMillis = 40)) withfadeOut(animationSpec = tween(40))}) {Text(modifier = Modifier.fillMaxWidth().padding(8.dp),textAlign = TextAlign.Center,color = MaterialTheme.colors.primary,text = it)}}
最后,还记得我们前面说过的吗?在列表中第一项的宽度非常小,显示出来非常难看,虽然我们添加了 AutoCenteringParams(itemIndex = 0)
使其自动填充,但是第一次打开时的默认位置还是处于最顶部,显然不符合我们的UI设计。
所以我们需要在第一次启动时手动移动第一项到中间来:
// 移动到第一个 item 确保按钮在中间
LaunchedEffect(key1 = Unit) {listState.scrollToItem(0)
}
完整代码
因为代码很简单,所以就不上传到代码托管了,直接全部贴上来吧。
private const val RunTimeInterval = 150L@Composable
fun WearApp() {WearOScomposetestTheme {var isRunning = remember { false } // 标记是否正在选菜中val listState = rememberScalingLazyListState() // ScalingLazyList 的 Statevar 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 = truecoroutine.launch(Dispatchers.IO) {runButtonIcon = Icons.Rounded.Pausevar index = 0while (isRunning) {val food = foodList[index]if (food.enable) {foodText = food.namedelay(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-changedfoodList[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)) withfadeOut(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<Foods> = 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 来亲自体验了一番。
不过受限于我现在手头没有设备,没法深入的去体验。
所以等我的手表到了后我们再继续完成尚未完成的功能吧。
参考资料
- Compose for Wear OS Codelab
- Use Jetpack Compose on Wear OS