本文是一个综合的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循环

自定义布局?

你可能比较疑惑:这里为啥需要自定义布局?这就要从我自己的数据结构说起了。为了表示按键的布局,我用了个二维字符数据

1
2
3
4
5
6
7
val symbols = arrayOf(
charArrayOf('C','(',')','/'),
charArrayOf('7','8','9','*'),
charArrayOf('4','5','6','-'),
charArrayOf('1','2','3','+'),
charArrayOf('⌫','0','.','=')
)

我希望的效果是呢,每个按键都是正方形,因此,输入区的长宽比需要和二维数组的行列比一致。也就是,竖屏的时候宽度固定,计算高度;横屏则反过来。
整个输入区由一个Box包裹,因此只需要动态调整它自己的宽高即可。因此,此处使用Modifier.layout修饰自己。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 每个正方形的宽度
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,见下文*/
}

如果你没有接触过自定义布局,可以参考如下文章:

回到文章,上面已经正确的设置了Box的大小,接下来往里面放内容就好。在这里就是简单的双重for循环

1
2
3
4
5
6
7
8
9
10
11
12
13
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里却相当简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Composable
fun CalcText(
modifier: Modifier,
formulaTextProvider: () -> String,
resultTextProvider: () -> String,
) {
val animSpec = remember {
TweenSpec<Float>(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

1
2
3
4
5
6
7
8
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进入动画:从右往左飞入。代码也很简单

1
2
3
4
5
6
7
8
9
10
11
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修饰符应该就可以。同样的,出于教程的目的,我这里还是换了个花里胡哨的做法:自定义布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 纵向布局,先摆放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中如下获得对应ViewModel

1
val vm: CalcViewModel = viewModel()

其他

状态栏

如果你仔细观察,上面的图中,为了更好的沉浸式,是没有状态栏的。这是借助的accompanist/systemuicontroller 库。
accompanist是Google官方提供的一系列Compose辅助library,帮助快速实现一些常用功能,比如PagerWebViewSwipeToRefresh等。
使用起来也很简单:

1
2
3
4
5
6
7
val systemUiController = rememberSystemUiController()
val isDark = isSystemInDarkTheme()
LaunchedEffect(systemUiController){
systemUiController.isSystemBarsVisible = false
// 设置状态栏颜色
// systemUiController.setStatusBarColor(Color.Transparent, !isDark)
}

横竖屏判断

此处判断的依据非常简单:当前屏幕的“宽度”。通过最外层的BoxWithConstraints获取到的constraints.maxWidth做判断依据,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)