Jetpack Compose出来有一段时间了,一直都没有去尝试,这次有点想法去玩一玩这个声明性界面工具,就以“原神”为主题写个列表吧。
整体设计参考DisneyCompose
效果图:
数据源
因为数据比较简单,也就只包含图片、姓名、描述等。所以在后台数据存储上选择的是Bmob后端云,一个方便前端开发的后端服务平台。
主要数据也是从原神各大网站搜集下来的,新建表结构并且将数据填充,我们简单看一下Bmob的后台。
数据准备好了,那就开始我们的Compose之旅。
首页UI绘制
整体结构
从上面的项目效果图来看,首页总布局属于是一个网格列表,平分两格,列表中的每个Item上方带有头像,头像下面是角色名称以及角色其他信息。
网格布局
因为整体分成两列,所以选择的是网格布局,Compose提供了一个实现-LazyVerticalGrid。
fun LazyVerticalGrid(
cells: GridCells,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
content: LazyGridScope.() -> Unit
)
LazyVerticalGrid中有几个重要参数先说明一下:
- GridCells :主要控制如何将单元格构建为列,如GridCells.Fixed(2),表示两列平分。
- Modifier : 主要用来对列表进行额外的修饰。
- PaddingValues :主要设置围绕整个内容的padding。
- LazyListState :用来控制或观察列表状态的状态对象
首页布局是平分两列的网格布局,那相应的代码如下:
LazyVerticalGrid(cells = GridCells.Fixed(2)) {}
单个Item
看过了外部框架,那现在来看每个Item的布局。每个Item为卡片式,外边框为圆角,且带有阴影。内部上方是一张图片Image,图片下方是两行文字Text。那Item具体该怎样布局?
我们先来看看在Compose之前,在xml中是怎么写?例如使用ConstraintLayout
布局,顶部放一个ImageView
,再来一个TextView layout_constraintTop_toBottomOf ImageView
,最后在来个TextView
也TopToBottomOf
第一个TextView
。
那使用Compose应该怎么写?
其实在Compose里也存在着ConstraintLayout
布局并且具体Api的调用思路与在xml中使用也是一致的。我们就来看看具体操作。
ConstraintLayout() {
Image()
Text()
Text()
}
一共两个元素:Image
,Text
,分别代表着xml里的ImageView
和TextView
。
- Image:
Image(
painter = rememberCoilPainter(request = item.url),
contentDescription = "",
contentScale = ContentScale.Crop,
modifier = Modifier
.clickable(onClick = {
val objectId = item.objectId
navController.navigate("detail/$objectId")
})
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.width(180.dp)
.height(160.dp)
.constrainAs(image) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
})
Image加载的是网络图片,则使用painter加载图片链接,contentScale
与xml中的scaleType
相似,modifier主要设置图片的样式,点击事件、宽高等。里面有一个需要注意的点constrainAs(image)
constrainAs(image) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
}
这段代码主要表示Image在父布局中的位置,例如相对父布局,相对其他子控件等,有点xml中layout_constraintTop_toBottomOf
内味。下面Text也是相同的道理。
- Text
Text(text = item.name,
color = Color.Black,
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.constrainAs(title) {
centerHorizontallyTo(parent)
top.linkTo(image.bottom)
}
)
Text的设置主要包含Text内容、文字类型、大小、颜色等。在constrainAs(title)
里有一句top.linkTo(image.bottom)
,这句代码指的就是xml中,TextView layout_constraintTop_toBottomOf ImageView
。
在Image和Text中发现了一个点,constrainAs(?)
中传入了一个值,且设置相对位置时也是以此值为控件的代表。这是在进行相对位置的设定之前,利用createRefs
创建多个引用,在ConstraintLayout中作为Modifier.constrainAs
的一部分分配给布局。
val (image, title, content) = createRefs()
具体代码:
ConstraintLayout() {
val (image, title, content) = createRefs()
//头像
Image(
//图片地址
painter = rememberCoilPainter(request = item.url),
contentDescription = "",
//图片缩放规则
contentScale = ContentScale.Crop,
modifier = Modifier
.clickable(onClick = {//点击事件
val objectId = item.objectId
navController.navigate("detail/$objectId")
})
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.width(180.dp)
.height(160.dp)
.constrainAs(image) {
centerHorizontallyTo(parent) //水平居中
top.linkTo(parent.top)//位于父布局的顶部
})
//文字
Text(text = item.name,
color = Color.Black,//颜色
style = MaterialTheme.typography.h6,//字体格式
textAlign = TextAlign.Center,
modifier = Modifier
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.constrainAs(title) {
centerHorizontallyTo(parent)//水平居中
top.linkTo(image.bottom)//位于图片的下方
}
)
Text(text = item.from,
color = Color.Black,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(4.dp)
.constrainAs(content) {
centerHorizontallyTo(parent)
top.linkTo(title.bottom)
})
}
数据填充
UI已经画好了,接下来就是数据展示的事情。还是以ViewModel-LiveData-Repository为整体请求方式。
因为数据都存储到了Bmob后台,就直接使用Bmob的方式查询数据:
private val bmobQuery: BmobQuery<GcDataItem> = BmobQuery()
fun queryRoleData(successLiveData: MutableLiveData<List<GcDataItem>>) {
bmobQuery.findObjects(object : FindListener<GcDataItem>() {
override fun done(list: MutableList<GcDataItem>?, e: BmobException?) {
if (e == null) {
successLiveData.value = list
}
}
})
}
具体的请求方式可参考Bmob的完档,这里就不在赘述。
ViewModel中还是抛出一个LiveData,而UI层相对之前有一些变化。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePoster(navController: NavController, model: HomeViewModel = viewModel()) {
model.queryGcData()
val data: List<GcDataItem> by model.getDataLiveData().observeAsState(listOf())
LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(data) {
ItemPoster(navController, item = it)
}
}
}
Compose提供了一个viewModel()
方法来获取ViewModel实例,至于怎么拿到数据,Compose提供了LiveData的一个扩展方法 observeAsState(listOf())
。它的主要作用是用来观察这个LiveData,并通过State表示它的值,每次有新值提交到LiveData时,返回的状态将被更新,从而导致每个状态的重新组合。
拿到List数据后,网格LazyVerticalGrid就开始使用items(data){}
添加列表,
LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(data) {
ItemPoster(navController, item = it)
}
}
而ItemPoster就是我们设置Item布局的地方,将每个Item的数据传递给ItemPoster,利用Image、Text等控件设置imageUrl、text内容等。
@Composable
fun ItemPoster(navController: NavController, item: GcDataItem) {
Surface(
modifier = Modifier
.padding(4.dp),
color = Color.White,
elevation = 8.dp,
shape = RoundedCornerShape(8.dp)
) {
ConstraintLayout() {
val (image, title, content) = createRefs()
Image(
//设置图片Url-item.url
painter = rememberCoilPainter(request = item.url),
...)
Text(text = item.name
...)
Text(text = item.from
...)
}
}
跳转
样例中还有一个从列表跳转到详情页的功能,Compose提供了一个跳转组件-navigation。这个navigation与之前管理Fragment的navigation思路也是一致的,利用NavHostController
进行不同页面的管理。我们先使用 rememberNavController()
方法创建一个NavHostController
实例。
val navController = rememberNavController()
接着将navController与NavHost相关联,且设置导航图的起始目的地startDestination
。
NavHost(navController = navController, startDestination = "Home") {}
我们将起始目的地暂时先标记为“Home”。
那如何对页面进行管理?这就需要在NavHost中使用composable添加页面,例如该项目有两个页面,一个首页列表页,一个详情页。我们就可以这样写:
NavHost(
navController = navController, startDestination = "Home"
) {
composable(
route = "Home",
){
HomePoster(navController)
}
composable("detail/{objectId}"){
val objectId = it.arguments?.getString("objectId")
DetailPoster(objectId){
navController.popBackStack()
}
}
}
第一个composable则代表的是列表页,并且将到达目的地的路线route
设置为“Home”,其实类似于ARouter框架中在每个Activity上设置Path,做一个标识作用,后面做跳转时也是依据该route进行跳转。
第二个composable则代表的是详情页,同样设置route="detail"
。
那如何从列表页跳到详情页?只需要在点击事件里使用navController.navigate("detail")
,传入想要跳转的route即可。
携带参数跳转
因为详情页需要根据所点击列表Item的Id进行数据查询,点击时要将id传到详情页,这就需要携带参数。
在Compose中,向route添加参数占位符,如"detail/{objectId}"
,从composable()
函数提取 NavArguments
。
如下修改详情页:
composable("detail/{objectId}"){
val objectId = it.arguments?.getString("objectId")
DetailPoster(objectId){
navController.popBackStack()
}
}
跳转时将objectId传到route的占位符中即可。
clickable(onClick = {
val objectId = item.objectId
navController.navigate("detail/$objectId")})
当然,compose navigation还支持launchMode设置、深层链接等,具体可查看官方文档。
一点感受
对于用习惯了xml编写UI的我来说,首次上手Compose其实还是蛮不习惯,Compose打破了原有的格局,给了我们一个全新的视角去看待Android,学完后有种“哦,原来UI还可以这么干!!”的感叹。对于Android开发者来说,其实需要这些新的路线去突破自己的固有化思维。
Compose的风格其实和Flutter有点像,估计是出于同一个爸爸的原因。但是Compose没有Flutter的无限套娃,对Android开发者来说还是比较友好的。如果想要学习Flutter,可以用Compose作为过渡。
以上便是本篇内容,感谢阅读,如果对你有帮助,欢迎点赞收藏关注三连走一波👉
项目地址:genshin-compose
欢迎关注公 z 号:9点大前端,每天9点推荐更多前端、Android、Flutter文章