JetpackCompose のAlertDialog で時間を溶かした


JetpackCompose でAlertDialog を使うときに失敗した話です

まとめると

  • AlertDialog を使うときはText のみで単純な構成にするのが無難
  • TextField を置きたいときはDialog を使おう
  • そもそもダイアログが必要か考えよう

どんな失敗?

テキスト入力をするダイアログをAlertDialogを使って作ると、レイアウトが崩れてしまうことがありました

@Composable
fun MyDialog4(onDismiss: () -> Unit) {
    val (text, setText) = remember { mutableStateOf("") }
    AlertDialog(
        onDismissRequest = onDismiss,
        text = {
            TextField(
                value = text,
                onValueChange = setText,
                modifier = Modifier.padding(32.dp),
            )
        },
        confirmButton = { ... },
    )
}

環境

  • Android Studio: Arctic Fox 2020.3.1 RC 1
  • Kotlin: 1.5.10
  • androidx.compose.*: 1.0.0-rc02

なぜ起きた?

このレイアウト崩れが起きた原因を探るため、AlertDialog の中身を見てみましょう
すると、ColumnScope.AlertDialogBaselineLayout にたどり着きます
ここでは、受け取ったタイトルとテキストを配置しています

@Composable
internal fun ColumnScope.AlertDialogBaselineLayout(
    title: @Composable (() -> Unit)?,
    text: @Composable (() -> Unit)?
) {
    Layout(
        {
            title?.let { title ->
                Box(...) {
                    title()
                }
            }
            text?.let { text ->
                Box(...) {
                    text()
                }
            }
        },
        Modifier.weight(1f, false)
    ) { measurables, constraints ->
        val titlePlaceable = measurables.firstOrNull { it.layoutId == "title" }?.measure(
            constraints.copy(minHeight = 0)
        )
        val textPlaceable = measurables.firstOrNull { it.layoutId == "text" }?.measure(
            constraints.copy(minHeight = 0)
        )

        // このあたりでタイトルとテキストの位置を計算
        ...

        layout(layoutWidth, layoutHeight) {
            titlePlaceable?.place(0, titlePositionY)
            textPlaceable?.place(0, textPositionY)
        }
    }
}

より詳しく見ていくと、タイトル・テキストそれぞれについて、ベースラインを見ながら縦方向の位置決めをしています(横方向は単純なため本稿では割愛)
細かい内容は実際のコードを読んでもらえればと思いますが、ここでの重要な点はベースラインの位置が特定の位置に来るように配置していることです。(特定の位置は、sp 依存の固定値)

// Place the title so that its first baseline is titleOffset from the top
val titlePositionY = titleOffset - firstTitleBaseline

これを知っていないと、いくつかの問題が起きることがあります

top 方向のpadding が効かない

ベースラインの位置を特定の位置に合わせようとするため、top にpadding を指定しても効きません
また、大きな値を入れてしまうとテキストそのものが潰れてしまいます

pad = 0 16 28
@Composable
fun MyDialog5(onDismiss: () -> Unit) {
    val pad = // 表中の値
    AlertDialog(
        onDismissRequest = onDismiss,
        title = null,
        text = {
            Text(text = "MyDialog5", modifier = Modifier.padding(top = pad.dp))
        },
        confirmButton = { CancelButton(onDismiss) },
    )
}

TextTextFieldでズレが起きる

TextTextFieldでは上端からベースラインまでの距離が異なります
そのため、単純に配置するだけでもズレが生じます

Text TextField
@Composable
fun MyDialog2(onDismiss: () -> Unit) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = null,
        text = { Text(text = "MyDialog2") },
        confirmButton = { CancelButton(onDismiss) },
    )
}

@Composable
fun MyDialog3(onDismiss: () -> Unit) {
    val (text, setText) = remember { mutableStateOf("") }
    AlertDialog(
        onDismissRequest = onDismiss,
        text = { TextField(value = text, onValueChange = setText) },
        confirmButton = { CancelButton(onDismiss) },
    )
}

解決案

解決案としては以下のような例が考えられます

Dialog を使う

自分でDialog から実装してしまいます
先ほど実際にAlertDialog の中身を見た人はわかると思いますが、これはDialog を使って実装されているため、これを真似ることで同じようなダイアログを自由に実現できます
タイトルなどを配置する場合、AlertDialog で設定されていたalpha やtextStyle についても自前で設定する必要があることに注意します

@Composable
fun MyDialog6(onDismiss: () -> Unit) {
    val (text, setText) = remember { mutableStateOf("") }
    Dialog(onDismissRequest = onDismiss) {
        Surface {
            Column {
                TextField(
                    value = text,
                    onValueChange = setText,
                    modifier = Modifier.padding(32.dp),
                )
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(all = 8.dp),
                ) {
                    CancelButton(
                        onClick = onDismiss,
                        modifier = Modifier
                            .align(Alignment.BottomEnd),
                    )
                }
            }
        }
    }
}

ダイアログを使わない

解決策とは言えないかもしれませんが、そもそもそのダイアログは必要でしょうか?
複雑なUI が必要であれば、ダイアログという考えから脱却し、通常の画面に配置するというのも手の一つです
また、ダイアログはユーザの操作をブロックするUI であり、その利用には慎重になる必要があります

参考リンク

公式ドキュメント
マテリアルデザインガイドライン
今回作成したサンプルプロジェクト、ダイアログについてはここ