2023年12月25日月曜日

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

  1. val id = "inlineContent"  
  2.   
  3. val inlineContent = mapOf(  
  4.     Pair(  
  5.         id,  
  6.         InlineTextContent(  
  7.             Placeholder(  
  8.                 width = 1.em,  
  9.                 height = 1.em,  
  10.                 placeholderVerticalAlign = PlaceholderVerticalAlign.AboveBaseline  
  11.             )  
  12.         ) {  
  13.             Icon(imageVector = Icons.Default.OpenInNew, contentDescription = null)  
  14.         }  
  15.     )  
  16. )  
  17.   
  18. Text(  
  19.     text = buildAnnotatedString {  
  20.         append("open here")  
  21.         appendInlineContent(id, "[open in new]")  
  22.     },  
  23.     inlineContent = inlineContent,  
  24.     fontSize = 24.sp,  
  25. )  
PlaceholderVerticalAlign で上下の位置を指定できる。中央揃えにするなら PlaceholderVerticalAlign.TextCenter がよさげ。

2023年7月15日土曜日

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

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


mimeType を "text/csv" にし、ファイル名を "sample.csv" や "sample" にした場合、ファイル名が重複したときの "(1)" が拡張子の前になる。
  1. @Composable  
  2. fun CreateDocumentSample() {  
  3.     val createDocumentLauncher =  
  4.         rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) {  
  5.             if (it != null) {  
  6.                 ...  
  7.             }  
  8.         }  
  9.   
  10.     Button(  
  11.         onClick = {  
  12.             createDocumentLauncher.launch("sample.csv")  
  13.         }  
  14.     ) {  
  15.         Text("Click")  
  16.     }  
  17. }  
  1. @Composable  
  2. fun CreateDocumentSample() {  
  3.     val createDocumentLauncher =  
  4.         rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) {  
  5.             if (it != null) {  
  6.                 ...  
  7.             }  
  8.         }  
  9.   
  10.     Button(  
  11.         onClick = {  
  12.             createDocumentLauncher.launch("sample")  
  13.         }  
  14.     ) {  
  15.         Text("Click")  
  16.     }  
  17. }  


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

以下では mimeType が "text/plain" でファイル名が "sample.csv" なので ".csv" は拡張子とは認識されず、実際のファイル名は "sample.csv.txt" になります。
  1. @Composable  
  2. fun CreateDocumentSample() {  
  3.     val createDocumentLauncher =  
  4.         rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {  
  5.             if (it != null) {  
  6.                 ...  
  7.             }  
  8.         }  
  9.   
  10.     Button(  
  11.         onClick = {  
  12.             createDocumentLauncher.launch("sample.csv")  
  13.         }  
  14.     ) {  
  15.         Text("Click")  
  16.     }  
  17. }  

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です。
  1. @ExperimentalResourceApi  
  2. interface Resource {  
  3.     suspend fun readBytes(): ByteArray  
  4. }  
