2
4

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Posted at

JetpackComposeのDialogを下からスライドイン表示させる

気象予報のアプリからちょっと脇にそれます。。。
前回単一選択ダイアログを作成しました。これに、iOS風の下からスライドインするアニメーションをつけようと思います。

参考にした記事

完成イメージはこちら
animation_dialog_AdobeExpress.gif

Dialogのアニメーション

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

アニメーションの記述

アニメーション動作を定義するCompose作成します。

@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ではなく、MutableTransitionStateとしているのは、ダイアログを閉じる際に、アニメーションの完了を待ってDialogのonDismissRequestを呼び出すためです。(アニメーションの完了待ちに関しては後述します。)

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

ダイアログを閉じる動作

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クラス

class SlideInTransitionDialogHelper(
    private val onStartDismissWithExitAnimation: ((() -> Unit)?) -> Unit,
) {
    fun triggerAnimatedClose(onCompletedAnimation: (() -> Unit)? = null) {
        onStartDismissWithExitAnimation.invoke(onCompletedAnimation)
    }
}

ダイアログ内に表示するコンテンツから閉じる、キャンセルなどのボタンを通して、ダイアログを閉じたいときに使用するHelperクラスです。

SlideInTransitionDialog

これまでのクラスを使用して、DialogComposeを作っていきます。

@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の表示(背景をグレーにして、空っぽのコンテンツを表示するところまで)
  2. 起動したら、コンテンツをアニメーション表示する
    という動作になります。

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

使う側

このスライドインするDialogのコンテンツとして、前回作った単一選択ダイアログを少しいじったものをつくります。

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()を呼び出してアニメーションを実行する様に修正しています。

完成!

これで冒頭の完成イメージができあがりました。
すべてのコードはこちらからどうぞ

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4