2023年12月25日月曜日

InlineTextContent を使って Composable のテキスト中にアイコンを表示する

val id = "inlineContent" val inlineContent = mapOf( Pair( id, InlineTextContent( Placeholder( width = 1.em, height = 1.em, placeholderVerticalAlign = PlaceholderVerticalAlign.AboveBaseline ) ) { Icon(imageVector = Icons.Default.OpenInNew, contentDescription = null) } ) ) Text( text = buildAnnotatedString { append("open here") appendInlineContent(id, "[open in new]") }, inlineContent = inlineContent, fontSize = 24.sp, ) PlaceholderVerticalAlign で上下の位置を指定できる。中央揃えにするなら PlaceholderVerticalAlign.TextCenter がよさげ。

2023年7月15日土曜日

ActivityResultContracts.CreateDocument に指定する mimeType には * を使ってはいけない

mimeType に "text/*"、ファイル名に "sample.csv" を指定した場合、ファイル名が重複したときの "(1)" が拡張子の後につけられてしまう。 @Composable fun CreateDocumentSample() { val createDocumentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) { if (it != null) { ... } } Button( onClick = { createDocumentLauncher.launch("sample.csv") } ) { Text("Click") } }


mimeType を "text/csv" にし、ファイル名を "sample.csv" や "sample" にした場合、ファイル名が重複したときの "(1)" が拡張子の前になる。 @Composable fun CreateDocumentSample() { val createDocumentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { if (it != null) { ... } } Button( onClick = { createDocumentLauncher.launch("sample.csv") } ) { Text("Click") } }
@Composable fun CreateDocumentSample() { val createDocumentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { if (it != null) { ... } } Button( onClick = { createDocumentLauncher.launch("sample") } ) { Text("Click") } }


名前についている拡張子が mimeType と異なる場合その部分は拡張子とは認識されず、mimeType に基づいた拡張子がつきます。

以下では mimeType が "text/plain" でファイル名が "sample.csv" なので ".csv" は拡張子とは認識されず、実際のファイル名は "sample.csv.txt" になります。 @Composable fun CreateDocumentSample() { val createDocumentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { if (it != null) { ... } } Button( onClick = { createDocumentLauncher.launch("sample.csv") } ) { Text("Click") } }

2023年5月27日土曜日

Compose Multiplatform で Image loading ってどうやるの?

技術書典14用に書いたんだけど mhidaka 多忙により寝かされてしまったため、ここで供養します。
夏コミ(C102)は別の記事を書く予定です。

==========================


