【带源码】我又用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 开发的内容,我以后也会做更多分享。
关注我,不错过我的任何新内容。我是扔物线,我不和你比高低,我只助你成长。