持续创造,加快成长!这是我参加「日新方案 10 月更文挑战」的第10天,点击检查活动详情

前言

俗话说,人生有三大难题:早上吃啥、正午吃啥、晚上吃啥。
这个问题一度困扰着无数的人,直到一款帮你挑选吃什么的神器《今日吃啥》呈现,人们再也不必为了每天吃啥而犯愁了。

哈哈,以上纯属抖机灵。

最近拜访谷歌开发者官网时发现主页 Banner 改成了 Wear OS 专题,其中有一项便是 Compose for Wear OS,刚好最近在学习 Compose ,所以我就摩拳擦掌摩拳擦掌。可是我的学习风格是在做中学,以实际项目作为载体来学习,那么这次做一个什么呢?

想了想,能够做一个吃什么挑选器,这种东西没什么难度,并且也兼具实用与玩乐,最要害的是这种类型APP假如做成手机APP会略显臃肿,更适合做小程序或网页。可是,假如把APP装到手表上,那感觉也不相同了,究竟谁不想一抬手就能选好吃的呢?

说干就干,咱们这就开端学习。

老规矩,在开端前先看看预览作用:

初探 Compose for Wear OS:实现一个简易选择APP

禁用作用(仅敞开了前面两个,剩余的全部禁用):

初探 Compose for Wear OS:实现一个简易选择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 上多了一些特定的组件,例如: 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 的模板,咱们只需求在创立时挑选这个模板即可:

初探 Compose for Wear OS:实现一个简易选择APP

规划页面

整体布局

咱们的目标是做一个吃什么挑选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 表明的是为屏幕增加含糊作用,例如为屏幕的底部和顶部增加含糊作用,以对中心显现内容表明着重:

初探 Compose for Wear OS:实现一个简易选择APP

positionIndicator 表明在屏幕边缘(一般是右侧)增加一个方位指示UI,例如为这个笔直翻滚列表增加的方位指示:

初探 Compose for Wear OS:实现一个简易选择APP

pageIndicator 表明增加一个页面指示UI,由于在 Wear OS 中,通常经过左右滑动来切换不同的页面,所以能够用这个槽增加一个当时页面方位:

初探 Compose for Wear OS:实现一个简易选择APP

timeText 表明增加一个位于界面顶部的时刻指示UI,由于规划准则中要求最好在需求长时刻逗留的界面增加时刻指示,究竟 Wear OS 大多数时分都是手表,假如一个手表连时刻都不能看,那还有什么用呢?

初探 Compose for Wear OS:实现一个简易选择APP

确定了运用 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 会经过缩放和淡入淡出的方法自动帮咱们处理不同宽度显现:

初探 Compose for Wear OS:实现一个简易选择APP

不知道看到这儿读者们有没有一个疑问,已然圆形屏幕宽度不共同,且越远离屏幕中心宽度越小,那在翻滚布局中岂不是意味着前几个 item (例如第一个),永远也无法被移动到最中心完结最大宽度显现了?

没错,的确存在这个问题,所以 ScalingLazyColumn 为咱们供给了一个参数 autoCentering 用于解决这个问题。

例如上面代码中咱们将这个参数设置为了 AutoCenteringParams(itemIndex = 0) 这表明自动为第一个 item 增加填充和偏移量,使得第一个 item 也能够被下拉到最中心。

初探 Compose for Wear OS:实现一个简易选择APP

在这个截图中,中心的按钮实际上是第一个 item,可是现在由于咱们设置了 AutoCenteringParams(itemIndex = 0) 所以它能够被下拉到最中心,假如不能被下拉的话将是这样:

初探 Compose for Wear OS:实现一个简易选择APP

接下来,咱们往这个根底结构中填充内容,首要是开端按钮:

@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 表明首要的显现文本。

这个控件显现作用如下:

初探 Compose for Wear OS:实现一个简易选择APP

将上面三个模块放进 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 将无法及时的感知到列表变化,具体体现为点击时无反应,可是滑出屏幕后再滑回来却又成功更新了:

初探 Compose for Wear OS:实现一个简易选择APP

咱们应该运用 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 = 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() 句子。

然而,加了这个之后,程序只能运转一次了,第2次无论如何也无法运转,查阅资料才得知,原来直接调用 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)) 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规划。

所以咱们需求在第一次发动时手动移动第一项到中心来:

// 移动到第一个 item 保证按钮在中心
LaunchedEffect(key1 = Unit) {
    listState.scrollToItem(0)
}

完好代码

由于代码很简略,所以就不上传到代码托管了,直接全部贴上来吧。

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<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 来亲身体验了一番。

不过受限于我现在手头没有设备,无法深入的去体验。

所以等我的手表到了后咱们再持续完结没有完结的功能吧。

参考资料

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