今年(2023年)のKotlinConf(https://kotlinconf.com/)でCompose Multiplatformが発表されました。これまではCompose Desktopを使うことでAndroidとDesktopでUIコードを共通化することができました。Compose Multiplatformではこれに加えiOSとWebもサポートしています。2023年5月時点ではiOSはAlpha版、WebはExperimental版です。

この章ではAndroidとiOSのUIをCompose Multiplatformで共通化したときに、画像をネットワークから非同期に読み込む方法を紹介します。

ここで紹介する方法は2023年5月時点のものであり、今後APIが変わる可能性が大いにあります。注意してください。



Resources

Compose MultiplatformにはResourceというinterfaceが用意されています。 Resourceにはsuspend関数のreadBytes()が定義されており、戻り値はByteArrayです。 @ExperimentalResourceApi interface Resource { suspend fun readBytes(): ByteArray } このResourceを実装したLoadImageResourceを用意します。 LoadImageResourceのreadBytes()では指定されたURLからByteArrayを取得する処理を実装します。LoadImageResourceではKtor(https://ktor.io/)を使っています。 @OptIn(ExperimentalResourceApi::class) class LoadImageResource(private val url: String) : Resource { override suspend fun readBytes(): ByteArray { return HttpClient().use { it.get(url).readBytes() } } }

rememberImageBitmap

Resourceの拡張関数としてrememberImageBitmap()が用意されています。 rememberImageBitmap()はComposable関数で、戻り値はLoadState<ImageBitmap>です。 LaunchedEffectのなかでResourceのreadBytes()を呼び出し、返ってきたByteArrayをtoImageBitmap()でImageBitmapに変換しています。 @ExperimentalResourceApi @Composable fun Resource.rememberImageBitmap(): LoadState<ImageBitmap> { val state: MutableState<LoadState<ImageBitmap>> = remember(this) { mutableStateOf(LoadState.Loading()) } LaunchedEffect(this) { state.value = try { LoadState.Success(readBytes().toImageBitmap()) } catch (e: Exception) { LoadState.Error(e) } } return state.value } LoadStateはsealed classでLoading、Success、Errorの3つのサブクラスがあります。 sealed class LoadState<T> { class Loading<T> : LoadState<T>() data class Success<T>(val value: T) : LoadState<T>() data class Error<T>(val exception: Exception) : LoadState<T>() }

AsyncImage

画像を読み込んで表示したいURLに対してLoadImageResourceを作り、rememberImageBitmap()の返す LoadStateに応じてComposableを表示します。 LoadState.SuccessのvalueプロパティからImageBitmapを取得し、BitmapPainterでPainterを作ります。 最後にBitmapPainterをImage composableにセットすることで画像が表示されます。 @OptIn(ExperimentalResourceApi::class) @Composable fun AsyncImage( url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, ) { val resource = remember(url) { LoadImageResource(url) } when (val loadState = resource.rememberImageBitmap()) { is LoadState.Loading, is LoadState.Error -> { Spacer(modifier = modifier.background(Color.LightGray)) } is LoadState.Success -> { Image( painter = BitmapPainter(loadState.value), contentDescription = contentDescription, contentScale = contentScale, modifier = modifier ) } } }

まとめ

これでCompose MultiplatformのAndroid、iOS両方でネットワークから読み込んだ画像が表示されます。 実際のアプリで使うにはキャッシュ機能などが必要になりますし、そのうちライブラリもでてくるでしょう。 Compose Multiplatformぜひ試してみてください。



2023年4月24日月曜日

Media3 の PlayerView で動画再生前の黒い画面の色を変える

setShutterBackgroundColor() を使う PlayerView(context).apply { ... setShutterBackgroundColor(backgroundColor) }

2023年3月16日木曜日

Compose の LottieAnimation でクリックしたときにアニメーションさせる

rememberLottieAnimatable を使います。 @Preview @Composable fun LottieAnimationSample() { val composition by rememberLottieComposition( LottieCompositionSpec.RawRes(R.raw.lottie_animation) ) val lottieAnimatable = rememberLottieAnimatable() val coroutineScope = rememberCoroutineScope() Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures( onTap = { coroutineScope.launch { lottieAnimatable.animate(composition) } }, ) } ) { LottieAnimation( composition = composition, progress = { lottieAnimatable.value }, modifier = Modifier.size(72.dp), ) } }

2023年3月8日水曜日

Navigation Compose の optional arguments に null を渡したいときは query parameter 自体を消さないといけない

Navigation Compose の destination に optional arguments を設定する場合、nullable = true を設定します(もしくは defaultValue を設定する)。 composable( route = "detail?id={id}", arguments = listOf( navArgument("id") { type = NavType.StringType nullable = true } ) ) { この場合、NavHostController.navigate() に渡す文字列と id の値の関係は次のようになります。

destination 先での id の値
navController.navigate("detail")null
navController.navigate("detail?id=AAAAA")"AAAA"
navController.navigate("detail?id=")""
val id: String? = null
navController.navigate("detail?id=${id}")
"null"


注意しないといけないのは "detail?id=${id}" です。id 変数が null のとき、これは "detail?id=null" になり、destination 先では "null" という文字列が取得されてしまいます。

よって id 変数が null かどうかによって次のように navigate() に渡す文字列を変える必要があります。 if (id == null) { navController.navigate("detail") } else { navController.navigate("detail?id=${id}") }

挙動確認のコード @Composable fun NavigationSample() { val navController = rememberNavController() NavHost( navController = navController, startDestination = "home" ) { composable("home") { Column { Text("home") Button( onClick = { navController.navigate("detail") } ) { Text("detail") } Button( onClick = { navController.navigate("detail?id=AAAAA") } ) { Text("detail?id=AAAAA") } Button( onClick = { navController.navigate("detail?id=") } ) { Text("detail?id=") } val id: String? = null Button( onClick = { navController.navigate("detail?id=${id}") } ) { Text("detail?id=${id}") } } } composable( route = "detail?id={id}", arguments = listOf( navArgument("id") { type = NavType.StringType nullable = true } ) ) { Column { val arguments = requireNotNull(it.arguments) val id = arguments.getString("id") Text(text = "detail") Text(text = "id is null : ${id == null}") } } } }