入坑 Jetpack Compose :写一个简单的计算器
本文是一个综合的Compose小例子,涉及动画、自定义布局、列表等主题。本文并非教程,只是展示展示Compose开发应用是什么感觉,并试图拉人入坑。如果你还没接触过,不妨进来扫一扫代码,读一读单词,感受感受~
本文所展示的思路仅为个人想法,并不代表最优解,也欢迎一起探讨
前言
8月份的时候,我关注了 fundroid 大佬的公众号,看到历史推文中有这么一篇,内容是Compose学习挑战赛,要求为“实现一个计算器 App”。正好自己对Compose有过一点经验 (这个可以点开头像看历史文章),抱着试试看的态度,我花大概4-5h完成并提交了作品。
尽管作品比较简单,但结果还是不错的(补充:看了看评论区大佬的图,发现这是个参与纪念奖 hhh):几天前,我收到了Google发来的这封邮件:
既然文章都写完了,那还是厚着脸皮留着吧
所以就简单介绍下吧,或许也可以当做非常入门的小案例,说不定能帮到些人、拉入点坑。
本文源码地址见文末
效果
可以看到,尽管开发的时间并不长,但是基本的小功能也还是有的。计算的时候也会有点简单的小动画,还适配了横屏的布局。
顺带一提,由于Compose天然的特性,项目还自动适配了深色模式,如下:
实现
以竖屏的布局为例,它主要包括这几个部分
或许我们可以分别叫它们:历史记录区、表达式区和输入区
输入区
之所以先看输入区,是因为这是页面的主体部分。从布局来看,整体为均匀的网格状。在Compose中,想实现这样的网格布局也有几种选择,比如使用Lazy系列的LazyGrid
(可以参考我的 Jetpack Compose LazyGrid使用全解)。不过,某种程度上,出于教程的目的,我在这里用的是自定义布局+For循环
。
自定义布局?
你可能比较疑惑:这里为啥需要自定义布局?这就要从我自己的数据结构说起了。为了表示按键的布局,我用了个二维字符数据
1 | val symbols = arrayOf( |
我希望的效果是呢,每个按键都是正方形,因此,输入区的长宽比需要和二维数组的行列比一致。也就是,竖屏的时候宽度固定,计算高度;横屏则反过来。
整个输入区由一个Box
包裹,因此只需要动态调整它自己的宽高即可。因此,此处使用Modifier.layout
修饰自己。代码如下:
1 | // 每个正方形的宽度 |
如果你没有接触过自定义布局,可以参考如下文章:
- 深入Jetpack Compose——布局原理与自定义布局(一) - 掘金 (juejin.cn)
- 深入Jetpack Compose——布局原理与自定义布局(二) - 掘金 (juejin.cn)
- 深入Jetpack Compose——布局原理与自定义布局(三) - 掘金 (juejin.cn)
- 深入Jetpack Compose——布局原理与自定义布局(四)ParentData - 掘金 (juejin.cn)
回到文章,上面已经正确的设置了Box
的大小,接下来往里面放内容就好。在这里就是简单的双重for循环:
1 | symbols.forEachIndexed { i, array -> |
Box
类似于View
,是最基本的@Composable
。在Compose中,各Composable
的样式由Modifier
修饰,以链式调用的方式设置。此处使用.size
修饰符确定了每个按键的大小,offset
确定了它们的位置(偏移)。这里有趣的地方是,因为padding
先于clickable
设置,所以点击的波纹是在padding
区域内的(这是我希望的效果,不然有点丑)。这也是初学者需要注意的一点:Modifier的顺序很重要
表达式区域
这个区域很简单,有趣的地方在于,它是有动画的。实现这样的效果或许在xml
里略显繁琐,但在Compose
里却相当简单
1 |
|
对,就这么点!这里的整体思路是,用Column
(纵向布局)放置两个Text
,并在resultText
(也就是计算结果)改变时执行动画,改变二者的字体大小。
这样的过程类似于View
体系下的属性动画
,但在Compose声明式 UI=f(State)
的理念下,写出的代码更自然。这或许是Compose开发上的另一有趣之处。
历史记录区
这个区域就更简单了,就是个列表呗。对于View
用户,这时候就要开始建xml、写ViewHolder、设置Adapter
一条龙了。但在Compose
下,一切只需要交给LazyColumn
1 | LazyColumn(modifier, state = listState) { |
Compose的列表就是这么简单,不用花里胡哨,不用几个文件来回跳。告诉它数据源以及每个item长什么样就好。
为了更好看一些,我还顺便给它加上了个Item进入动画:从右往左飞入。代码也很简单
1 | items(vm.histories) { item -> |
上面的代码里出现了不少remember
,可以理解为“记住”。Compose的刷新类似于在重新调用函数,于是为了让某个值能被保存下来,就得放在remember
里。LaunchedEffect
则为副作用的一种,当首次进入Composition
或括号里的值(key)改变时才执行里面的内容,在这里用于启动动画。
三个部分介绍完,接下来就是把它们合在一起啦
合在一起
竖屏状态下,合在一起似乎还有点困难:我们需要先摆放底部的输入区,等计算完它的宽高后,再在它上面放上历史记录和表达式。
要解决这个问题也有挺多方法,比如Column
+weight
修饰符应该就可以。同样的,出于教程的目的,我这里还是换了个花里胡哨的做法:自定义布局。
1 | /** |
代码中使用到了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
,帮助快速实现一些常用功能,比如Pager
、WebView
、SwipeToRefresh
等。
使用起来也很简单:
1 | val systemUiController = rememberSystemUiController() |
横竖屏判断
此处判断的依据非常简单:当前屏幕的“宽度”。通过最外层的BoxWithConstraints
获取到的constraints.maxWidth
做判断依据,代码如下:
1 | BoxWithConstraints( |
通过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)