玩会儿Compose,原神主题列表

4,906 阅读6分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

Jetpack Compose出来有一段时间了,一直都没有去尝试,这次有点想法去玩一玩这个声明性界面工具,就以“原神”为主题写个列表吧。

整体设计参考DisneyCompose

效果图:

image.png

image.png

数据源

因为数据比较简单,也就只包含图片、姓名、描述等。所以在后台数据存储上选择的是Bmob后端云,一个方便前端开发的后端服务平台。

主要数据也是从原神各大网站搜集下来的,新建表结构并且将数据填充,我们简单看一下Bmob的后台。

image.png

数据准备好了,那就开始我们的Compose之旅。

首页UI绘制

整体结构

从上面的项目效果图来看,首页总布局属于是一个网格列表,平分两格,列表中的每个Item上方带有头像,头像下面是角色名称以及角色其他信息。

image.png

网格布局

因为整体分成两列,所以选择的是网格布局,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,最后在来个TextViewTopToBottomOf第一个TextView

那使用Compose应该怎么写?

其实在Compose里也存在着ConstraintLayout布局并且具体Api的调用思路与在xml中使用也是一致的。我们就来看看具体操作。

ConstraintLayout() {
Image()
Text()
Text()
}

一共两个元素:ImageText,分别代表着xml里的ImageViewTextView

  • 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)

                    })
        }

image.png

数据填充

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