modal-dialogandroid-jetpack-composeandroid-alertdialogandroid-snackbarsnackbar

Jetpack Compose Snackbar above Dialog


How can a Snackbar be shown above a Dialog or AlertDialog in Jetpack Compose? Everything I have tried has resulted in the snack bar being below the scrim of the dialog not to mention the dialog itself.

According to Can I display material design Snackbar in dialog? it is possible in non-Compose Android by using a custom or special (like getDialog().getWindow().getDecorView()) view, but that isn't accessible from Compose I believe (at least not without a lot of effort).


Solution

  • I came up with a solution that mostly works. It uses the built-in Snackbar() composable for the rendering but handles the role of SnackbarHost() with a new function SnackbarInDialogContainer().

    Usage example:

    var error by remember { mutableStateOf<String?>(null) }
    AlertDialog(
        ...
        text = {
            ...
            if (error !== null) {
                SnackbarInDialogContainer(error, dismiss = { error = null }) {
                    Snackbar(it, Modifier.padding(WindowInsets.ime.asPaddingValues()))
                }
            }
        }
        ...
    )
    

    It has the following limitations:

    This has built-in support for avoiding the IME (software keyboard), but you may still need to follow https://stackoverflow.com/a/73889690/582298 to make it fully work.

    Code for the Composable:

    @Composable
    fun SnackbarInDialogContainer(
        text: String,
        actionLabel: String? = null,
        duration: SnackbarDuration =
            if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite,
        dismiss: () -> Unit,
        content: @Composable (SnackbarData) -> Unit
    ) {
        val snackbarData = remember {
            SnackbarDataImpl(
                SnackbarVisualsImpl(text, actionLabel, true, duration),
                dismiss
            )
        }
    
        val dur = getDuration(duration, actionLabel)
        if (dur != Long.MAX_VALUE) {
            LaunchedEffect(snackbarData) {
                delay(dur)
                snackbarData.dismiss()
            }
        }
    
        val popupPosProvider by imeMonitor()
        Popup(
            popupPositionProvider = popupPosProvider,
            properties = PopupProperties(clippingEnabled = false),
        ) {
            content(snackbarData)
        }
    }
    
    
    @Composable
    private fun getDuration(duration: SnackbarDuration, actionLabel: String?): Long {
        val accessibilityManager = LocalAccessibilityManager.current
        return remember(duration, actionLabel, accessibilityManager) {
            val orig = when (duration) {
                SnackbarDuration.Short -> 4000L
                SnackbarDuration.Long -> 10000L
                SnackbarDuration.Indefinite -> Long.MAX_VALUE
            }
            accessibilityManager?.calculateRecommendedTimeoutMillis(
                orig, containsIcons = true, containsText = true, containsControls = actionLabel != null
            ) ?: orig
        }
    }
    
    /**
     * Monitors the size of the IME (software keyboard) and provides an updating
     * PopupPositionProvider.
     */
    @Composable
    private fun imeMonitor(): State<PopupPositionProvider> {
        val provider = remember { mutableStateOf(ImePopupPositionProvider(0)) }
        val context = LocalContext.current
        val decorView = remember(context) { context.getActivity()?.window?.decorView }
        if (decorView != null) {
            val ime = remember { WindowInsetsCompat.Type.ime() }
            val bottom = remember { MutableStateFlow(0) }
            LaunchedEffect(Unit) {
                while (true) {
                    bottom.value = ViewCompat.getRootWindowInsets(decorView)?.getInsets(ime)?.bottom ?: 0
                    delay(33)
                }
            }
            LaunchedEffect(Unit) {
                bottom.collect { provider.value = ImePopupPositionProvider(it) }
            }
        }
        return provider
    }
    
    /**
     * Places the popup at the bottom of the screen but above the keyboard.
     * This assumes that the anchor for the popup is in the middle of the screen.
     */
    private data class ImePopupPositionProvider(val imeSize: Int): PopupPositionProvider {
        override fun calculatePosition(
            anchorBounds: IntRect, windowSize: IntSize,
            layoutDirection: LayoutDirection, popupContentSize: IntSize
        ) = IntOffset(
            anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2, // centered on screen
            anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 + // centered on screen
                (windowSize.height - imeSize) / 2 // move to the bottom of the screen
        )
    }
    
    
    private fun Context.getActivity(): Activity? {
        var currentContext = this
        while (currentContext is ContextWrapper) {
            if (currentContext is Activity) {
                return currentContext
            }
            currentContext = currentContext.baseContext
        }
        return null
    }
    
    
    private data class SnackbarDataImpl(
        override val visuals: SnackbarVisuals,
        val onDismiss: () -> Unit,
    ) : SnackbarData {
        override fun performAction() { /* TODO() */ }
        override fun dismiss() { onDismiss() }
    }
    
    private data class SnackbarVisualsImpl(
        override val message: String,
        override val actionLabel: String?,
        override val withDismissAction: Boolean,
        override val duration: SnackbarDuration
    ) : SnackbarVisuals