📸

Compose MultiplatformでコンポーネントをImageBitmapにするには

に公開

こんにちは!sugitaniと申します。ブラックキャット・カーニバル(略称ブラキャニ)というCompose Multiplatformで作られたSNSアプリを開発しています。

本稿はブラキャニの開発で得られた"あれどうやるんだっけ" を備忘録も兼ねて共有していくシリーズの2作目です。

コンポーネントをImageBitmapにする方法

ブラキャニは開発リソースの問題によりブラウザから閲覧する機能がありません。暫定の対応として、アプリ内の様子をキャプチャしてシェアできる機能を作成しました。

理屈として

  1. ImageBitmapはサイズ指定だけで作れる
  2. CanvasImageBitmapから作れる(〜に書き込める)
  3. DrawScope.drawはcanvasを対象にできる

となります。

簡単なコンポーネントをImageBitmapにするアプリを実装してみましょう

キャプチャ前 キャプチャ後

ComponentToImageBitmapの作成

上記の原理をコードにした物です

package cc.bcc.cmpexamples.example002

import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.draw
import kotlinx.coroutines.delay

@Composable
fun ComponentToImageBitmap(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
): ImageBitmap? {
    var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }

    // Wait until drawing is complete. Can be removed if content doesn't contain LaunchedEffect etc.
    var waited by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        delay(500)
        waited = true
    }

    Column(
        modifier =
            modifier
                .drawWithCache {
                    val width = this.size.width.toInt()
                    val height = this.size.height.toInt()

                    val newImageBitmap = ImageBitmap(width, height)
                    val canvas = Canvas(newImageBitmap)

                    if (waited) {
                        onDrawWithContent {
                            imageBitmap = newImageBitmap
                            draw(this, this.layoutDirection, canvas, this.size) {
                                this@onDrawWithContent.drawContent()
                            }
                        }
                    } else {
                        onDrawWithContent {
                            // Wait for content to be ready
                        }
                    }
                },
    ) {
        content()
    }

    return imageBitmap
}

使い方

以下のように使います。

package cc.bcc.cmpexamples.example002

import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.ui.tooling.preview.Preview

@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
internal fun App() {
    var capturedImage by remember { mutableStateOf<ImageBitmap?>(null) }
    var shouldCapture by remember { mutableStateOf(false) }

    MaterialTheme {
        Scaffold(
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text("Capture Example") },
                )
            },
        ) { innerPadding ->

            Column(
                modifier =
                    Modifier
                        .padding(innerPadding)
                        .fillMaxSize()
                        .padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp),
            ) {
                // Sample component to capture
                Box {
                    if (shouldCapture) {
                        capturedImage =
                            ComponentToImageBitmap(
                                modifier = Modifier.fillMaxWidth(),
                            ) {
                                SampleComponent()
                            }
                    }

                    SampleComponent()
                }

                // Capture button
                Button(
                    onClick = {
                        shouldCapture = !shouldCapture
                        if (!shouldCapture) capturedImage = null
                    },
                    modifier = Modifier.fillMaxWidth(),
                ) {
                    Text(if (shouldCapture) "Reset" else "Capture Component")
                }

                // Display captured image
                capturedImage?.let { bitmap ->
                    Image(
                        bitmap = bitmap,
                        contentDescription = "Captured component",
                        modifier =
                            Modifier
                                .fillMaxWidth()
                                .border(2.dp, Color.Gray),
                    )
                }
            }
        }
    }
}

@Composable
private fun SampleComponent() {
    Card(
        modifier =
            Modifier
                .fillMaxWidth()
                .padding(8.dp),
        colors = CardDefaults.cardColors(containerColor = Color(0xFF6200EE)),
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text(
                text = "Hello Capture!",
                color = Color.White,
                fontSize = 24.sp,
                fontWeight = FontWeight.Bold,
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "This component will be captured",
                color = Color.White.copy(alpha = 0.8f),
                fontSize = 14.sp,
            )
        }
    }
}

注意点

  • アニメーションやLaunchedEffect等が関係して描画が遅延するものをキャプチャする場合はコード中のdelay500ms等のように小細工が必要になります
  • 昔の実験なので現在もそうなのかはわかりませんがAndroidでModifier.shadowが効きません。代わりに adamglin0/compose-shadow等を使う必要があります
  • かなり昔の実験なので現在もそうなのかはわかりませんが、バックグラウンドでのキャプチャはテキストが絡まなければできました(テキストはSkiaかSkikoのどちらかでマルチスレッド前提ではないコードがあって駄目だった)
  • 画像読み込みにcoil3を利用している場合ImageLoaderのallowHardware(false)を呼ばないとクラッシュします

サンプルプロジェクト

本稿のソースコード、および動作するコードは
https://github.com/blackcat-carnival/cmp-examples/tree/main/002.component_to_image
にあります。

免責事項

このコードはあくまで"自分はこう実装した"という例ですので、よりよい方法がある可能性があります。見つけたら教えてください!

以下宣伝

ブラックキャット・カーニバル

Discussion