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年12月25日月曜日
InlineTextContent を使って Composable のテキスト中にアイコンを表示する
2023年7月15日土曜日
ActivityResultContracts.CreateDocument に指定する mimeType には * を使ってはいけない
mimeType に "text/*"、ファイル名に "sample.csv" を指定した場合、ファイル名が重複したときの "(1)" が拡張子の後につけられてしまう。
mimeType を "text/csv" にし、ファイル名を "sample.csv" や "sample" にした場合、ファイル名が重複したときの "(1)" が拡張子の前になる。
名前についている拡張子が mimeType と異なる場合その部分は拡張子とは認識されず、mimeType に基づいた拡張子がつきます。
以下では mimeType が "text/plain" でファイル名が "sample.csv" なので ".csv" は拡張子とは認識されず、実際のファイル名は "sample.csv.txt" になります。
@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が変わる可能性が大いにあります。注意してください。
夏コミ(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 を設定する)。
注意しないといけないのは "detail?id=${id}" です。id 変数が null のとき、これは "detail?id=null" になり、destination 先では "null" という文字列が取得されてしまいます。
よって id 変数が null かどうかによって次のように navigate() に渡す文字列を変える必要があります。
挙動確認のコード
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}")
}
}
}
}