【带源码】我又用Jetpack Compose做了个示例App,是怎样的体验?
视频先行
下面是视频内容的脚本整理稿。如果你看了视频,那下面的文稿就不用看了,直接翻到底部就行。
开场
大家好,我是扔物线朱凯。
去年我发过几个视频讲 Compose,有做 Compose 的介绍的,有讲原理的,但唯独没有实际代码的讲解。倒是有一节我的 Compose 课程的试听课我也放了出来,但是那个是课程,是三个小时的,有人嫌长,嫌啰嗦。
我真是……
所以今天,我就给大家来一个 Compose 代码的演示。
这个,是我用 Compose 写的一个叫做「围观」的虚构的 App,「虚构」的意思就是里面的功能是我编的,因为我只是要把界面和交互给做出来。这界面看着复杂,但是用 Compose 写起来其实特别简单。今天我就用最流畅的方式给大家讲解一下它的代码,让大家看看 Compose 的界面代码到底长什么样。
功能
我先简单介绍一下这个软件的「功能」。「功能」两个字得加个引号,因为都是假的嘛。
首先顶部是一些基本信息,例如当前用户是谁、通知按钮,以及一个搜索框。
然后中间就是围观的内容。
最上面你可以选择你要围观谁——实际上是选不了的啊,我没写这个功能,所以我就不点了;往下是这个你围观的人他喜欢的东西,比如你看这是在围观我对吧,我就喜欢这么几个东西。你也可以点击其中一项,来查看详情。
详情页的顶部是一个预览图,图的下方就是各种详细信息。
然后关掉详情页之后,下方是这个被围观的人最近的行动轨迹。
在页面的最下方,是一个切换标签页的导航条,不过这个条的导航功能我也没做,只做了点击效果。
就这么简单的一个虚构的示例 App,叫做《围观》。那么接下来我们就来看看它的代码是怎么写的。
代码
首先,由于它的界面是用 Compose 写的,所以我要把 setContentView()
删掉,换成 Compose 专用的 setContent {}
:
setContentView(R.layout.activity_main)
...
setContent {
}
然后就可以写内容了。
鉴于这是个上下结构的分栏布局,所以二话不说,我先把底部的导航条给做了。用一个 Row()
来做横向布局,里面用 4 个连续的 Icon()
来显示 4 个图标:
setContent {
NavBar()
}
->
@Composable
private fun NavBar() {
Row {
Icon(painterResource(R.drawable.icon1), "图标")
Icon(painterResource(R.drawable.icon2), "图标")
Icon(painterResource(R.drawable.icon3), "图标")
Icon(painterResource(R.drawable.icon4), "图标")
}
}
很难看,没关系,我去调整一下这些图标。我把图标抽到单独的函数里:
再调整一下图标的尺寸:
再归整一下它们的位置布局:
就一下子变得整齐了。
然后我再给它们涂上色:
哎,就又好看了很多。
这时候为了让这些图标有点击的波纹效果,我再把它外面包上一层 Button():
好,这样底栏就完成了。
代码的细节我在这个视频里就不深入拆解了,今天主要是快速演示、快速感受,让你感受一下用 Compose 写界面大概是怎么一回事。要看细节的可以去看这个项目的源码,扫码加我的助教,让她发给你就行,暗号「围观」。或者看我的 Compose 课程的试听课也行,那个也很细,也可以加助教让助教发你。
导航条完成之后,就可以做上面的部分了。那么在做这部分之前,我要先用一个 Column()
来做纵向布局来把整个界面上下分开,下面是导航条,上面就是我接下来要写的部分:
Column {
NavBar()
}
由于这一部分的内部也是纵向的布局,所以我再套一层 Column()
:
Column {
Column {
}
NavBar()
}
然后加个 fillMaxWidth()
让它横向撑满;再给它个 weight()
,让它纵向撑满剩余空间,也就是把导航条踩到底:
Column {
Column(Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xfffafafa))) {
}
NavBar()
}
好,接下来就要填充上面这一大块空白了。
从上到下地做,所以第一步是顶部的基本信息的横条。
我可以先用一个 Row()
来给出横向的布局:
Column {
Column(
Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xfffcfcfc))
) {
Row {
}
}
NavBar()
}
然后往里面依次塞一个头像的图片:
Column {
Column(
Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xfffcfcfc))
) {
Row {
Image(
painterResource(id = R.drawable.avatar_rengwuxian),
"头像",
)
}
}
NavBar()
}
一个纵向包着两块文字的 Column()
:
Column {
Column(
Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xfffcfcfc))
) {
Row {
Image(
painterResource(id = R.drawable.avatar_rengwuxian),
"头像",
)
Column {
Text("欢迎回来!")
Text("小朱")
}
}
}
NavBar()
}
和一个小铃铛:
Column {
Column(
Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xfffcfcfc))
) {
Row {
Image(
painterResource(id = R.drawable.avatar_rengwuxian),
"头像",
)
}
Column {
Text("欢迎回来!")
Text("小朱")
}
Image(
painterResource(R.drawable.notification_new),
"通知"
)
}
NavBar()
}
东西都显示出来了,接下来给它们调调样式。
头像做成圆角的,尺寸也调一下:
Image(
painterResource(id = R.drawable.avatar_rengwuxian),
"头像",
Modifier
.size(64.dp)
.clip(CircleShape)
)
文字的颜色和样式也打磨打磨:
Text("欢迎回来!", fontSize = 14.sp, color = Color(0xffb4b4b4))
Text("小朱", fontSize = 18.sp, fontWeight = FontWeight.Bold)
小铃铛给缩小点:
Image(
painterResource(R.drawable.notification_new),
"通知",
Modifier.size(32.dp)
)
再加个圆形的底子:
Surface(
Modifier.clip(CircleShape),
color = Color(0xfffef7f0)
) {
Image(
painterResource(R.drawable.notification_new),
"通知",
Modifier.padding(10.dp).size(32.dp)
)
}
样式调好之后,再统一调整一下它们的布局:
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painterResource(id = R.drawable.avatar_rengwuxian),
"头像",
Modifier.size(64.dp).clip(CircleShape)
)
Column(
Modifier.padding(start = 14.dp).weight(1f)
) {
Text("欢迎回来!", fontSize = 14.sp, color = Color(0xffb4b4b4))
Spacer(modifier = Modifier.height(6.dp))
Text("小朱", fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
Surface(
Modifier.clip(CircleShape),
color = Color(0xfffef7f0)
) {
Image(
painterResource(R.drawable.notification_new),
"通知",
Modifier
.padding(10.dp)
.size(32.dp)
)
}
}
然后给整个 Row()
加上个 padding()
:
Row(
Modifier
.fillMaxWidth()
.padding(28.dp, 36.dp, 28.dp, 16.dp),
verticalAlignment = Alignment.CenterVertically
)
好,这个顶栏就完成了。我把它的代码也抽到单独的函数里,干干净净:
Column {
Column(
Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xfffcfcfc))
) {
TopBar()
}
NavBar()
}
往下是一个搜索栏:
Column(...) {
TopBar()
SearchBar()
}
->
@Composable
fun SearchBar() {
}
一样的写法,基本没什么好说的:
@Composable
fun SearchBar() {
Row(
Modifier
.padding(24.dp, 12.dp)
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(28.dp))
.background(Color.White), verticalAlignment = Alignment.CenterVertically
) {
var searchText by remember { mutableStateOf("") }
BasicTextField(
searchText, { searchText = it }, Modifier
.padding(start = 24.dp)
.weight(1f), textStyle = TextStyle(fontSize = 16.sp)
) {
if (searchText.isEmpty()) {
Text("搜搜看?", color = Color(0xffb4b4b4), fontSize = 16.sp)
}
it()
}
Box(
Modifier
.padding(6.dp)
.fillMaxHeight()
.aspectRatio(1f)
.clip(CircleShape)
.background(Color(0xfffa9e51))
) {
Icon(
painterResource(R.drawable.ic_search), "搜索",
Modifier
.size(24.dp)
.align(Alignment.Center),
tint = Color.White
)
}
}
}
只有一点:它的提示文字的写法和传统的 EditText()
不一样,是我自己手写的:
这种写法看起来比较麻烦,但是极大地增加了灵活性。比如我可以给它加个图标、加个帮助文字、报错文字,直接加就行,而且格式完全不受限制,而在传统的 EditText
里只能靠自定义 View 来实现这些功能。其实 Compose 官方也其实提供了更简单的提示文字的写法,直接填写一个字符串类型的 label
参数就行:
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Label") }
)
只是这种写法要用 Material Design 的样式,而我想完全自己定制样式,所以就没用它。
搜索栏再往下,就是核心区域了,也就是被围观的人的信息。——刚才我说过,这是个叫做《围观》的 App 还记得吗?虽然是虚构的。
围观区域最上面是被围观的人名列表,这个很简单,横向布局里包着一些文字就完了,其中被选中的文字底部有一个圆头的横条:
TopBar()
SearchBar()
NamesBar()
...
@Composable
fun NamesBar() {
val names = listOf("扔物线", "朱凯", "老冯", "郝哥", "张三", "孙悟空")
var selected by remember { mutableStateOf(0) }
LazyRow(Modifier.padding(0.dp, 8.dp), contentPadding = PaddingValues(12.dp, 0.dp)) {
itemsIndexed(names) { index, name ->
Column(Modifier.padding(16.dp, 4.dp).width(IntrinsicSize.Max)) {
Text(name, color = if (index == selected) Color(0xfffa9e51) else Color(0xffb4b4b4))
if (index == selected) {
Box(
Modifier.fillMaxWidth().height(2.dp).clip(RoundedCornerShape(1.dp))
.background(Color(0xfffa9e51))
)
}
}
}
}
}
关于这里唯一需要说的是,由于列表的内容是可以滑动的,所以需要用 LazyRow()
而不是 Row()
。这个 LazyRow()
的功能对标的是传统写法的 RecyclerView
,不过写起来简单一些,不用 Adapter
,也不用 ViewHolder
,直接循环遍历每一项然后直接写每个项目的布局就完了:
虽然看起来是遍历的写法,但 Compose 依然会和 RecyclerView
一样动态加载,用到哪一项才布局和绘制哪一项,不会像代码看起来那样老老实实地把每一项都按顺序全部计算,所以虽然是遍历的写法,但性能上不会爆炸。
名字列表再往下,是一个叫做「TA 爱的」的区域,也就是这个被围观的人他都喜欢什么。那么首先,来个标题栏:
TopBar()
SearchBar()
NamesBar()
LovesArea()
...
@Composable
fun LovesArea() {
Row(
Modifier
.padding(24.dp, 12.dp, 24.dp)
.fillMaxWidth()
) {
Text("TA 爱的", fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
Text("查看全部", fontSize = 15.sp, color = Color(0xffb4b4b4))
}
}
好,标题栏的下方是横向排布的那些「爱」的列表:
@Composable
fun LovesArea() {
Column {
Row(
Modifier
.padding(24.dp, 12.dp, 24.dp)
.fillMaxWidth()
) {
Text("TA 爱的", fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
Text("查看全部", fontSize = 15.sp, color = Color(0xffb4b4b4))
}
// 就这里
}
}
由于是个列表,所以我用 LazyRow()
而不是 Row()
:
LazyRow {
}
里面内容的填充还是同样的套路,做完之后就是这个样子:
然后我用 LazyRow()
的 horizontalArrangement
参数来把元素互相隔开:
LazyRow(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
...
}
再用 contentPadding
来给两头也加上点空白:
LazyRow(horizontalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = PaddingValues(24.dp, 0.dp)) {
...
}
这个 contentPadding
,它和 padding()
的区别是,在滑动的时候内容不会在两头被切掉,而是会触达到滑动区域的边缘:
那么这个「TA 爱的」部分就也做完了。看着还不错,是吧?
剩下的也都是这个写法,比如再往下的「TA 去过」板块:
以及这里的项目被点开后的新页面:
所以布局代码我就不反复说了。重点说一下这个打开和关闭的动画:
动画是分多种的,这种实质上属于界面跳转的过渡动画。要做这种动画,最传统的方式是用 Android 提供的 Transition API。不过 Transition API 的的效果会差一些,如果不涉及 Activity 或者 Fragment 的跳转,过渡动画也可以选择用 Jetpack 的 MotionLayout 来实现。MotionLayout 在 Compose 里也是可以用的,不过我还没试过 Compose 里的 MotionLayout 的功能全不全,我在这里是直接用 Compose 的动画 API 来实现的这个过渡效果。原理很简单:给这个详情页设置四种状态:未打开、已打开、打开中以及关闭中,通过状态来渐变式操作详情页中的各个属性的值——比如每个组件的尺寸和偏移——从而达到详情页的显示和关闭动作的动画形式的呈现。
效果还不错哈?实际上如果你不会 Compose,或者你们公司的项目里没在用 Compose,而是用的传统的 View 系统,这种效果也是可以实现的,你有两个选择:自定义 View,或者 MotionLayout。其中自定义 View 会麻烦一点,MotionLayout 就简单很多,性能上也不一定会比 Compose 更差——甚至对于现在来说可能会好一点,这个我不确定,因为虽然 Compose 的理论性能是比 View 系统更好的,包括 MotionLayout,但它不是比较新嘛,所以我不确定它每个部分的细节优化跟没跟上。
另外据我所知,Compose 在动画方面也正在做更多的工作,其中就包括让这种过渡动画的开发可以更加简便。虽然像我这样手写整个动画过程也不是很麻烦,但是如果有了官方的更省事的 API,那肯定还是更好的。谁还不是个小懒蛋呢?
这部分动画的详细代码大家可以去看我的源码,加我的助教,让她发你就行,而且我的 Compose 课程正式版中也会紧跟官方的脚步,扩充动画相关的内容。
结尾
今天就是这些了,喜欢的话请大家点个赞,你们的支持对我很重要。关于 Compose,以及刚才我提到的 MotionLayout,还有更多的各种 Android 开发的内容,我以后也会做更多分享。
关注我,不错过我的任何新内容。我是扔物线,我不和你比高低,我只助你成长。