JetpackCompose のAlertDialog で時間を溶かした

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

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

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

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/e4fb60e6-7d1f-153c-bcb4-057e1072e830.png  width=180px>

```kotlin
@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` にたどり着きます
ここでは、受け取ったタイトルとテキストを配置しています

```kotlin
@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 依存の固定値）

```kotlin
// 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 |
|:-:|:-:|:-:|:-:|
|   | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/df706e57-c6df-ca83-4bfe-c37ce7e2c46c.png  width=150px> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/4cacbe95-a25f-be3e-b5bf-5097e703d49f.png  width=150px> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/76896c96-c9af-c2f9-c671-1ebef09699c3.png  width=150px> |


```kotlin
@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) },
    )
}
```

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

| Text | TextField |
|:-:|:-:|
| <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/6028eec8-8345-a50b-660b-1a5d368fdd41.png  width=150px> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/7371e319-a8cd-1562-35b4-b838c9570890.png  width=150px> |

```kotlin
@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 についても自前で設定する必要があることに注意します

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/21e3f71b-502e-4c76-e4b8-252362a2b95e.png  width=150px>

```kotlin
@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 であり、その利用には慎重になる必要があります

# 参考リンク
[公式ドキュメント](https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#AlertDialog(kotlin.Function0,kotlin.Function0,androidx.compose.ui.Modifier,kotlin.Function0,kotlin.Function0,androidx.compose.ui.graphics.Shape,androidx.compose.ui.graphics.Color,androidx.compose.ui.graphics.Color,androidx.compose.ui.window.DialogProperties))
[マテリアルデザインガイドライン](https://material.io/components/dialogs)
[今回作成したサンプルプロジェクト](https://github.com/warahiko/ComposeAlertDialogTest)、ダイアログについては[ここ](https://github.com/warahiko/ComposeAlertDialogTest/blob/main/app/src/main/java/io/github/warahiko/composealertdialogtest/MyDialog.kt)