JetpackCompose 下からスライドインするダイアログを作る

# JetpackComposeのDialogを下からスライドイン表示させる
気象予報のアプリからちょっと脇にそれます。。。
[前回](https://qiita.com/jozuko_dev/items/b946bc26f0ee52484b19)単一選択ダイアログを作成しました。これに、iOS風の下からスライドインするアニメーションをつけようと思います。

[参考にした記事](https://medium.com/tech-takeaways/ios-like-modal-view-dialog-animation-in-jetpack-compose-fac5778969af)

完成イメージはこちら
![animation_dialog_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/6fb6be15-7425-e2e7-5eb5-dca270e6123b.gif)

## Dialogのアニメーション
DialogComposeは、表示/非表示を切り替えるだけなら「AnimatedVisibility」でもOKですが、それ以外のアニメーションは動きません。
Dialogとしての機能（戻るボタンで閉じる、コンテンツ外をタップで閉じる）はそのままで、コンテンツに表示アニメーションをつける場合は、Dialogのcontent内で表示/非表示時にアニメーションを実行するようにすれば良いです。

## アニメーションの記述
アニメーション動作を定義するCompose作成します。
```kotlin:
@Composable
fun SlideInTransition(
    visibleState: MutableTransitionState<Boolean>,
    content: @Composable AnimatedVisibilityScope.() -> Unit,
) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Transparent),
        contentAlignment = Alignment.Center,
    ) {
        AnimatedVisibility(
            visibleState = visibleState,
            enter = slideInVertically(
                animationSpec = tween(
                    durationMillis = ANIMATION_TIME.toInt(),
                    easing = LinearEasing,
                ),
                initialOffsetY = { fullHeight -> fullHeight },
            ),
            exit = slideOutVertically(
                animationSpec = tween(
                    durationMillis = ANIMATION_TIME.toInt(),
                    easing = LinearEasing,
                ),
                targetOffsetY = { fullHeight -> fullHeight },
            ),
            content = content,
        )
    }
}
```
引数のvisibleStateを、MutableState<Boolean>ではなく、MutableTransitionState<Boolean>としているのは、ダイアログを閉じる際に、アニメーションの完了を待ってDialogのonDismissRequestを呼び出すためです。（アニメーションの完了待ちに関しては後述します。）

一番外側のBoxは、ダイアログが表示した際に一番下のレイヤーになるレイアウトです。（これがないとanimationでコンテンツが表示できなかった）この中で、スライドイン/スライドアウトのアニメーション処理を行っています。

## ダイアログを閉じる動作
```kotlin
private suspend fun startDismissWithExitAnimation(
    showContentState: MutableTransitionState<Boolean>,
    onDismissRequest: () -> Unit,
) = withContext(Dispatchers.IO) {
    showContentState.targetState = false

    while (true) {
        delay(10L)
        if (!showContentState.targetState && showContentState.isIdle) {
            onDismissRequest.invoke()
            break
        }
    }
}
```
delayがあるので、suspend関数になります。
showContentStateは、前述のvisibleStateと同じインスタンスです。これを切り替えることで、まずは下にスライドアウトするアニメーションを動かします。
アニメーションの動作終了は、showContentState.isIdleがtrueになるのでそれを監視することで実現してみました。
アニメーションが終わったら、ダイアログ自体の表示終了をリクエストするためonDismissRequestを呼び出しています。

## DialogのHelperクラス
```kotlin
class SlideInTransitionDialogHelper(
    private val onStartDismissWithExitAnimation: ((() -> Unit)?) -> Unit,
) {
    fun triggerAnimatedClose(onCompletedAnimation: (() -> Unit)? = null) {
        onStartDismissWithExitAnimation.invoke(onCompletedAnimation)
    }
}
```
ダイアログ内に表示するコンテンツから閉じる、キャンセルなどのボタンを通して、ダイアログを閉じたいときに使用するHelperクラスです。

## SlideInTransitionDialog
これまでのクラスを使用して、DialogComposeを作っていきます。
```kotlin
@Composable
fun SlideInTransitionDialog(
    onDismissRequest: () -> Unit,
    dismissOnBackPress: Boolean = true,
    content: @Composable (SlideInTransitionDialogHelper) -> Unit,
) {
    val coroutineScope: CoroutineScope = rememberCoroutineScope()
    val showContentState = remember { MutableTransitionState(false) }
    val mutex = remember { Mutex(true) }

    LaunchedEffect(key1 = Unit) {
        launch {
            mutex.withLock {
                showContentState.targetState = true
            }
        }
    }

    Dialog(
        onDismissRequest = {
            coroutineScope.launch {
                startDismissWithExitAnimation(showContentState, onDismissRequest)
            }
        },
        properties = DialogProperties(dismissOnBackPress = dismissOnBackPress),
    ) {
        LaunchedEffect(key1 = Unit) {
            if (mutex.isLocked) {
                mutex.unlock()
            }
        }

        SlideInTransition(visibleState = showContentState) {
            content(
                SlideInTransitionDialogHelper(
                    onStartDismissWithExitAnimation = { onCompletedAnimation ->
                        coroutineScope.launch {
                            startDismissWithExitAnimation(showContentState, onDismissRequest)
                            onCompletedAnimation?.invoke()
                        }
                    },
                ),
            )
        }
    }
}
```
処理の流れとしては、
1. Dialogの表示（背景をグレーにして、空っぽのコンテンツを表示するところまで）
1. 起動したら、コンテンツをアニメーション表示する
という動作になります。

Dialogの表示を待機するのに、Mutexを使用しています。
DialogComposeのLaunchedでmutexのロックを解除するまで、SlideInTransitionDialogのLaunchedにあるmutex.withLockで処理が止まります。つまり、Dialogが起動したあとに、mutexのロックが解除され、mutex.withLockでロックの取得ができ、アニメーション表示が開始されるという流れになります。

## 使う側
このスライドインするDialogのコンテンツとして、前回作った単一選択ダイアログを少しいじったものをつくります。
```kotlin:SingleSelectionSlideInDialog.kt
    SlideInTransitionDialog(onDismissRequest = onDismissRequest) { helper ->
        Surface(
            /*...*/
        ) {
            Column(
                /*...*/
            ) {
                /*...*/
                Buttons(
                    /*...*/
                    onDismissRequest = {
                        helper.triggerAnimatedClose()
                    },
                    onConfirmRequest = {
                        helper.triggerAnimatedClose {
                            onConfirmRequest.invoke(it)
                        }
                    },
                )
            }
        }
    }
```
SingleSelectionDialog（アニメーションじゃない方）では、SurfaceをDialogのコンテンツにしていました。
SingleSelectionSlideInDialog（アニメーションの方）は、先ほど作ったSlideInTransitionDialogのコンテンツにしています。

また閉じる際の動作は、Screenの呼び出し側から指定されたonDismissRequest,onConfirmRequestをそのまま呼び出していましたが、HelperクラスのtriggerAnimatedClose（）を呼び出してアニメーションを実行する様に修正しています。

## 完成！
これで冒頭の完成イメージができあがりました。
すべてのコードは[こちらからどうぞ](https://github.com/jozuko/WeatherForecast/tree/d9d53fde5ed568da5abdb906c7c9618ca527e5f3)