前言
在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。
在原本项目中我使用的是传统 view 配合 RecyclerView 和 GridLayout 布局方式进行拼图的预览,但是这会存在一个问题。
实际上是这样排列的:
但是预想中应该是这样排列:
可以看到,我们的需求应该是完全按照顺序来排列,但是瀑布流布局却是在每一行中,哪一列的高度最小就优先排到哪一列,而不是严格按照给定顺序排列。
显然,这是不符合我们的需求的。
我曾经试图找到其他的替代方式实现这个效果,或者试图找到 GridLayout 的某个参数可以修改为按顺序排列,但是一直无果。
最终,只能用自定义布局来实现我想要的效果了。但是对于原生 View 的自定义布局非常麻烦,我也没有接触过,所以就一直不了了之了。
最近一直在学习 compose ,发现 compose 的自定义布局还挺简单的,所以就萌生了使用 compose 的自定义布局来实现这个需求的想法。
由于这个项目是使用的传统 View ,并且已经上线运行很久了,不可能一蹴而就直接全部改成使用 compose,并且这个项目也还挺复杂的,移植起来也不简单。所以,我决定先只将此处的预览界面改为使用 compose,也就是混合使用 View 与 compose。
开始移植
compose 自定义布局
在开始之前我们需要先使用 compose 编写一个符合我们需求的自定义布局:
@Composable
fun TestLayout(
modifier: Modifier = Modifier,
columns: Int = 2,
content: @Composable ()->Unit
) {
Layout(
modifier = modifier,
content = content,
) { measurables: List<Measurable>, constrains: Constraints ->
val itemWidth = constrains.maxWidth / columns
val itemConstraints = constrains.copy(minWidth = itemWidth, maxWidth = itemWidth)
val placeables = measurables.map { it.measure(itemConstraints) }
val heights = IntArray(columns)
var rowNo = 0
layout(width = constrains.maxWidth, height = constrains.maxHeight){
placeables.forEach { placeable ->
placeable.placeRelative(itemWidth * rowNo, heights[rowNo])
heights[rowNo] += placeable.height
rowNo++
if (rowNo >= columns) rowNo = 0
}
}
}
}
这个自定义布局有三个参数:
modifier
Modifier 这个不用过多介绍
columns
表示一行需要放多少个 item
content
放置于其中的 itam
布局的实现也很简单,首先由于每个子 item 的宽度都是一致的,所以我们直接定义 item 宽度为当前布局的最大可用尺寸除以一行的 item 数量: val itemWidth = constrains.maxWidth / columns
。
然后创建一个 Array 用于存放每一列的当前高度,方便后面摆放时计算位置: val heights = IntArray(columns)
。
接下来遍历所有子项 placeables.forEach { placeable -> }
。并使用绝对坐标放置子项,且 x 坐标为 宽度乘以当前列, y 坐标为 当前列高度 placeable.placeRelative(itemWidth * rowNo, heights[rowNo])
。
最后将高度累加 heights[rowNo] += placeable.height
并更新列数到下一列 rowNo++
、 if (rowNo >= columns) rowNo = 0
下面预览一下效果:
@Composable
fun Test() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TestLayout {
Rectangle(height = 120, color = Color.Blue, index = "1")
Rectangle(height = 60, color = Color.LightGray, index = "2")
Rectangle(height = 140, color = Color.Yellow, index = "3")
Rectangle(height = 80, color = Color.Cyan, index = "4")
}
}
}
@Composable
fun Rectangle(height: Int, color: Color, index: String) {
Column(
modifier = Modifier
.size(width = 100.dp, height = height.dp)
.background(color),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = index, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
}
}
@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
Test()
}
效果如下:
完美符合我们的需求。
增加修改 gradle 配置
为了给已有项目增加 compose 支持我们需要增加一些依赖以及更新一些参数配置。
检查 AGP 版本
首先,我们需要确保 Android Gradle Plugins(AGP)版本是最新版本。
如果不是的话需要升级到最新版本,确保 compose 的使用,例如我写作时最新稳定版是 7.3.0
点击 Tools - AGP Upgrade Assistant 打开 AGP 升级助手,选择最新版本后升级即可。
检查 kotlin 版本
不同的 Compose Compiler 版本对于 kotlin 版本有要求,具体可以查看 Compose to Kotlin Compatibility Map
例如,我们这里使用 Compose Compiler 版本为 1.3.2 则要求 kotlin 版本为 1.7.20
修改配置信息
首先确保 API 等级大于等于21,然后启用 compose:
buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
配置 Compose Compiler 版本:
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
并且确保使用 JVM 版本为 Java 8 , 需要修改的所有配置信息如下:
android {
defaultConfig {
...
minSdkVersion 21
}
buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
...
// Set both the Java and Kotlin compilers to target Java 8.
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
}
添加依赖
dependencies {
// Integration with activities
implementation 'androidx.activity:activity-compose:1.5.1'
// Compose Material Design
implementation 'androidx.compose.material:material:1.2.1'
// Animations
implementation 'androidx.compose.animation:animation:1.2.1'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.2.1'
// Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.2.1'
}
自此所有配置修改完成,Sync 一下吧~
将 view 替换为 compose
根据我们的需求,我们需要替换的是用于预览拼图的 RecyclerView:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">
<!-- ... -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- ... -->
</androidx.constraintlayout.widget.ConstraintLayout>
将其替换为承载 compose 的 ComposeView:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">
<!-- ... -->
<androidx.compose.ui.platform.ComposeView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- ... -->
</androidx.constraintlayout.widget.ConstraintLayout>
在原本初始化 RecyclerView 的地方,将我们上面写好的 composable 设置进去。
将:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// ...
initRecyclerView()
// ...
}
改为:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// ...
bind.jointGifPreviewRecyclerView.setContent {
Test()
}
// ...
}
ComposeView
的 setContent(content: @Composable () -> Unit)
方法只有一个 content
参数,而这个参数是一个添加了 @Composable
注解的匿名函数,也就是说,在其中我们可以正常的使用 compose 了。
更改完成后看一下运行效果:
可以看到,混合使用完全没有问题。
但是这里我们使用的是写死的 item 数据,而不是用户动态选择的图片数据,所以下一步我们需要搞定 compose 和 view 之间的数据交互。
数据交互
首先,因为我们需要显示的动图,所以需要引入一下对动图的支持,这里我们直接使用 coil 。
引入 coil 依赖:
// coil compose
implementation 'io.coil-kt:coil-compose:2.2.2'
// coil gif 解码支持
implementation 'io.coil-kt:coil-gif:2.2.2'
定义一个用于显示 gif 的 composable:
@Composable
fun GifImage(
uri: Uri,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Image(
painter = rememberAsyncImagePainter(model = uri, imageLoader = imageLoader),
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.FillWidth
)
}
其中,rememberAsyncImagePainter
的 model
参数支持多种类型的图片,例如:File Uri String Drawable Bitmap 等,这里因为我们原本项目中使用的是 Uri ,所以我们也定义为使用 Uri。
而 coil 对于不同 API 版本支持两种解码器 ImageDecoderDecoder
和 GifDecoder
按照官方的说法:
Coil includes two separate decoders to support decoding GIFs. GifDecoder supports all API levels, but is slower. ImageDecoderDecoder is powered by Android’s ImageDecoder API which is only available on API 28 and above. ImageDecoderDecoder is faster than GifDecoder and supports decoding animated WebP images and animated HEIF image sequences.
简单翻译就是 GifDecoder
支持所有 API 版本,但是速度较慢; ImageDecoderDecoder
仅支持 API >= 28 但是速度较快。
因为我们的需求是宽度一致,等比缩放长度,所以需要给 Image
加上缩放类型 contentScale = ContentScale.FillWidth
。
之后把我们的自定义 Layout 改一下名字,其他内容不变: SquareLayout
增加一个 JointGifSquare 用作界面入口:
@Composable
fun JointGifSquare(
columns: Int,
uriList: ArrayList<Uri>,
) {
SquareLayout(columns = columns) {
uriList.forEachIndexed { index, uri ->
GifImage(
uri = uri,
)
}
}
}
其中 columns
表示每一行有多少列;uriList
表示需要显示 GIF 动图 Uri 列表。
最后,将 Fragmnet 中原本初始化 RecyclerView 的方法改为:
private fun initRecyclerView() {
val showGifResolutions = arrayListOf()
// 获取用户选择的图片列表,初始化 showGifResolutions
// ...
var lineLength = GifTools.JointGifSquareLineLength[gifUris!!.size]
bind.jointGifPreviewRecyclerView.setContent {
JointGifSquare(
lineLength,
gifUris!!
)
}
}
其中,GifTools.JointGifSquareLineLength
是我定义的一个 HashMap 用来存放所有图片数量与每一行数量的对应关系:
val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)
从上面可以看出,其实要从 compose 中拿到 View 的数据也很简单,直接传值进去即可。
最终运行效果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IsewV9LJ-1684632656916)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9c9d43bbdbd34bb08e0876781baaba02~tplv-k3u1fbpfcp-watermark.image?)]
原本使用 view 的运行效果:
可以看到,使用 compose 重构后的排列方式才是符合我们预期的排列方式。
总结
自此,我们就完成了将 View 中的其中一个界面替换为使用 compose 实现,也就是混合使用 view 和 compose 。
其实这个功能还有两个特性没有移植,那就是支持点击预览中的任意图片后可以更换图片和长按图片可以拖拽排序。
这两个功能的界面实现非常简单,难点在于,我怎么把更换图片和重新排序图片后的状态传回给 View。
这个问题我们就留着以后再说吧。
参考资料
- 深入Jetpack Compose——布局原理与自定义布局(一)
- Adding Jetpack Compose to your app