月: 2024年5月

[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があるか確認してから実装した方が良さそうです。