📸
Compose MultiplatformでコンポーネントをImageBitmapにするには
こんにちは!sugitaniと申します。ブラックキャット・カーニバル(略称ブラキャニ)というCompose Multiplatformで作られたSNSアプリを開発しています。
本稿はブラキャニの開発で得られた"あれどうやるんだっけ" を備忘録も兼ねて共有していくシリーズの2作目です。
コンポーネントをImageBitmapにする方法
ブラキャニは開発リソースの問題によりブラウザから閲覧する機能がありません。暫定の対応として、アプリ内の様子をキャプチャしてシェアできる機能を作成しました。
理屈として
-
ImageBitmap
はサイズ指定だけで作れる -
Canvas
はImageBitmap
から作れる(〜に書き込める) -
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)
を呼ばないとクラッシュします
サンプルプロジェクト
本稿のソースコード、および動作するコードは
にあります。免責事項
このコードはあくまで"自分はこう実装した"という例ですので、よりよい方法がある可能性があります。見つけたら教えてください!
以下宣伝
- コメントのしやすさともらいやすさに全力を注いだショートSNS ブラキャニ をお試しください
- ブラックキャット・カーニバル株式会社のPublicationではCompose Multiplatformに関する記事を沢山投稿しています
- Compose Multiplatformでアプリのバックグラウンド切り替えを検知するには
- Compose MultiplatformでコンポーネントをImageBitmapにするには
- Compose MultiplatformでKoinを使って環境別のDIを行う方法
- Compose Multiplatformでexpect/actialでContextをうまく扱う方法
- Compose Multiplatformで後から重ねられる&一部をくり抜いたコンポーネントを作るには
- Compose MultiplatformでImageBitmapとBitmap/UIImageを相互変換するには
- Compose MultiplatformでLiquid glass対応のタブ表示を行うには
Discussion