カテゴリー: Jetpack Compose

android:adjustViewBounds=”true”をImageコンポーズで実装する

https://stackoverflow.com/a/60651336

import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource

@Composable
fun Modifier.adjustViewBounds(@DrawableRes id: Int): Modifier {
    val painter = painterResource(id)
    return this then Modifier.aspectRatio(painter.intrinsicSize.width / painter.intrinsicSize.height)
}

ImageコンポーズのModifierに設定すればOK。

Image(
    modifier = Modifier.adjustViewBounds(R.drawable.xxxx),
    painter = painterResource(R.drawable.xxxx),
    contentDescription = "description"
)

拡張関数便利


Composeをクリックしたときの波紋効果(リップルエフェクト)を消す

import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier

@Composable
fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier =
    this then Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = null,
        onClick = onClick
    )

使う時はModifierに設定すれば良いだけです。


[JetpackCompose] iOSのようにリストの項目をスワイプできるようにする

iOSではリストの一部をスワイプして削除、マーキングする等アクションを発生させることができますが、Androidも同じ機能を実装する場合は自作するかGoogle非公式ライブラリを使うことが多かったと思います。マテリアルデザイン3でもリストのガイドラインでもスワイプについての言及があります。

https://m3.material.io/components/lists/guidelines#79546d3e-e897-4cfe-b842-664f6ad8750b

まだベータ版ですが、JetpackComposeでSwipeToDismissBoxが追加されたのでこれでiOSに近いUIを提供できます。

https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#SwipeToDismissBox(androidx.compose.material3.SwipeToDismissBoxState,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Boolean,kotlin.Boolean,kotlin.Boolean,kotlin.Function1)

