Compose太香了,不想再寫傳統 xml View?教你如何在已有View專案中混合使用Compose

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第9天,點選檢視活動詳情

前言

在我的文章 記一次 kotlin 在 MutableList 中使用 remove 引發的問題 中,我提到有一個功能是將多張動圖以N宮格的形式拼接,並且每個動圖的寬保證一致,但是高不保證一致。

在原本專案中我使用的是傳統 view 配合 RecyclerView 和 GridLayout 佈局方式進行拼圖的預覽,但是這會存在一個問題。

實際上是這樣排列的:

s1.png

但是預想中應該是這樣排列:

s2.png

可以看到,我們的需求應該是完全按照順序來排列,但是瀑布流佈局卻是在每一行中,哪一列的高度最小就優先排到哪一列,而不是嚴格按照給定順序排列。

顯然,這是不符合我們的需求的。

我曾經試圖找到其他的替代方式實現這個效果,或者試圖找到 GridLayout 的某個引數可以修改為按順序排列,但是一直無果。

最終,只能用自定義佈局來實現我想要的效果了。但是對於原生 View 的自定義佈局非常麻煩,我也沒有接觸過,所以就一直不了了之了。

最近一直在學習 compose ,發現 compose 的自定義佈局還挺簡單的,所以就萌生了使用 compose 的自定義佈局來實現這個需求的想法。

由於這個專案是使用的傳統 View ,並且已經上線執行很久了,不可能一蹴而就直接全部改成使用 compose,並且這個專案也還挺複雜的,移植起來也不簡單。所以,我決定先只將此處的預覽介面改為使用 compose,也就是混合使用 View 與 compose。

開始移植

compose 自定義佈局

在開始之前我們需要先使用 compose 編寫一個符合我們需求的自定義佈局:

```kotlin @Composable fun TestLayout( modifier: Modifier = Modifier, columns: Int = 2, content: @Composable ()->Unit ) { Layout( modifier = modifier, content = content, ) { measurables: List, 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

下面預覽一下效果:

```kotlin @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() } ```

效果如下:

s2.png

完美符合我們的需求。

增加修改 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:

groovy buildFeatures { // Enables Jetpack Compose for this module compose true }

配置 Compose Compiler 版本:

groovy composeOptions { kotlinCompilerExtensionVersion '1.3.2' }

並且確保使用 JVM 版本為 Java 8 , 需要修改的所有配置資訊如下:

```groovy 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'
}

} ```

新增依賴

groovy 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:

```kotlin

<!-- ... -->

<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" />

<!-- ... -->

```

將其替換為承載 compose 的 ComposeView:

```kotlin

<!-- ... -->

<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" />

<!-- ... -->

```

在原本初始化 RecyclerView 的地方,將我們上面寫好的 composable 設定進去。

將:

```kotlin override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState)

// ...

initRecyclerView()

// ...

}

```

改為:

```kotlin override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState)

// ...

bind.jointGifPreviewRecyclerView.setContent {
    Test()
}

// ...

} ```

ComposeViewsetContent(content: @Composable () -> Unit) 方法只有一個 content 引數,而這個引數是一個添加了 @Composable 註解的匿名函式,也就是說,在其中我們可以正常的使用 compose 了。

更改完成後看一下執行效果:

s3.png

可以看到,混合使用完全沒有問題。

但是這裡我們使用的是寫死的 item 資料,而不是使用者動態選擇的圖片資料,所以下一步我們需要搞定 compose 和 view 之間的資料互動。

資料互動

首先,因為我們需要顯示的動圖,所以需要引入一下對動圖的支援,這裡我們直接使用 coil 。

引入 coil 依賴:

groovy // coil compose implementation 'io.coil-kt:coil-compose:2.2.2' // coil gif 解碼支援 implementation 'io.coil-kt:coil-gif:2.2.2'

定義一個用於顯示 gif 的 composable:

```kotlin @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
)

} ```

其中,rememberAsyncImagePaintermodel 引數支援多種型別的圖片,例如:File Uri String Drawable Bitmap 等,這裡因為我們原本專案中使用的是 Uri ,所以我們也定義為使用 Uri。

而 coil 對於不同 API 版本支援兩種解碼器 ImageDecoderDecoderGifDecoder 按照官方的說法:

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 用作介面入口:

kotlin @Composable fun JointGifSquare( columns: Int, uriList: ArrayList<Uri>, ) { SquareLayout(columns = columns) { uriList.forEachIndexed { index, uri -> GifImage( uri = uri, ) } } }

其中 columns 表示每一行有多少列;uriList 表示需要顯示 GIF 動圖 Uri 列表。

最後,將 Fragmnet 中原本初始化 RecyclerView 的方法改為:

```kotlin private fun initRecyclerView() { val showGifResolutions = arrayListOf()

// 獲取使用者選擇的圖片列表,初始化 showGifResolutions

// ...

var lineLength = GifTools.JointGifSquareLineLength[gifUris!!.size]

bind.jointGifPreviewRecyclerView.setContent {
    JointGifSquare(
        lineLength,
        gifUris!!
    )
}

} ```

其中,GifTools.JointGifSquareLineLength 是我定義的一個 HashMap 用來存放所有圖片數量與每一行數量的對應關係:

kotlin 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 的資料也很簡單,直接傳值進去即可。

最終執行效果:

g1.gif

原本使用 view 的執行效果:

g2.gif

可以看到,使用 compose 重構後的排列方式才是符合我們預期的排列方式。

總結

自此,我們就完成了將 View 中的其中一個介面替換為使用 compose 實現,也就是混合使用 view 和 compose 。

其實這個功能還有兩個特性沒有移植,那就是支援點選預覽中的任意圖片後可以更換圖片和長按圖片可以拖拽排序。

這兩個功能的介面實現非常簡單,難點在於,我怎麼把更換圖片和重新排序圖片後的狀態傳回給 View。

這個問題我們就留著以後再說吧。

參考資料

  1. 深入Jetpack Compose——佈局原理與自定義佈局(一)
  2. Adding Jetpack Compose to your app