- 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,
- )
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")
- }
- }
@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.csv")
}
) {
Text("Click")
}
}
- @Composable
- fun CreateDocumentSample() {
- val createDocumentLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) {
- if (it != null) {
- ...
- }
- }
- Button(
- onClick = {
- createDocumentLauncher.launch("sample")
- }
- ) {
- 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")
- }
- }
@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
- }
@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()
- }
- }
- }
@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
- }
@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>()
- }
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
- )
- }
- }
- }
@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)
- }
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),
- )
- }
- }
@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
- }
- )
- ) {
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}")
- }
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}")
- }
- }
- }
- }
@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}")
}
}
}
}
登録:
投稿 (Atom)