import android.widget.Toast
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Tex
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SwipeToDismissBoxScreen() {
    val context = LocalContext.current
    val firstDismissState = rememberSwipeToDismissBoxState()
    val firstTargetColor by animateColorAsState(getColor(firstDismissState.targetValue), label = "firstTargetColor")
    val secondDismissState = rememberSwipeToDismissBoxState()
    MaterialTheme {
        Column {
            if (firstDismissState.currentValue != SwipeToDismissBoxValue.Settled) {
                LaunchedEffect(key1 = "first") {
                    Toast.makeText(context, firstDismissState.currentValue.toString(), Toast.LENGTH_SHORT).show()
                    firstDismissState.reset()
                }
            }

            SwipeToDismissBox(
                state = firstDismissState,
                backgroundContent = {
                    Box(Modifier.fillMaxSize().background(firstTargetColor))
                }
            ) {
                OutlinedCard(shape = RectangleShape) {
                    ListItem(
                        headlineContent = { Text("FirstItem") },
                        supportingContent = { Text("Swipe me left or right!") }
                    )
                }
            }

            if (secondDismissState.currentValue == SwipeToDismissBoxValue.Settled) {
                SwipeToDismissBox(
                    state = secondDismissState,
                    backgroundContent = {
                        val color by animateColorAsState(getColor(secondDismissState.targetValue), label = "second")
                        Box(Modifier.fillMaxSize().background(color))
                    }
                ) {
                    OutlinedCard(shape = RectangleShape) {
                        ListItem(
                            headlineContent = { Text("SecondItem") },
                            supportingContent = { Text("Swipe me left or right!") }
                        )
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
fun getColor(value: SwipeToDismissBoxValue): Color =
    when (value) {
        SwipeToDismissBoxValue.Settled -> Color.LightGray
        SwipeToDismissBoxValue.StartToEnd -> Color.Green
        SwipeToDismissBoxValue.EndToStart -> Color.Red
    }

2024年5月時点ではまだ正式版ではないので@OptIn(ExperimentalMaterial3Api::class)が必要です。今回の実装ではスワイプして項目を維持する場合と消えてなくなる場合のコードになります。アニメーション使ってキレイに消したり、背景にアイコンを表示したり、LazyColumnで実装してもできると思います。

"androidx.compose.material3:material3のライブラリは2023年あたりだと使えないので最新のバージョンを使いましょう。

JetpackComposeでカスタムダイアログを実装する

AndroidViewのときはダイアログのライブラリとか作ってくる方がいて使っていたのですが、JetpackComposeだとあんまり無くて自作することになることが多いです。

タイトル、テキスト、キャンセルボタン、OKボタンのみのダイアログを作る場合はAlertDialogを使いましょう。

https://developer.android.com/develop/ui/compose/components/dialog?hl=ja#alert

っで本題ですが、ダイアログの中身を変更する場合はDialogを使います。

@Composable
internal fun CustomDialog(
    onDismissRequest: () -> Unit,
    onConfirmation: () -> Unit
) {
    val narrowPadding = 16.dp
    val widePadding = 24.dp
    Dialog(
        onDismissRequest = { onDismissRequest.invoke() }) { // onDismissRequest()でもOK
        Card(
            shape = RoundedCornerShape(28.dp)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = narrowPadding, vertical = widePadding),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                /* ダイアログに表示したいComposeをここに書く */

                // 下部のOK、Cancelボタン
                Row(
                    modifier = Modifier
                        .align(Alignment.End)
                        .padding(top = widePadding)
                ) {
                    TextButton(
                        onClick = { onDismissRequest.invoke() }
                    ) {
                        Text(text = stringResource(id = android.R.string.cancel))
                    }
                    TextButton(
                        onClick = { onConfirmation.invoke((sliderValue * 10).toInt()) }
                    ) {
                        Text(text = stringResource(id = android.R.string.ok))
                    }
                }
            }
        }
    }
}

角丸やパディングはマテリアルデザインのルールがあるようなので、それに従うように実装すると見た目がきれいになります。

https://m3.material.io/components/dialogs/specs#9a8c226b-19fa-4d6b-894e-e7d5ca9203e8


material design 3 composeでテキスト付きボタンを表示する

Composeで実装しているとボタンでも複数ありますし、ボタン内にアイコンを表示する場合もあるかと思います。material design 3でテキスト付きのボタンは下記の種類があります。

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        androidx.compose.material3.Button(onClick = { }) { Text("androidx.compose.material3.Button") }
        androidx.compose.material3.ElevatedButton(onClick = { }) { Text("androidx.compose.material3.ElevatedButton") }
        androidx.compose.material3.FilledTonalButton(onClick = { }) { Text("androidx.compose.material3.FilledTonalButton") }
        androidx.compose.material3.OutlinedButton(onClick = { }) { Text("androidx.compose.material3.OutlinedButton")}
        androidx.compose.material3.TextButton(onClick = { }) {Text("androidx.compose.material3.TextButton") }
    }

IconButtonも結構見た目が違います

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        androidx.compose.material3.IconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.IconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledIconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledIconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledTonalIconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledTonalIconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.OutlinedIconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.OutlinedIconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        }

特定のComposeに固執してデザインを変えるより期待するデザインに近いComposeを使う方がコードも少なくできるのでどんなComposeがあるか確認してから実装した方が良さそうです。


[JetpackCompose] 画像を表示してクリックリスナーを設定し、波紋効果も効くようにする

JetpackComposeって実装方法色々あってどの方法が良いのか調べるのがちょっと面倒だったりする。結論から言うとIconButtonを使うとできます。ImageやIconでも多分やりかたあると思うけど、波紋効果がデフォルトで効かなかったりクリックリスナーが無かったりするので、IconButtonが一番簡単。

https://m3.material.io/components/icon-buttons/overview

@Composable
fun MyIconButton() {
    IconButton(onClick = {}) {
        Icon(
            painter = painterResource(R.drawable.icon),
            contentDescription = "icon"
        )
    }
}


[Android] ImageComposeでローカルの画像が表示されているか確認するユニットテストを考える

ユニットテストでDrawable画像が表示されているかを確認したい時ってあんまり無いのでしょうか。Googleから公式で用意されていないようなのでEspressoの時は自作でDrawableMatcherを作って対応していました。Composeだとどうなんだろうと調べてみるとSemanticsというのを使うらしい。

https://developer.android.com/jetpack/compose/semantics?hl=ja

「テスト フレームワークは、このプロパティを使用してノードを検出し、それを操作して、アサーションを行います。」

一応Semantics自体はテストフレームワークのプロパティを設定してアサーションしても良いことは書いてありました。Semanticsを使うとModifierの属性を追加できるようなのでImageComposeにDrawableリソースを保存できるようにします。

build.gradle.ktsに下記を追加。

implementation("androidx.compose.ui:ui-test-junit4:2.6.2") // バージョンは適宜調べてください

下記のコードを追加。

import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.junit4.ComposeContentTestRule

val DrawableId = SemanticsPropertyKey<Int>("DrawableResId")
var SemanticsPropertyReceiver.drawableId by DrawableId
fun ComposeContentTestRule.onNodeWithDrawable(drawableId: Int): SemanticsNodeInteraction =
    onNode(SemanticsMatcher.expectValue(DrawableId, drawableId))

ImageComposeに画像を設定します。

Image(
    modifier = Modifier.semantics { drawableId = R.drawable.ic_app },
    painter = painterResource(id = R.drawble.ic_app),
    contentDescription = stringResource(id = R.string.app_name)
)

ユニットテストでonNodeWithDrawable()を使ってアサーションします。

@RunWith(AndroidJUnit4::class)
class ImageComposeTest {
    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun assert_image() {
        composeRule.setContent {
            // Imageを表示するCompose
      }
    composeRule.onNodeWithDrawable(R.drawable.ic_app).assertIsDisplayed()
    }
}

https://stackoverflow.com/a/71389302

これでImageComposeがDrawableリソースのsemanticsを持っているか確認できるが、painterと同じ設定をしてしまっているし、ImageComposeのcontentDescriptionはnullableであるが設定するべきで、contentDescriptionのアサーションをすれば実質同じことなので、なんかテストとしては微妙なのかなと思います。公式でonNodeWithDrawable()を作ってくれないかなぁ。


[Compose] LocalInspectionModeを使うと@Previewが少し便利になる

Composeの@Preview便利ですよね。@Previewをを使い込んでいくと「プログラム実行時の処理と@Preview時の処理を分けたい」場面が出てきます。例えばプログラム実行時にサーバから画像を取得して表示するけど@Preview時は通信できないのでローカルの画像を表示するとします。そのときはLocalInspectionModeを使うと便利です。

https://developer.android.com/jetpack/compose/tooling/previews?hl=ja#localinspectionmode

@Composable
fun compose() {
  if (LocalInspectionMode.current) {
    // @Previewでプレビュー表示されている場合
      // ローカル画像を表示する等
  } else {
    // 通常の実行時
    // 通信して画像を表示する等
  }
}

AndroidViewだと<ImageView tools:src=”@drawable/ic_app”>みたいな感じですね。ビルドが長いアプリほど導入のメリットが大きいんではないでしょうか。


Composeのプレビューをちょっと便利にする

Composeのプレビューを表示しておくとアプリをビルドしなくても大体の見た目を確認できるので便利です。しかし普通に@Previewアノテーションだけつけてもプレビューは一つしか表示されません。複数のプレビューを表示したい時はPreviewParameterProviderを使うと2個以上のプレビューを表示できます。

import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter

internal class AlertDialogPreviewParameterProvider : PreviewParameterProvider<AlertDialogPreviewParameterProvider.AlertDialogPreviewParameter> {
    data class AlertDialogPreviewParameter(
        val iconRes: Int?,
        val iconDescription: String?
    )

    override val values: Sequence<AlertDialogPreviewParameter>
        get() = sequenceOf(
            AlertDialogPreviewParameter(null, null),
            AlertDialogPreviewParameter(android.R.drawable.ic_delete, "iconDescription"),
        )
}

@Preview
@Composable
internal fun AlertDialogPreview(
    @PreviewParameter(AlertDialogPreviewParameterProvider::class) parameter: AlertDialogPreviewParameterProvider.AlertDialogPreviewParameter,
) =
    AlertDialog(
        iconRes = parameter.iconRes,
        iconDescription = parameter.iconDescription,
        title = "title",
        text = "text",
        dismissText = "dismissText",
        confirmText = "confirmText",
        onDismissRequest = {},
        onConfirmation = {},
    )

@Composable
fun AlertDialog(
    @DrawableRes iconRes: Int? = null,
    iconDescription: String? = null,
    title: String,
    text: String,
    confirmText: String,
    dismissText: String,
    onDismissRequest: () -> Unit,
    onConfirmation: () -> Unit,
) {
    // iconResとiconDescriptionはnullの場合に表示が異なるAlertDialog
}

これで2個以上のプレビューがAndroidStudio上に表示されます。ちなみにkoverでカバレッジを計測している場合は@Previewの関数やPreviewParameterProviderを継承しているクラスがカバレッジ対象のコードとして判定されてしまいますが、下記のような設定をbuild.gradle.ktsに記載すればカバレッジ対象から外すことができます。

koverReport {
    filters {
        excludes {
            classes(
                // PreviewParamterProviderというクラス名を除外する
                "*PreviewParameterProvider*",
            )
            // @Previewアノテーションがついている関数を除外する
            annotatedBy("androidx.compose.ui.tooling.preview.Preview")
        }
    }
}

JetpackComposeのプレビューが表示されないときのメモ

@Composableが置かれているモジュールのbuild.gradle.ktsに下記を追加

debugImplementation("androidx.compose.ui:ui-tooling:1.5.4") // バージョンは適宜調べてください
implementation("androidx.compose.ui:ui-tooling-preview-android:1.5.4")

プレビューするときにComposeが単体で実行可能になるように引数を与えて上げるようにします。

正しく動かなくても良いので適当なデータを渡します。

@Preview
@Composable
fun NavigationRailPreview() =
    NavigationRail(uiState = MainUiState(), onClick = { _, _ -> })

@Composable
fun NavigationRail(uiState: MainUiState, onClick: (index: Int, item: NavigationItem) -> Unit) {
    NavigationRail {
        ....
    }
}

AndroidStudioにプレビューが表示されるようになります。