このResourceを実装したLoadImageResourceを用意します。 LoadImageResourceのreadBytes()では指定されたURLからByteArrayを取得する処理を実装します。LoadImageResourceではKtor(https://ktor.io/)を使っています。
  1. @OptIn(ExperimentalResourceApi::class)  
  2. class LoadImageResource(private val url: String) : Resource {  
  3.     override suspend fun readBytes(): ByteArray {  
  4.         return HttpClient().use {  
  5.             it.get(url).readBytes()  
  6.         }  
  7.     }  
  8. }  


rememberImageBitmap

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


AsyncImage

画像を読み込んで表示したいURLに対してLoadImageResourceを作り、rememberImageBitmap()の返す LoadStateに応じてComposableを表示します。 LoadState.SuccessのvalueプロパティからImageBitmapを取得し、BitmapPainterでPainterを作ります。 最後にBitmapPainterをImage composableにセットすることで画像が表示されます。
  1. @OptIn(ExperimentalResourceApi::class)  
  2. @Composable  
  3. fun AsyncImage(  
  4.     url: String,  
  5.     contentDescription: String?,  
  6.     modifier: Modifier = Modifier,  
  7.     contentScale: ContentScale = ContentScale.Fit,  
  8. ) {  
  9.     val resource = remember(url) { LoadImageResource(url) }  
  10.   
  11.     when (val loadState = resource.rememberImageBitmap()) {  
  12.         is LoadState.Loading,  
  13.         is LoadState.Error -> {  
  14.             Spacer(modifier = modifier.background(Color.LightGray))  
  15.         }  
  16.         is LoadState.Success -> {  
  17.             Image(  
  18.                 painter = BitmapPainter(loadState.value),  
  19.                 contentDescription = contentDescription,  
  20.                 contentScale = contentScale,  
  21.                 modifier = modifier  
  22.             )  
  23.         }  
  24.     }  
  25. }  


まとめ

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



2023年4月24日月曜日

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

setShutterBackgroundColor() を使う
  1. PlayerView(context).apply {  
  2.     ...  
  3.   
  4.     setShutterBackgroundColor(backgroundColor)  
  5. }  

2023年3月16日木曜日

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

rememberLottieAnimatable を使います。
  1. @Preview  
  2. @Composable  
  3. fun LottieAnimationSample() {  
  4.     val composition by rememberLottieComposition(  
  5.         LottieCompositionSpec.RawRes(R.raw.lottie_animation)  
  6.     )  
  7.     val lottieAnimatable = rememberLottieAnimatable()  
  8.     val coroutineScope = rememberCoroutineScope()  
  9.   
  10.     Box(  
  11.         contentAlignment = Alignment.Center,  
  12.         modifier = Modifier  
  13.             .fillMaxSize()  
  14.             .pointerInput(Unit) {  
  15.                 detectTapGestures(  
  16.                     onTap = {  
  17.                         coroutineScope.launch {  
  18.                             lottieAnimatable.animate(composition)  
  19.                         }  
  20.                     },  
  21.                 )  
  22.             }  
  23.     ) {  
  24.         LottieAnimation(  
  25.             composition = composition,  
  26.             progress = { lottieAnimatable.value },  
  27.             modifier = Modifier.size(72.dp),  
  28.         )  
  29.     }  
  30. }    

2023年3月8日水曜日

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

Navigation Compose の destination に optional arguments を設定する場合、nullable = true を設定します(もしくは defaultValue を設定する)。
  1. composable(  
  2.     route = "detail?id={id}",  
  3.     arguments = listOf(  
  4.         navArgument("id") {  
  5.             type = NavType.StringType  
  6.             nullable = true  
  7.         }  
  8.     )  
  9. ) {  
この場合、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() に渡す文字列を変える必要があります。
  1. if (id == null) {  
  2.     navController.navigate("detail")  
  3. else {  
  4.     navController.navigate("detail?id=${id}")  
  5. }  


挙動確認のコード
  1. @Composable  
  2. fun NavigationSample() {  
  3.     val navController = rememberNavController()  
  4.     NavHost(  
  5.         navController = navController,  
  6.         startDestination = "home"  
  7.     ) {  
  8.         composable("home") {  
  9.             Column {  
  10.                 Text("home")  
  11.   
  12.                 Button(  
  13.                     onClick = {  
  14.                         navController.navigate("detail")  
  15.                     }  
  16.                 ) {  
  17.                     Text("detail")  
  18.                 }  
  19.   
  20.                 Button(  
  21.                     onClick = {  
  22.                         navController.navigate("detail?id=AAAAA")  
  23.                     }  
  24.                 ) {  
  25.                     Text("detail?id=AAAAA")  
  26.                 }  
  27.   
  28.                 Button(  
  29.                     onClick = {  
  30.                         navController.navigate("detail?id=")  
  31.                     }  
  32.                 ) {  
  33.                     Text("detail?id=")  
  34.                 }  
  35.     
  36.                 val id: String? = null  
  37.                 Button(  
  38.                     onClick = {  
  39.                         navController.navigate("detail?id=${id}")  
  40.                     }  
  41.                 ) {  
  42.                     Text("detail?id=${id}")  
  43.                 }  
  44.             }  
  45.         }  
  46.   
  47.         composable(  
  48.             route = "detail?id={id}",  
  49.             arguments = listOf(  
  50.                 navArgument("id") {  
  51.                     type = NavType.StringType  
  52.                     nullable = true  
  53.                 }  
  54.             )  
  55.         ) {  
  56.             Column {  
  57.                 val arguments = requireNotNull(it.arguments)  
  58.                 val id = arguments.getString("id")  
  59.                 Text(text = "detail")  
  60.                 Text(text = "id is null : ${id == null}")  
  61.             }  
  62.         }  
  63.     }  
  64. }