tag:blogger.com,1999:blog-58599514536641222032024-03-15T04:29:35.700+09:00Y.A.M の 雑記帳yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.comBlogger955125tag:blogger.com,1999:blog-5859951453664122203.post-2534091155253193462024-02-27T08:24:00.000+09:002024-02-27T08:24:17.120+09:00Android Studio (IntelliJ IDEA) の Replace の正規表現モードを使う例えば
<code name="code" class="java">
assertThat(answer).isEqualTo(2)
</code>
を
<code name="code" class="java">
assertEquals(2, answer)
</code>
に置き換えたい場合、Replace の正規表現モードを使うことで簡単に変換できる。
<br><br>
Cmd + Shift + R で Replace in Files window を開き、<br>
(そのファイルだけ置き換えたいときは Cmd + R)<br>
変換元を入力するフィールドの右端の 「.*」 部分をオンにする。<br><br>
変換元に
<code name="code" class="java">
assertThat\((.*)\)\.isEqualTo\((.*)\)
</code>
変換先に
<code name="code" class="java">
assertEquals\($2, $1\)
</code>
と入れて、Replace All ボタンを押すと全部置き換わる。便利!
<br><br>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh82LwPcwze0ip9EY5aic7FdINB8ybYtWJ1o9xGXLtoc6GT5vwyp9JviXBdpD7jxFtQ9FIeK5idY6QpN2vLZ2eg93AHyCMXDn5d8fNc8UyyKTsdRKUu_QkXvDq4PVwavK7GQjjeeGePvNT542mfzwWMKnJDbU-UvSd4E3oGLXLpRQHllzGOF0A5vp1fYLal/s1736/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202024-02-26%2021.22.12.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="600" data-original-height="1520" data-original-width="1736" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh82LwPcwze0ip9EY5aic7FdINB8ybYtWJ1o9xGXLtoc6GT5vwyp9JviXBdpD7jxFtQ9FIeK5idY6QpN2vLZ2eg93AHyCMXDn5d8fNc8UyyKTsdRKUu_QkXvDq4PVwavK7GQjjeeGePvNT542mfzwWMKnJDbU-UvSd4E3oGLXLpRQHllzGOF0A5vp1fYLal/s600/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202024-02-26%2021.22.12.png"/></a></div>
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-6640465350544432892023-12-25T15:36:00.003+09:002023-12-25T15:38:10.843+09:00InlineTextContent を使って Composable のテキスト中にアイコンを表示する<code name="code" class="java">
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,
)
</code>
PlaceholderVerticalAlign で上下の位置を指定できる。中央揃えにするなら PlaceholderVerticalAlign.TextCenter がよさげ。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiACgMMB7PkZNaqz8I3CHDMl6uQ65IIJB8fg6DNrg3eA5eWY3UAgzN-u_A4C5ylXKHHiyRLhYQZBHbIUwMMOO5yCqGkQusdDrMkM7bVBgCtJwu5xXet4janwHJVur3DJULs4K8lD5FPyTRo-PHNoQOWLMXQptcFan6GpoC6QLnpvwiq82mAvOQnRtJZcI4v/s1600/Screenshot_20231225_153127.png" style="display: block; padding: 1em 0; text-align: center; "><img height=640 alt="" border="0" data-original-height="1920" data-original-width="1080" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiACgMMB7PkZNaqz8I3CHDMl6uQ65IIJB8fg6DNrg3eA5eWY3UAgzN-u_A4C5ylXKHHiyRLhYQZBHbIUwMMOO5yCqGkQusdDrMkM7bVBgCtJwu5xXet4janwHJVur3DJULs4K8lD5FPyTRo-PHNoQOWLMXQptcFan6GpoC6QLnpvwiq82mAvOQnRtJZcI4v/s1600/Screenshot_20231225_153127.png"/></a></div>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-1132236282078503762023-07-15T12:59:00.000+09:002023-07-15T12:59:13.349+09:00ActivityResultContracts.CreateDocument に指定する mimeType には * を使ってはいけないmimeType に "text/*"、ファイル名に "sample.csv" を指定した場合、ファイル名が重複したときの "(1)" が拡張子の後につけられてしまう。
<code name="code" class="java">
@Composable
fun CreateDocumentSample() {
val createDocumentLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
if (it != null) {
...
}
}
Button(
onClick = {
createDocumentLauncher.launch("sample.csv")
}
) {
Text("Click")
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjB9wB2wACcmWRQqIHY4RnLD7Lf0PYNweiDkpYf-yAFhtWekjgomgdhT_tMfYePI9Adpa5ip4_9sFYgWsxWxu8q8LoJ22IMBynkistss2PyOj741-Z0Q0NkK_l3XTcPBsHqtBfXkWLK_a28xELtfygucgXnPnfHpz5Thh4vl7M_POtdYpj4f3tMDTmDA5AY/s1920/Screenshot_20230715_124425.png" style="display: block; padding: 1em 0; text-align: center;"><img alt="" border="0" height="600" data-original-height="1920" data-original-width="1080" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjB9wB2wACcmWRQqIHY4RnLD7Lf0PYNweiDkpYf-yAFhtWekjgomgdhT_tMfYePI9Adpa5ip4_9sFYgWsxWxu8q8LoJ22IMBynkistss2PyOj741-Z0Q0NkK_l3XTcPBsHqtBfXkWLK_a28xELtfygucgXnPnfHpz5Thh4vl7M_POtdYpj4f3tMDTmDA5AY/s600/Screenshot_20230715_124425.png"/></a></div>
<br><br>
mimeType を "text/csv" にし、ファイル名を "sample.csv" や "sample" にした場合、ファイル名が重複したときの "(1)" が拡張子の前になる。
<code name="code" class="java">
@Composable
fun CreateDocumentSample() {
val createDocumentLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) {
if (it != null) {
...
}
}
Button(
onClick = {
createDocumentLauncher.launch("sample.csv")
}
) {
Text("Click")
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgHf5V81OVjOx88pMIeLX1KC0P4MTbcu1Hb-47NOvLj9GinbHEHh3f3AT0B5kPI-zz6al7z6RWX-BagVVzbH8-op94E2eVzBU2hJofjhV5faA1QaDg2X4aAQVfQnJq32NVecpBbDJqGd447S7DGwQu-CKNsjzgKXB7BYExUGJpyTdFqr503d4W97fxTovMt/s1920/Screenshot_20230715_124741.png" style="display: block; padding: 1em 0; text-align: center;"><img alt="" border="0" height="600" data-original-height="1920" data-original-width="1080" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgHf5V81OVjOx88pMIeLX1KC0P4MTbcu1Hb-47NOvLj9GinbHEHh3f3AT0B5kPI-zz6al7z6RWX-BagVVzbH8-op94E2eVzBU2hJofjhV5faA1QaDg2X4aAQVfQnJq32NVecpBbDJqGd447S7DGwQu-CKNsjzgKXB7BYExUGJpyTdFqr503d4W97fxTovMt/s600/Screenshot_20230715_124741.png"/></a></div>
<code name="code" class="java">
@Composable
fun CreateDocumentSample() {
val createDocumentLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) {
if (it != null) {
...
}
}
Button(
onClick = {
createDocumentLauncher.launch("sample")
}
) {
Text("Click")
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjj1fxUb8mOYw6TNwWU8AG6YGzxl7hIWXU-X0bIjRQBRhMzIjzCKKHLaWuojwLIwdsXYEh4YEsQjR3QfZS1QBkRiPZ_KY0-JclXFrY7uV4yeESGMsJr3v0yorA-NyAXDhiUwg0MjnkbmvkGMp_lcXEO0gwmAWBU27r4ZYKgqs0lwwYyDJGHKYJ73PSJliqo/s1920/Screenshot_20230715_124808.png" style="display: block; padding: 1em 0; text-align: center;"><img alt="" border="0" height="600" data-original-height="1920" data-original-width="1080" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjj1fxUb8mOYw6TNwWU8AG6YGzxl7hIWXU-X0bIjRQBRhMzIjzCKKHLaWuojwLIwdsXYEh4YEsQjR3QfZS1QBkRiPZ_KY0-JclXFrY7uV4yeESGMsJr3v0yorA-NyAXDhiUwg0MjnkbmvkGMp_lcXEO0gwmAWBU27r4ZYKgqs0lwwYyDJGHKYJ73PSJliqo/s600/Screenshot_20230715_124808.png"/></a></div>
<br><br>
名前についている拡張子が mimeType と異なる場合その部分は拡張子とは認識されず、mimeType に基づいた拡張子がつきます。
<br><br>
以下では mimeType が "text/plain" でファイル名が "sample.csv" なので ".csv" は拡張子とは認識されず、実際のファイル名は "sample.csv.txt" になります。
<code name="code" class="java">
@Composable
fun CreateDocumentSample() {
val createDocumentLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {
if (it != null) {
...
}
}
Button(
onClick = {
createDocumentLauncher.launch("sample.csv")
}
) {
Text("Click")
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhYSw1Ti5fi6wHuScqexYFp53soKTBTKkMjNaTpWMFlMp-5ctjpgvlcpbMxfw95lEPNy-DbhI7_DtUTiicl73dJ-CWTsuVdStU8pXGWBaIXe30BxNI8sJzrkn6FXk-P2_z6X1Rpu2UgGw6rqKS3yEw1t1e55n7BIKWBnFpJ0Dd4LSOPH8R8NDAnBrrsZ0cN/s1920/Screenshot_20230715_125324.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" height="600" data-original-height="1920" data-original-width="1080" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhYSw1Ti5fi6wHuScqexYFp53soKTBTKkMjNaTpWMFlMp-5ctjpgvlcpbMxfw95lEPNy-DbhI7_DtUTiicl73dJ-CWTsuVdStU8pXGWBaIXe30BxNI8sJzrkn6FXk-P2_z6X1Rpu2UgGw6rqKS3yEw1t1e55n7BIKWBnFpJ0Dd4LSOPH8R8NDAnBrrsZ0cN/s600/Screenshot_20230715_125324.png"/></a></div>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-30104810667379778102023-05-27T19:52:00.002+09:002023-05-27T20:14:34.692+09:00Compose Multiplatform で Image loading ってどうやるの?技術書典14用に書いたんだけど mhidaka 多忙により寝かされてしまったため、ここで供養します。<br>
夏コミ(C102)は別の記事を書く予定です。
<br><br>
==========================
<br><br><br>
今年(2023年)のKotlinConf(<a href="https://kotlinconf.com/">https://kotlinconf.com/</a>)でCompose Multiplatformが発表されました。これまではCompose Desktopを使うことでAndroidとDesktopでUIコードを共通化することができました。Compose Multiplatformではこれに加えiOSとWebもサポートしています。2023年5月時点ではiOSはAlpha版、WebはExperimental版です。
<br><br>
この章ではAndroidとiOSのUIをCompose Multiplatformで共通化したときに、画像をネットワークから非同期に読み込む方法を紹介します。
<br><br>
ここで紹介する方法は2023年5月時点のものであり、今後APIが変わる可能性が大いにあります。注意してください。
<br><br><br><br>
<h4>Resources</h4>
Compose MultiplatformにはResourceというinterfaceが用意されています。
Resourceにはsuspend関数のreadBytes()が定義されており、戻り値はByteArrayです。
<code name="code" class="java">
@ExperimentalResourceApi
interface Resource {
suspend fun readBytes(): ByteArray
}
</code>
このResourceを実装したLoadImageResourceを用意します。
LoadImageResourceのreadBytes()では指定されたURLからByteArrayを取得する処理を実装します。LoadImageResourceではKtor(<a href="https://ktor.io/">https://ktor.io/</a>)を使っています。
<code name="code" class="java">
@OptIn(ExperimentalResourceApi::class)
class LoadImageResource(private val url: String) : Resource {
override suspend fun readBytes(): ByteArray {
return HttpClient().use {
it.get(url).readBytes()
}
}
}
</code>
<br><br>
<h4>rememberImageBitmap</h4>
Resourceの拡張関数としてrememberImageBitmap()が用意されています。
rememberImageBitmap()はComposable関数で、戻り値はLoadState<ImageBitmap>です。
LaunchedEffectのなかでResourceのreadBytes()を呼び出し、返ってきたByteArrayをtoImageBitmap()でImageBitmapに変換しています。
<code name="code" class="java">
@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
}
</code>
LoadStateはsealed classでLoading、Success、Errorの3つのサブクラスがあります。
<code name="code" class="java">
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>()
}
</code>
<br><br>
<h4>AsyncImage</h4>
画像を読み込んで表示したいURLに対してLoadImageResourceを作り、rememberImageBitmap()の返す
LoadStateに応じてComposableを表示します。
LoadState.SuccessのvalueプロパティからImageBitmapを取得し、BitmapPainterでPainterを作ります。
最後にBitmapPainterをImage composableにセットすることで画像が表示されます。
<code name="code" class="java">
@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
)
}
}
}
</code>
<br><br>
<h4>まとめ</h4>
これでCompose MultiplatformのAndroid、iOS両方でネットワークから読み込んだ画像が表示されます。
実際のアプリで使うにはキャッシュ機能などが必要になりますし、そのうちライブラリもでてくるでしょう。
Compose Multiplatformぜひ試してみてください。
<br><br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-12799035678213985672023-04-24T13:58:00.002+09:002023-04-24T13:58:32.943+09:00Media3 の PlayerView で動画再生前の黒い画面の色を変えるsetShutterBackgroundColor() を使う
<code name="code" class="java">
PlayerView(context).apply {
...
setShutterBackgroundColor(backgroundColor)
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-34438687728278421742023-03-16T12:07:00.002+09:002023-03-16T12:07:52.201+09:00Compose の LottieAnimation でクリックしたときにアニメーションさせるrememberLottieAnimatable を使います。
<code name="code" class="java">
@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),
)
}
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-47243934926743041852023-03-08T12:07:00.004+09:002023-03-08T12:07:53.223+09:00Navigation Compose の optional arguments に null を渡したいときは query parameter 自体を消さないといけないNavigation Compose の destination に optional arguments を設定する場合、nullable = true を設定します(もしくは defaultValue を設定する)。
<code name="code" class="java">
composable(
route = "detail?id={id}",
arguments = listOf(
navArgument("id") {
type = NavType.StringType
nullable = true
}
)
) {
</code>
この場合、NavHostController.navigate() に渡す文字列と id の値の関係は次のようになります。
<br><br>
<table cellpadding="15">
<tr><th></th><th>destination 先での id の値</th></tr>
<tr><td>navController.navigate("detail")</td><td>null</td></tr>
<tr><td>navController.navigate("detail?id=AAAAA")</td><td>"AAAA"</td></tr>
<tr><td>navController.navigate("detail?id=")</td><td>""</td></tr>
<tr><td>val id: String? = null<br>navController.navigate("detail?id=${id}")</td><td>"null"</td></tr>
</table>
<br><br>
注意しないといけないのは "detail?id=${id}" です。id 変数が null のとき、これは "detail?id=null" になり、destination 先では "null" という文字列が取得されてしまいます。
<br><br>
よって id 変数が null かどうかによって次のように navigate() に渡す文字列を変える必要があります。
<code name="code" class="java">
if (id == null) {
navController.navigate("detail")
} else {
navController.navigate("detail?id=${id}")
}
</code>
<br><br>
挙動確認のコード
<code name="code" class="java">
@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}")
}
}
}
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-78774294329634517962022-12-07T10:47:00.004+09:002022-12-07T10:48:06.116+09:00ラベルと値が縦に並んでいて、値部分の start が揃っている Composable を作るラベルと値が縦に並んでいる表があって、その値の start の位置を合わせたい。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjl7GqOAHMjkpZ6jz55XeRIvxDccF1lx5vWT1LHJtsZ0V_i41izxmBrvJKKDbu4gwLwHFCWxxEh9_NfkhpxObe_bCwqCVANT_MOU1gbDiyednzzRheCURWETx-qs0xBN9N_fEd2YG0kXegPBO0fVRVCOotzj9Iy3rEQF9OHaWZzT0uYGMyjWH0-QuO-bw/s640/Screenshot_20221207_102543.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" height="400" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjl7GqOAHMjkpZ6jz55XeRIvxDccF1lx5vWT1LHJtsZ0V_i41izxmBrvJKKDbu4gwLwHFCWxxEh9_NfkhpxObe_bCwqCVANT_MOU1gbDiyednzzRheCURWETx-qs0xBN9N_fEd2YG0kXegPBO0fVRVCOotzj9Iy3rEQF9OHaWZzT0uYGMyjWH0-QuO-bw/s400/Screenshot_20221207_102543.png"/></a></div>
ConstraintLayout で Barrier を使うか、自分で Layout を作ることになる。
<br><br><br>
<h4>Composableの順番で位置を指定する場合</h4>
content 内の Composable が「ラベル、値、区切り線、ラベル、値、区切り線、...」のようになっている前提の場合、このようなコードで実現できる。
<code name="code" class="java">
@Composable
fun LabelValueTable(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
val layoutWidth = constraints.maxWidth
val labelMeasurables = mutableListOf<Measurable>()
val valueMeasurables = mutableListOf<Measurable>()
val dividerMeasurables = mutableListOf<Measurable>()
measurables.forEachIndexed { index, measurable ->
when (index % 3) {
0 -> labelMeasurables.add(measurable)
1 -> valueMeasurables.add(measurable)
2 -> dividerMeasurables.add(measurable)
}
}
val constraintsForLabel = constraints.copy(minWidth = 0, minHeight = 0)
val labelPlaceables = labelMeasurables.map { it.measure(constraintsForLabel) }
val widthOfLabel = labelPlaceables.maxOf { it.width }
val constraintsForValue = constraintsForLabel.copy(maxWidth = layoutWidth - widthOfLabel)
val valuePlaceables = valueMeasurables.map { it.measure(constraintsForValue) }
val dividerPlaceables = dividerMeasurables.map { it.measure(constraintsForLabel) }
val heights = labelPlaceables.mapIndexed { index, labelPlaceable ->
val valuePlaceable = valuePlaceables.getOrNull(index)
max(labelPlaceable.height, valuePlaceable?.height ?: 0)
}
layout(
width = constraints.maxWidth,
height = max(
heights.sum() + dividerPlaceables.sumOf { it.height },
constraints.minHeight
),
) {
var top = 0
labelPlaceables.forEachIndexed { index, labelPlaceable ->
val rowHeight = heights[index]
labelPlaceable.placeRelative(
x = 0,
y = top + (rowHeight - labelPlaceable.height) / 2
)
val valuePlaceable = valuePlaceables.getOrNull(index)
valuePlaceable?.placeRelative(
x = widthOfLabel,
y = top + (rowHeight - valuePlaceable.height) / 2
)
val dividerPlaceable = dividerPlaceables.getOrNull(index)
dividerPlaceable?.placeRelative(
x = 0,
y = top + rowHeight
)
top += rowHeight + (dividerPlaceable?.height ?: 0)
}
}
}
}
</code>
まずラベル部分を measure して、全てのラベルから最大の width(= widthOfLabel) を計算する。
<br><br>
constraints.maxWidth から widthOfLabel を引いた値を maxWidth とした Constrains で値部分を measure する。
<br><br>
あとは、ラベル、値、区切り線を配置する。
<br><br>
値や区切り線に何も表示しないところは Spacer() をおけばいい。
<code name="code" class="java">
LabelValueTable(
modifier = modifier.fillMaxWidth()
) {
Text(
text = "名前",
modifier = Modifier.padding(16.dp)
)
Text(
text = "山田 太郎",
modifier = Modifier.padding(16.dp)
)
Divider()
Text(
text = "bio",
modifier = Modifier.padding(16.dp)
)
Text(
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
modifier = Modifier.padding(16.dp)
)
Divider()
Text(
text = "label",
modifier = Modifier.padding(16.dp)
)
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "headline",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "subtitle",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Divider(
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = "生年月日",
modifier = Modifier.padding(16.dp)
)
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "1990-01-01",
modifier = Modifier.weight(1f)
)
IconButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Default.Edit, contentDescription = "edit")
}
}
Divider()
Text(
text = "性別",
modifier = Modifier.padding(16.dp)
)
Text(
text = "男性",
modifier = Modifier.padding(16.dp)
)
}
</code>
<br><br>
<h4>ParentDataModifier で位置を指定する場合</h4>
ParentDataModifier を使って Cell の位置を指定する場合、このようなコードで実現できる。
<code name="code" class="java">
@Composable
fun LabelValueTable(
modifier: Modifier = Modifier,
content: @Composable LabelValueTableScope.() -> Unit,
) {
Layout(
content = { LabelValueTableScopeInstance.content() },
modifier = modifier
) { measurables, constraints ->
val layoutWidth = constraints.maxWidth
val labelMeasurables = mutableListOf<Measurable>()
val valueMeasurables = mutableListOf<Measurable>()
val dividerMeasurables = mutableListOf<Measurable>()
measurables.forEach { measurable ->
when (measurable.parentData) {
is LabelIndex -> labelMeasurables.add(measurable)
is ValueIndex -> valueMeasurables.add(measurable)
is DividerIndex -> dividerMeasurables.add(measurable)
}
}
val map = mutableMapOf<Int, Triple<Placeable?, Placeable?, Placeable?>>()
val constraintsForLabel = constraints.copy(minWidth = 0, minHeight = 0)
val labelPlaceables = labelMeasurables.map { it.measure(constraintsForLabel) }
val widthOfLabel = labelPlaceables.maxOf { it.width }
labelMeasurables.forEachIndexed { index, measurable ->
val columnIndex = (measurable.parentData as LabelIndex).columnIndex
map[columnIndex] = Triple(labelPlaceables[index], null, null)
}
val constraintsForValue = constraintsForLabel.copy(maxWidth = layoutWidth - widthOfLabel)
val valuePlaceables = valueMeasurables.map { it.measure(constraintsForValue) }
valueMeasurables.forEachIndexed { index, measurable ->
val columnIndex = (measurable.parentData as ValueIndex).columnIndex
map[columnIndex] = map.getOrDefault(columnIndex, Triple(null, null, null))
.copy(second = valuePlaceables[index])
}
val dividerPlaceables = dividerMeasurables.map { it.measure(constraintsForLabel) }
dividerMeasurables.forEachIndexed { index, measurable ->
val columnIndex = (measurable.parentData as DividerIndex).columnIndex
map[columnIndex] = map.getOrDefault(columnIndex, Triple(null, null, null))
.copy(third = dividerPlaceables[index])
}
val list = map.toList()
.sortedBy { it.first }
.map { it.second }
val heights = list.map {
max(it.first?.height ?: 0, it.second?.height ?: 0)
}
layout(
width = constraints.maxWidth,
height = max(
heights.sum() + dividerPlaceables.sumOf { it.height },
constraints.minHeight
),
) {
var top = 0
list.forEachIndexed { index, triple ->
val (labelPlaceable, valuePlaceable, dividerPlaceable) = triple
val rowHeight = heights[index]
labelPlaceable?.placeRelative(
x = 0,
y = top + (rowHeight - labelPlaceable.height) / 2
)
valuePlaceable?.placeRelative(
x = widthOfLabel,
y = top + (rowHeight - valuePlaceable.height) / 2
)
dividerPlaceable?.placeRelative(
x = 0,
y = top + rowHeight
)
top += rowHeight + (dividerPlaceable?.height ?: 0)
}
}
}
}
@Immutable
interface LabelValueTableScope {
@Stable
fun Modifier.label(columnIndex: Int): Modifier
@Stable
fun Modifier.value(columnIndex: Int): Modifier
@Stable
fun Modifier.divider(columnIndex: Int): Modifier
}
internal object LabelValueTableScopeInstance : LabelValueTableScope {
@Stable
override fun Modifier.label(columnIndex: Int): Modifier {
return this.then(LabelIndex(columnIndex))
}
@Stable
override fun Modifier.value(columnIndex: Int): Modifier {
return this.then(ValueIndex(columnIndex))
}
@Stable
override fun Modifier.divider(columnIndex: Int): Modifier {
return this.then(DividerIndex(columnIndex))
}
}
@Immutable
private data class LabelIndex(val columnIndex: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? {
return this@LabelIndex
}
}
@Immutable
private data class ValueIndex(val columnIndex: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? {
return this@ValueIndex
}
}
@Immutable
private data class DividerIndex(val columnIndex: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? {
return this@DividerIndex
}
}
</code>
measurable.parentData からラベル、値、区切り線のどれで、column index が何かを取得する。
<br><br>
measure と配置部分でやっていることは同じ。
<br><br>
使う側では Modifier.label(), Modifier.value(), Modifier.divider() を使って位置を指定する。
<code name="code" class="java">
LabelValueTable(
modifier = modifier.fillMaxWidth()
) {
Text(
text = "名前",
modifier = Modifier
.padding(16.dp)
.label(0)
)
Text(
text = "山田 太郎",
modifier = Modifier
.padding(16.dp)
.value(0)
)
Divider(
modifier = Modifier.divider(0)
)
Text(
text = "bio",
modifier = Modifier
.padding(16.dp)
.label(1)
)
Text(
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
modifier = Modifier
.padding(16.dp)
.value(1)
)
Divider(
modifier = Modifier.divider(1)
)
Text(
text = "label",
modifier = Modifier
.padding(16.dp)
.label(2)
)
Column(
modifier = Modifier
.padding(16.dp)
.value(2)
) {
Text(
text = "headline",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "subtitle",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Divider(
modifier = Modifier
.padding(bottom = 24.dp)
.divider(2)
)
Text(
text = "生年月日",
modifier = Modifier
.padding(16.dp)
.label(3)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(16.dp)
.value(3),
) {
Text(
text = "1990-01-01",
modifier = Modifier.weight(1f)
)
IconButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Default.Edit, contentDescription = "edit")
}
}
Divider(
modifier = Modifier.divider(3)
)
Text(
text = "性別",
modifier = Modifier
.padding(16.dp)
.label(4)
)
Text(
text = "男性",
modifier = Modifier
.padding(16.dp)
.value(4)
)
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-62159551888533192252022-12-06T16:12:00.001+09:002022-12-06T16:12:42.734+09:00ParentDataModifier<a href="https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/ParentDataModifier" target="_blank">ParentDataModifier</a> は、親の Layout にデータを提供するための Modifier です。
<br>
ParentDataModifier の Density.modifyParentData() で返したデータが <a href="https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/IntrinsicMeasurable#parentData%28%29" target="_blank">IntrinsicMeasurable.parentData</a> に格納されます。
<br><br>
Measureable が IntrinsicMeasurable を実装しているので、MeasurePolicy の MeasureScope.measure() でこのデータを利用できます。
<code name="code" class="java">
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
measurables.forEach {
val parentData = measurable.parentData
...
}
...
}
</code>
ParentDataModifier interface を実装している Modifier として
<ul>
<li>LayoutId</li>
<li>BoxChildData</li>
<li>LayoutWeightImpl</li>
<li>HorizontalAlignModifier</li>
<li>VerticalAlignModifier</li>
<li>SiblingsAlignedModifier</li>
</ul>
などがあります。
<br><br>
この中で LayoutId は自分の Layout でも使うことができます。
<code name="code" class="java">
Layout(
content = {
Icon(
imageVector = Icons.Default.Home,
contentDescription = "home",
modifier = Modifier.layoutId("icon")
)
Text(
text = "home",
modifier = Modifier.layoutId("text")
)
},
modifier = modifier
) { measurables, constraints ->
val iconMeasurable = measurables.first { it.layoutId == "icon" }
val textMeasurable = measurables.first { it.layoutId == "text" }
...
}
</code>
Modifier.layoutId() は LayoutId を適用した Modifier を返すメソッドです。
<code name="code" class="java">
@Stable
fun Modifier.layoutId(layoutId: Any) = this.then(
LayoutId(
layoutId = layoutId,
inspectorInfo = debugInspectorInfo {
name = "layoutId"
value = layoutId
}
)
)
</code>
LayoutId は ParentDataModifier と LayoutIdParentData を実装した Modifier で、Density.modifyParentData() では LayoutId 自身を返します。
<code name="code" class="java">
@Immutable
private class LayoutId(
override val layoutId: Any,
inspectorInfo: InspectorInfo.() -> Unit
) : ParentDataModifier, LayoutIdParentData, InspectorValueInfo(inspectorInfo) {
override fun Density.modifyParentData(parentData: Any?): Any? {
return this@LayoutId
}
...
}
interface LayoutIdParentData {
val layoutId: Any
}
</code>
Measurable.layoutId は parentData から Modifier.layoutId() で渡した layoutId を取得する便利メソッドです。
<code name="code" class="java">
val Measurable.layoutId: Any?
get() = (parentData as? LayoutIdParentData)?.layoutId
</code>
<br><br>
ParentDataModifier を実装した独自 Modifier を用意することができます。
<code name="code" class="java">
@Immutable
interface MyLayoutScope {
@Stable
fun Modifier.myLayoutData(index: Int): Modifier
}
internal object MyLayoutScopeInstance : MyLayoutScope {
@Stable
override fun Modifier.myLayoutData(index: Int): Modifier {
return this.then(MyLayoutData(index = index))
}
}
@Immutable
private data class MyLayoutData(val index: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? {
return this@MyLayoutData
}
}
val Measurable.index: Int?
get() = (parentData as? MyLayoutData)?.index
</code>
<br><br><br><br>yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-243547083147916982022-11-30T17:39:00.003+09:002022-11-30T17:39:27.736+09:00Material 2 / Material 3 の Typography 対応表<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg4XYNYeNSrteiIcuwdhOf3YlzUkYBrKnO2cmjq5Qr74vzW27XF5WgRAA29Is67AujjdIr8oniJ9KFs-83hMxa0g57yJJuDmJ1aSNEPnZQVSeRWUZCI5FtFXm_f_m20mEfv9YoFE1iLA1PbaLW_sLu7jONrN4YH9L_MpO7nruWW1x1OL-GcrSf6rtvTNQ/s1600/DroidKaigi2022_yanzm.001.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" data-original-height="1080" data-original-width="1920" width=600 src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg4XYNYeNSrteiIcuwdhOf3YlzUkYBrKnO2cmjq5Qr74vzW27XF5WgRAA29Is67AujjdIr8oniJ9KFs-83hMxa0g57yJJuDmJ1aSNEPnZQVSeRWUZCI5FtFXm_f_m20mEfv9YoFE1iLA1PbaLW_sLu7jONrN4YH9L_MpO7nruWW1x1OL-GcrSf6rtvTNQ/s1600/DroidKaigi2022_yanzm.001.png"/></a></div>yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-59341845479180877972022-11-25T10:23:00.002+09:002022-11-25T10:45:12.828+09:00BottomNavigation / NavigationBar が消える時にレイアウトが下にずれるのを防ぎたい<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiHk-SN4Z14u533a4Vg6uBUHwFLkc6EYM9G7z6oD835mKgGC4KmGFoe5wROAVmLQkLIyzISmWEUF0tISQNijYnJmDsEQxmHUW2U7H5oNzv9VmIs-6auImXdBwcgnIPF08GJX7kc1VvgZKV2r7pY5RsG3Nr_dC_xp7Vxq7c-QLgwzXjZP0T_aycXMx0qNA/s800/device-2022-11-25-100749_2.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" height="600" data-original-height="800" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiHk-SN4Z14u533a4Vg6uBUHwFLkc6EYM9G7z6oD835mKgGC4KmGFoe5wROAVmLQkLIyzISmWEUF0tISQNijYnJmDsEQxmHUW2U7H5oNzv9VmIs-6auImXdBwcgnIPF08GJX7kc1VvgZKV2r7pY5RsG3Nr_dC_xp7Vxq7c-QLgwzXjZP0T_aycXMx0qNA/s600/device-2022-11-25-100749_2.gif"/></a></div>
このように Compose Navigation で BottomNavigation / NavigationBar を表示しない画面に遷移したときに、現在のレイアウトが下にずれてしまうことがあります。
<br>
これを防ぐ方法を紹介します。
<br><br>
<h4>1. NavHost に innerPadding を使わない</h4>
<code name="code" class="java">
Scaffold(
...
) { innerPadding ->
NavHost(
modifier = Modifier.padding(innerPadding), // これはダメ
...
) {
...
</code>
NavHost の中の Composable で innerPadding を使うようにします。
<code name="code" class="java">
Scaffold(
...
) { innerPadding ->
val modifier = Modifier.padding(innerPadding)
NavHost(
...
) {
composable(Destination.Home.route) {
HomeScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier, // こうする
)
}
...
@Composable
private fun HomeScreen(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
...
}
}
</code>
このとき currentBackStack が null でも BottomNavigation / NavigationBar が表示されるようにしないと bottom padding が 0 になる問題があるので注意してください。
<code name="code" class="java">
val navBackStackEntry by navController.currentBackStackEntryAsState()
// ここで ?: Destination.Home.route をつけないと HomeScreen() に渡される Modifier で bottom padding が 0 になってしまう
val currentRoute = navBackStackEntry?.destination?.route ?: Destination.Home.route
val routes = remember { Destination.values().map { it.route } }
val showNavigationBar = currentRoute in routes
if (showNavigationBar) {
BottomNavigation {
...
</code>
<br>
<h4>2. BottomNavigation / NavigationBar が表示される画面を nested graph にする</h4>
これを
<code name="code" class="java">
NavHost(
navController = navController,
startDestination = Destination.Home.route
) {
composable(Destination.Home.route) {
HomeScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier,
)
}
composable(Destination.Settings.route) {
SettingsScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier,
)
}
composable("profile") {
ProfileScreen(
modifier = modifier,
)
}
}
</code>
こうする
<code name="code" class="java">
NavHost(
navController = navController,
startDestination = "main"
) {
// BottomNavigation / NavigationBar が表示される画面を nested graph にする
navigation(
route = "main",
startDestination = Destination.Home.route
) {
composable(Destination.Home.route) {
HomeScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier,
)
}
composable(Destination.Settings.route) {
SettingsScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier,
)
}
}
composable("profile") {
ProfileScreen(
modifier = modifier,
)
}
}
</code>
これで、下にずれなくなります。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgjB-FQ9DJpVccmOfIG9NrsWD3Yil9RxjTAd4a-wvoN1bgv4Ijjt8NrzciIOvFAhqeZHb34k6GcR5yxxEuvNmFRZqb4R3CezMAqpDlHtgFAWedcWUfwomKAcskV-53PSPuc9ELNRNEc9FBq3JWD15xmwK_41rso8GQNdkO-TMc48ZWzSjMxacn4bWEl-g/s800/device-2022-11-25-100822_2.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" height="600" data-original-height="800" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgjB-FQ9DJpVccmOfIG9NrsWD3Yil9RxjTAd4a-wvoN1bgv4Ijjt8NrzciIOvFAhqeZHb34k6GcR5yxxEuvNmFRZqb4R3CezMAqpDlHtgFAWedcWUfwomKAcskV-53PSPuc9ELNRNEc9FBq3JWD15xmwK_41rso8GQNdkO-TMc48ZWzSjMxacn4bWEl-g/s600/device-2022-11-25-100822_2.gif"/></a></div>
最後に全体のコードを置いておきます。
<code name="code" class="java">
enum class Destination(val title: String, val route: String, val imageVector: ImageVector) {
Home("Home", "home", Icons.Filled.Home),
Settings("Settings", "settings", Icons.Filled.Settings)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Main")
}
)
},
bottomBar = {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val routes = remember { Destination.values().map { it.route } }
val currentRoute = navBackStackEntry?.destination?.route ?: Destination.Home.route
val showNavigationBar = currentRoute in routes
if (showNavigationBar) {
NavigationBar {
Destination.values().forEach { destination ->
NavigationBarItem(
icon = { Icon(destination.imageVector, contentDescription = null) },
label = { Text(destination.title) },
selected = currentRoute == destination.route,
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}
) { innerPadding ->
val modifier = Modifier.padding(innerPadding)
NavHost(
navController = navController,
startDestination = "main"
) {
navigation(
route = "main",
startDestination = Destination.Home.route
) {
composable(Destination.Home.route) {
HomeScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier,
)
}
composable(Destination.Settings.route) {
SettingsScreen(
onClick = {
navController.navigate("profile")
},
modifier = modifier,
)
}
}
composable("profile") {
ProfileScreen(
modifier = modifier,
)
}
}
}
}
@Composable
private fun HomeScreen(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
Text("home")
Button(onClick = onClick, modifier = Modifier.align(Alignment.BottomCenter)) {
Text("button")
}
}
}
@Composable
private fun SettingsScreen(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
Text("settings")
Button(onClick = onClick, modifier = Modifier.align(Alignment.BottomCenter)) {
Text("button")
}
}
}
@Composable
private fun ProfileScreen(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
Text("profile")
}
}
</code>
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-24716904492480897032022-11-15T09:46:00.002+09:002022-11-15T09:46:53.268+09:00Material 3 で TopAppBar の shadow がなくなったけど、コンテンツとの境界はどう表現する?Material 2 では TopAppBar に shadow がついていて、スクロールアウトするコンテンツとの境界として機能していました。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi47A2SVcTi0HnsR2FeKciD3TTqI8Rj0v13an29ek3oM5BYjUAPRKebxZOkzjiMgnlbCon3uOyTePQTe4dimjaMou1wejLwz7I5O-vF4yoZMfU_TtgY3yyR0GT8BdsiYeeAzI6z3AizpfKn6DEI5DFCX2oWi21CFjHD567_6bKysyHg6mP-ajfGxUB1_Q/s640/Screenshot_20221115_085142.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="240" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi47A2SVcTi0HnsR2FeKciD3TTqI8Rj0v13an29ek3oM5BYjUAPRKebxZOkzjiMgnlbCon3uOyTePQTe4dimjaMou1wejLwz7I5O-vF4yoZMfU_TtgY3yyR0GT8BdsiYeeAzI6z3AizpfKn6DEI5DFCX2oWi21CFjHD567_6bKysyHg6mP-ajfGxUB1_Q/s600/Screenshot_20221115_085142.png"/></a></div>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg9aXPMHRkSQmQfVSB7fKpg7dK45lbUA1zPxB4tZHk-MgnasUGjC1MI0mfERVVVL-Qesv1qvzaRK97J09DUrgsr90Kd5O35kBIn5UJwuNndy7pXFxPSyuMakKq71gmyeM18f2BDLrhbkZFHYym63d3fmkYfzRZEDCJxK0uHQmG4-B0Kp06MuvTDBQbDnQ/s640/Screenshot_20221115_085151.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="240" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg9aXPMHRkSQmQfVSB7fKpg7dK45lbUA1zPxB4tZHk-MgnasUGjC1MI0mfERVVVL-Qesv1qvzaRK97J09DUrgsr90Kd5O35kBIn5UJwuNndy7pXFxPSyuMakKq71gmyeM18f2BDLrhbkZFHYym63d3fmkYfzRZEDCJxK0uHQmG4-B0Kp06MuvTDBQbDnQ/s600/Screenshot_20221115_085151.png"/></a>
</div>
<br><br>
Material 3 では shadow はつきません。そのため、Material 2 のコードをそのまま Material 3 に移行すると、コンテンツの境界表現がなくなってしまいます。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjApVVgdoJua2jOUgm4gRES7yiA9diOymQ00qJN_JB_XavkhG009bKLPVLmRUCkJHRpEBEzQOqANK4lmal2HLbfT2GMr-J2Ei5ukv-84vFrRU3B_dqCj1CA3EWJMdzcA4QMP9pj_lA7xObe93RbqpNSDaY6OCvn4x4qFgu32rNf6VM065BA2TnTSHFbVw/s1600/Screenshot_20221115_085355.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="240" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjApVVgdoJua2jOUgm4gRES7yiA9diOymQ00qJN_JB_XavkhG009bKLPVLmRUCkJHRpEBEzQOqANK4lmal2HLbfT2GMr-J2Ei5ukv-84vFrRU3B_dqCj1CA3EWJMdzcA4QMP9pj_lA7xObe93RbqpNSDaY6OCvn4x4qFgu32rNf6VM065BA2TnTSHFbVw/s1600/Screenshot_20221115_085355.png"/></a></div><div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh8vvcqJTrhc0W35RV5IQFGrkPPKHqO3HVzrpaKi77rY8CMjT9xeVD4M-JnyB42Y88jdWb9HOSn24HQcqnhzUT_9dI-xpC2Aytthuqr--4sRGv-oONoNEVmduBYvRjsJGKaWRGwdcCOSEM18OENKISSZ8ffuH0qeCGGhyB4bpvku0NfJyy1VvfaJBb9vw/s1600/Screenshot_20221115_085421.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="240" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh8vvcqJTrhc0W35RV5IQFGrkPPKHqO3HVzrpaKi77rY8CMjT9xeVD4M-JnyB42Y88jdWb9HOSn24HQcqnhzUT_9dI-xpC2Aytthuqr--4sRGv-oONoNEVmduBYvRjsJGKaWRGwdcCOSEM18OENKISSZ8ffuH0qeCGGhyB4bpvku0NfJyy1VvfaJBb9vw/s1600/Screenshot_20221115_085421.png"/></a></div>
Material 3 では shadow の代わりに色のオーバーレイでコンテンツと分離します。<br>
そのために TopAppBarScrollBehavior を使います。
<code name="code" class="java">
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = {
...
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
LazyColumn(
contentPadding = it
) {
...
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg-kM0hTLtRG7zQig9irvpaR-mhi3SOvuAHO52jtVY2cI16TaTyj8h7ed1mxGp_Y5FrM-KjGcfwxn4jW4pMg0tEeHgytuMLkCDf6W5LaNQvQmgbXM861sOfNcZjq4uOw7qBQUjrRocbrE5Iyg1d1G-MXfolnTSvzIXKwPuw5gT5e9qOq5Iy9rCseZtAIQ/s1600/Screenshot_20221115_091007.png" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="240" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg-kM0hTLtRG7zQig9irvpaR-mhi3SOvuAHO52jtVY2cI16TaTyj8h7ed1mxGp_Y5FrM-KjGcfwxn4jW4pMg0tEeHgytuMLkCDf6W5LaNQvQmgbXM861sOfNcZjq4uOw7qBQUjrRocbrE5Iyg1d1G-MXfolnTSvzIXKwPuw5gT5e9qOq5Iy9rCseZtAIQ/s1600/Screenshot_20221115_091007.png"/></a></div>
TopAppBarScrollBehavior として
<ul>
<li>PinnedScrollBehavior
<li>EnterAlwaysScrollBehavior
<li>ExitUntilCollapsedScrollBehavior
</ul>
が用意されています。
<br><br>
<h4>TopAppBarDefaults.pinnedScrollBehavior()</h4>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhUO11fV9d5FpRPE3LYZ0yEHcCvgeyezG3S4D3V6jsfhaOOaCct9G3DM5CzJ6vhCR_pc9G65ngtdp7HMfs3SQHsdkGs29m2XaMnTfm3ck9ujBD05x3M7Jclc5b_xXkY7oSam0IsL9APdgdYLTOx9BVmeBFVG3Dhmq8FJRpnRDDxigbKD0WDY2hChI9mbQ/s1600/device-2022-11-15-091352.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhUO11fV9d5FpRPE3LYZ0yEHcCvgeyezG3S4D3V6jsfhaOOaCct9G3DM5CzJ6vhCR_pc9G65ngtdp7HMfs3SQHsdkGs29m2XaMnTfm3ck9ujBD05x3M7Jclc5b_xXkY7oSam0IsL9APdgdYLTOx9BVmeBFVG3Dhmq8FJRpnRDDxigbKD0WDY2hChI9mbQ/s1600/device-2022-11-15-091352.gif"/></a></div>
<h4>TopAppBarDefaults.enterAlwaysScrollBehavior()</h4>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiHaYcYC0N_yLJNTIKl-FUnaMWBViSw7PO3rCmjlkZEOIZBROCaZ29HypyaAn2aP1Bay2ShvuYBysFqb97u7pruq9QsRSZbFoUH4jSRAOK4VTZN8YIWTmZT1AL64yq89BxklY53g0ON2eGaYIHGKL48ZVtt0z6eaLYQvDNdQ35j1I2JJcZIKdljIDuKKg/s1600/device-2022-11-15-093400.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiHaYcYC0N_yLJNTIKl-FUnaMWBViSw7PO3rCmjlkZEOIZBROCaZ29HypyaAn2aP1Bay2ShvuYBysFqb97u7pruq9QsRSZbFoUH4jSRAOK4VTZN8YIWTmZT1AL64yq89BxklY53g0ON2eGaYIHGKL48ZVtt0z6eaLYQvDNdQ35j1I2JJcZIKdljIDuKKg/s1600/device-2022-11-15-093400.gif"/></a></div>
<h4>ExitUntilCollapsedScrollBehavior</h4>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgBtgxQw0SMdO8Zo0C7C1fe1Y-5PADtnAvxw7WC33bq89XLlPu0g_3Ob7YEzfPXmZevgobuRGfKJtCIE977cZFnNKPW13UoVClM6lm3zuuJHLqc4OAa3fHTSFg7cVabYyWiC2-M5GEvlpoUv356cOaokTssnTlIBRitZ_r4jsBj_CHuQAMS_StzXqNM5Q/s1600/device-2022-11-15-093425.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" data-original-height="640" data-original-width="360" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgBtgxQw0SMdO8Zo0C7C1fe1Y-5PADtnAvxw7WC33bq89XLlPu0g_3Ob7YEzfPXmZevgobuRGfKJtCIE977cZFnNKPW13UoVClM6lm3zuuJHLqc4OAa3fHTSFg7cVabYyWiC2-M5GEvlpoUv356cOaokTssnTlIBRitZ_r4jsBj_CHuQAMS_StzXqNM5Q/s1600/device-2022-11-15-093425.gif"/></a></div>
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-54304595233876818902022-09-25T18:36:00.002+09:002022-09-25T18:36:36.778+09:00AndroidViewBinding<code name="code" class="java">
implementation "androidx.compose.ui:ui-viewbinding:$compose_version"
</code>
<code name="code" class="java">
AndroidViewBinding(
factory = ListItemBinding::inflate,
update = {
...
},
modifier = ...,
)
</code>
<code name="code" class="java">
AndroidViewBinding(
factory = { inflater, parent, attachToParent ->
ListItemBinding.inflate(inflater, parent, attachToParent).apply {
...
}
},
update = {
...
},
modifier = ...,
)
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-12536242469462206442022-09-17T13:39:00.002+09:002022-09-17T13:39:44.866+09:00derivedStateOf の効果を LayoutInspector の composition count で確認するderivedStateOf を使っていない、よくないコード
<code name="code" class="java">
val state = rememberLazyListState()
// TODO derivedStateOf を使う
val showScrollToTopButton= state.firstVisibleItemIndex > 0
LazyColumn(
state = state,
modifier = Modifier.fillMaxSize()
) {
...
}
if (showScrollToTopButton) {
Button(
onClick = {
...
},
...
) {
Text("scroll to top")
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh2q6nVz2iWh81gGgoQkg1FfxDVgFAsakVNyXZf7H7ffG97SZq-H6QvGKlpS-6eCdCoIaBsY2IaM-nTwBIgkpk7I9n9r5zgwEfDHnDDZsjLgE5LgV9w6AgeRH46TTFiLYuB1Meu-yDtgjf8ZSGg0HLZSJVVXu2trcd9TEQMERjVoyAlu24-SA89XAEWZQ/s627/Screen%20Recording%202022-09-17%20at%2013.31.12-2.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="600" data-original-height="615" data-original-width="627" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh2q6nVz2iWh81gGgoQkg1FfxDVgFAsakVNyXZf7H7ffG97SZq-H6QvGKlpS-6eCdCoIaBsY2IaM-nTwBIgkpk7I9n9r5zgwEfDHnDDZsjLgE5LgV9w6AgeRH46TTFiLYuB1Meu-yDtgjf8ZSGg0HLZSJVVXu2trcd9TEQMERjVoyAlu24-SA89XAEWZQ/s600/Screen%20Recording%202022-09-17%20at%2013.31.12-2.gif"/></a></div>
LayoutInspector で Button が表示されたあともスクロールのたびに recompose が走っているので Button の skip count が増えていっています。
<code name="code" class="java">
val showScrollToTopButton by remember {
derivedStateOf { state.firstVisibleItemIndex > 0 }
}
</code>
に変えると、スクロールのたびに recompose が走っていたのがなくなり、Button の skip count が増えなくなりました。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjKqgQ1PzG6rQNsINo9OlkobAelkz7bmRhHkMbco_zFKzDQ0Mt3oTEpnwLVZIXP8-N9usKvkWssg7S_vNysikPXqaTHe3JKPgJaFeiQJJVNleFxopgvGX4RAPxJpW7SRc6W3frIqtv121YyUEndUkPvN9WxiYhJ6Y7Y8LZUXxfijx1D1_8gF0LxsVG2bg/s627/Screen%20Recording%202022-09-17%20at%2013.34.27-2.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" width="600" data-original-height="615" data-original-width="627" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjKqgQ1PzG6rQNsINo9OlkobAelkz7bmRhHkMbco_zFKzDQ0Mt3oTEpnwLVZIXP8-N9usKvkWssg7S_vNysikPXqaTHe3JKPgJaFeiQJJVNleFxopgvGX4RAPxJpW7SRc6W3frIqtv121YyUEndUkPvN9WxiYhJ6Y7Y8LZUXxfijx1D1_8gF0LxsVG2bg/s320/Screen%20Recording%202022-09-17%20at%2013.34.27-2.gif"/></a></div>
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-49620355395213935532022-08-20T10:21:00.001+09:002022-08-20T10:21:30.916+09:00Accompanist の Navigation Material の BottomSheet で表示エリアにおさまるように配置する<a href="https://google.github.io/accompanist/navigation-material/">Accompanist : Navigation Material</a>
<code name="code" class="java">
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
fun MyApp() {
val bottomSheetNavigator = rememberBottomSheetNavigator()
val navController = rememberNavController(bottomSheetNavigator)
ModalBottomSheetLayout(bottomSheetNavigator) {
MyNavHost(navController)
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
fun MyNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable(route = "home") {
Button(onClick = {
navController.navigate("sheet")
}) {
Text("show bottom sheet")
}
}
bottomSheet(route = "sheet") {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Spacer(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
)
}
}
}
}
</code>
この場合、Blue の四角は Bottom Sheet を完全に展開したときの中心に配置されます。
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi2-b_OpaaZmjdauffbPmnxcyr4SK8O0nlbcYzg4QgJB5EBvcno708C5YcDxAFaKJWWlDLeO7VZ1to9ORZNV-qdXvmXnPDOEmqFRgLjjiPwyjqjTbVquv6uVX13JrYGH2exRimg0xzSv-Z3VN9dEbHCUBfywrYe-l43qownSJptY1cnmIZpxKQQ-6pypQ/s1600/device-2022-08-20-100723.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" data-original-height="480" data-original-width="270" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi2-b_OpaaZmjdauffbPmnxcyr4SK8O0nlbcYzg4QgJB5EBvcno708C5YcDxAFaKJWWlDLeO7VZ1to9ORZNV-qdXvmXnPDOEmqFRgLjjiPwyjqjTbVquv6uVX13JrYGH2exRimg0xzSv-Z3VN9dEbHCUBfywrYe-l43qownSJptY1cnmIZpxKQQ-6pypQ/s1600/device-2022-08-20-100723.gif"/></a></div>
rememberNavController に指定した BottomSheetNavigator は NavHostController の navigatorProvider から取得できます。<br><br>
BottomSheetNavigator の navigatorSheetState から BottomSheet の offset が取れるので、それを利用すると Blue の四角を BottomSheet の表示されている領域の中心に配置することができます。
<code name="code" class="java">
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
fun MyNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable(route = "home") {
Button(onClick = {
navController.navigate("sheet")
}) {
Text("show bottom sheet")
}
}
bottomSheet(route = "sheet") {
val bottomSheetNavigator = navController.navigatorProvider[BottomSheetNavigator::class]
val offset = bottomSheetNavigator.navigatorSheetState.offset.value
Column {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Spacer(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
)
}
with(LocalDensity.current) {
Spacer(modifier = Modifier.height(offset.toDp()))
}
}
}
}
}
</code>
<div class="separator" style="clear: both;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhoSDNztOe8a1PrD-i84Jb9VvSbHMOwVL5S0Q96ytVWP_5MtoO50UvMUXGQfjgwTEHEyP9iclVC-x2aWhXDEtkR5fXD8BxS7fb9Urijf5bMWVjiqXXsQ7xDNJVFu-1F_6HKNObknSp-dY9gJIAA-Yj8F9xQTPlNP8gqOiDyl1o1HWlOq816n-OL-z5S6w/s1600/device-2022-08-20-101217.gif" style="display: block; padding: 1em 0; text-align: center; "><img alt="" border="0" data-original-height="480" data-original-width="270" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhoSDNztOe8a1PrD-i84Jb9VvSbHMOwVL5S0Q96ytVWP_5MtoO50UvMUXGQfjgwTEHEyP9iclVC-x2aWhXDEtkR5fXD8BxS7fb9Urijf5bMWVjiqXXsQ7xDNJVFu-1F_6HKNObknSp-dY9gJIAA-Yj8F9xQTPlNP8gqOiDyl1o1HWlOq816n-OL-z5S6w/s1600/device-2022-08-20-101217.gif"/></a></div>
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-60874508873182193482022-08-08T17:42:00.000+09:002022-08-08T17:42:01.929+09:00kotlin coroutines 1.6.4 で TestScope.backgroundScope が追加された
<a href="https://github.com/Kotlin/kotlinx.coroutines/releases/tag/1.6.4">https://github.com/Kotlin/kotlinx.coroutines/releases/tag/1.6.4</a>
<br><br>
background で実行してテスト終了時にキャンセルされる coroutines を起動できます。
<br>
いままでは明示的に cancelAndJoin() していたのがいらなくなりますね。
<br><br>
前
<code name="code" class="java">
@Test
fun test() = runTest {
val list = mutableListOf<SomeValue>()
val job = launch(UnconfinedTestDispatcher()) {
repository.someValueFlow().collect {
list.add(it)
}
}
...
assertEquals(expectedSomeValueList, list)
job.cancelAndJoin()
}
</code>
後
<code name="code" class="java">
@Test
fun test() = runTest {
val list = mutableListOf<SomeValue>()
backgroundScope.launch(UnconfinedTestDispatcher()) {
repository.someValueFlow().collect {
list.add(it)
}
}
...
assertEquals(expectedSomeValueList, list)
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-57628874850574161482022-07-27T12:13:00.001+09:002022-07-27T12:13:03.728+09:00Notification runtime permission の挙動メモAndroid 13 に新規インストール
<pre>
hasPermission = false, rationale = false
↓
requestPermission : ダイアログ出る
選択しない
↓
hasPermission = false, rationale = false
↓
requestPermission : ダイアログ出る
拒否
↓
hasPermission = false, rationale = true
↓
requestPermission : ダイアログ出る
選択しない
↓
hasPermission = false, rationale = true
↓
requestPermission : ダイアログ出る
拒否
↓
hasPermission = false, rationale = false,
↓
requestPermission : ダイアログ出ない
</pre>
<br><br>
<pre>
hasPermission = false, rationale = false
↓
requestPermission : ダイアログ出る
選択しない
↓
設定画面で ON → OFF
↓
hasPermission = false, rationale = true
↓
requestPermission : ダイアログ出る
選択しない
↓
設定画面で ON → OFF
↓
hasPermission = false, rationale = true
↓
requestPermission : ダイアログ出る
拒否
↓
hasPermission = false, rationale = false
↓
requestPermission : ダイアログ出ない
↓
設定画面で ON → OFF
↓
hasPermission = false, rationale = false
↓
requestPermission : ダイアログ出ない
</pre>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-23016841657737854052022-07-11T15:24:00.002+09:002022-07-11T15:24:50.367+09:00 Calling getBackStackEntry during composition without using remember with a NavBackStackEntry key といわれたら<code name="code" class="java">
NavHost(...) {
composable(...) {
val parentEntry = remember { navController.getBackStackEntry(route) }
...
}
}
</code>
Navigation 2.5.0-rc01 から上記コードは lint error になり、「Calling getBackStackEntry during composition without using remember with a NavBackStackEntry key」といわれます。
<br>
この変更については <a href="https://issuetracker.google.com/issues/227382831">https://issuetracker.google.com/issues/227382831</a> に書かれています。
<br><br>
修正するには、composable の lambda に渡される BackStackEntry を remember の key として渡します。
<code name="code" class="java">
NavHost(...) {
composable(...) { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry(route) }
...
}
}
</code>
<br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-16505928843736204302022-05-06T09:15:00.003+09:002022-05-06T09:15:47.372+09:00Compose のテストで No compose hierarchies found in the app エラーがでた場合
次のような Compose のテストを実行したときに
<code name="code" class="java">
class ComposeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun myTest() {
composeTestRule.setContent {
Text("Hello")
}
composeTestRule.onNodeWithText("Hello").assertIsDisplayed()
}
}
</code>
<br>
java.lang.IllegalStateException: No compose views found in the app. Is your Activity resumed?
<br>
<br>
(1.1 系)<br>
や
<br>
<br>
java.lang.IllegalStateException: No compose hierarchies found in the app. Possible reasons include: (1) the Activity that calls setContent did not launch; (2) setContent was not called; (3) setContent was called before the ComposeTestRule ran. If setContent is called by the Activity, make sure the Activity is launched after the ComposeTestRule runs
<br>
<br>
(1.2 系)<br><br>
というエラーがでる場合、<b>テストを実行しているエミュレータやデバイスの画面が off になっていないかチェック</b>しましょう。
<br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-86438142341275898382022-05-05T12:39:00.006+09:002022-05-05T12:39:51.764+09:00Espresso test 3.4.0 で Duplicate class org.checkerframework.checker エラーが出る場合espresso-contrib から org.checkerframework:checker を exclude します。
<code name="code" class="java">
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.4.0") {
exclude group: "org.checkerframework", module: "checker"
}
</code>
<br><br>
ref : <a href="https://github.com/android/android-test/issues/861">https://github.com/android/android-test/issues/861</a>
<br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-60982112488691578802022-04-30T11:35:00.002+09:002022-04-30T11:35:37.751+09:00LazyColumn/LazyRow で content types を使う<code name="code" class="java">
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = rememberLazyListState()
) {
item(
contentType = "header"
) {
Text(
text = "Header",
modifier = Modifier
.padding(16.dp)
.fillParentMaxWidth()
)
}
items(
count = 100,
contentType = { "item" }
) {
Text(
text = "Item : $it",
modifier = Modifier
.padding(16.dp)
.fillParentMaxWidth()
)
}
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-3657236518911549532022-04-14T09:36:00.000+09:002022-04-14T09:36:31.367+09:00WindowInsetsControllerCompat を使って status bar と navigation bar の light mode を切り替えるMaterial Catalog アプリのコードを読んでいて<a href="https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/integration-tests/material-catalog/src/main/java/androidx/compose/material/catalog/ui/theme/Theme.kt" target="_blank">見つけた</a>んですが、
<br>
WindowCompat.getInsetsController() で取得した WindowInsetsControllerCompat の setAppearanceLightStatusBars() と setAppearanceLightNavigationBars() を使うことで、status bar と navigation bar の light mode(light mode だとアイコンがグレーになり、dark だと白になる)をコードから切り替えることができます。
<br><br>
このようにアプリ用の MaterialTheme のところで SideEffect を使って切り替え処理をすると、Theme の xml で頑張らなくて良くなるので便利です。
<code name="code" class="java">
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val view = LocalView.current
val context = LocalContext.current
SideEffect {
val controller = WindowCompat.getInsetsController(context.findActivity().window, view)
controller?.isAppearanceLightStatusBars = !darkTheme
controller?.isAppearanceLightNavigationBars = !darkTheme
}
MaterialTheme(
colors = if (!darkTheme) LightColorPalette else DarkColorPalette,,
typography = Typography,
shapes = Shapes,
content = content
)
}
private tailrec fun Context.findActivity(): Activity =
when (this) {
is Activity -> this
is ContextWrapper -> this.baseContext.findActivity()
else -> throw IllegalArgumentException("Could not find activity!")
}
</code>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-15111574685235047802022-03-27T21:14:00.001+09:002022-03-27T21:14:31.081+09:00CameraX で動画撮影できるようになったので Compose で実装してみた。公式のサンプル <a href="https://github.com/android/camera-samples/tree/main/CameraXVideo">https://github.com/android/camera-samples/tree/main/CameraXVideo</a> を参考に、Compose で実装してみました。
<br><br>
<a href="https://github.com/yanzm/CameraXComposeSample">https://github.com/yanzm/CameraXComposeSample</a>
<br><br>
<br><br>
最初は逐次的に Compose に置き換えていたのですが、Recording の状態を処理するあたりで難しくなって、declarative UI の意識で状態を一から考えないと無理だなってなりました。<br>
なので公式のサンプルがやっている処理を理解したうえで一から状態を考えた結果、公式のサンプルとはかなり違うコードになっています。
<br><br>
既存の imperative UI なコードを declarative UI に移行するのは結構難しいなと思いました。
<br><br>
<br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-85040877247354368482022-02-23T10:05:00.002+09:002022-02-23T10:05:54.794+09:00StateFlow.collectAsState() だと現在の値を初期値として使ってくれるStateFlow.collectAsState() だと StateFlow の value を初期値として使ってくれます。一方 Flow.collectAsState() では initial に指定した値が初期値になります。
<code name="code" class="java">
@Composable
fun <T> StateFlow<T>.collectAsState(
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsState(value, context)
@Composable
fun <T : R, R> Flow<T>.collectAsState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext
): State<R> = produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) {
collect { value = it }
}
}
</code>
<br><br>
例えばサーバーからデータをとってきて表示する画面があり、画面の状態を表す UiState が次のようになっているとします。
<code name="code" class="java">
sealed interface UiState {
object Initial : UiState
object Loading : UiState
data class Error(val e: Exception) : UiState
data class Success(val profile: Profile) : UiState
}
</code>
<br>
(* 私は基本的には ViewModel からは StateFlow ではなく State を公開するようにしています。)
<br><br><br>
<b>Flow.collectAsState() の場合</b>
<code name="code" class="java">
class ProfileViewModel : ViewModel() {
val uiState: Flow<UiState> = ...
...
}
</code>
<code name="code" class="java">
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel
) {
ProfileContent(
uiState = viewModel.uiState.collectAsState(initial = UiState.Initial).value
)
}
@Composable
private fun ProfileContent(
uiState: UiState
) {
when (uiState) {
UiState.Initial,
UiState.Loading -> {
...
}
is UiState.Error -> {
...
}
is UiState.Success -> {
...
}
}
}
</code>
Flow.collectAsState() の場合、ProfileContent の (re)compose およびそのときの UiState は次のようになります。
<br><br>
UiState.Initial → (ViewModel でデータ取得開始)→ UiState.Loading → (取得成功)→ UiState.Success
<br><br>
ここで ProfileScreen から Navigation Compose で別の画面に行って戻ってくると、一瞬 UiState.Initial になります。
<br><br>
UiState.Success → (Navigation Compose で別の画面に行って戻ってくる) → UiState.Initial → UiState.Success
<br>
<br><br><br>
<b>StateFlow.collectAsState() の場合</b>
<code name="code" class="java">
class ProfileViewModel : ViewModel() {
val uiState: StateFlow<UiState> = ...
...
}
</code>
<code name="code" class="java">
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel
) {
ProfileContent(
uiState = viewModel.uiState.collectAsState().value
)
}
</code>
StateFlow.collectAsState() の場合、データ取得完了までの UiState の流れは同じです。
<br><br>
UiState.Initial → (ViewModel でデータ取得開始)→ UiState.Loading → (取得成功)→ UiState.Success
<br><br>
一方、ここで ProfileScreen から Navigation Compose で別の画面に行って戻ってきても UiState.Initial にはなりません。
<br><br>
UiState.Success → (Navigation Compose で別の画面に行って戻ってくる) → UiState.Success
<br>
<br>
そのため、ViewModel から StateFlow を公開して StateFlow の collectAsState() を使ったほうがよいです。<br>
<a href="https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html">stateIn()</a> を使えば Flow を StateFlow に変換することができます。
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0tag:blogger.com,1999:blog-5859951453664122203.post-16843286501893103332021-12-16T12:16:00.000+09:002021-12-16T12:16:17.174+09:00Dagger Hilt 2.40.2 で EntryPoints.get() の便利 overloads である EntryPointAccessors が追加された今まで
<code name="code" class="java">
val entryPoint = EntryPoints.get(activity, ActivityCreatorEntryPoint::class.java)
</code>
EntryPointAccessors を使うと
<code name="code" class="java">
val entryPoint = EntryPointAccessors.fromActivity<ActivityCreatorEntryPoint>(activity)
</code>
fromApplication(), fromActivity(), fromFragment(), fromView() が用意されている。
<br><br><br>
yanzmhttp://www.blogger.com/profile/04059587494895790858noreply@blogger.com0