1

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).

coderforlife
  • 1,378
  • 18
  • 31

1 Answers1

1

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:

  • Has to be used in place within the dialog instead of at the top level
  • There is no host to queue messages, instead that has to be handled elsewhere if desired
  • Dismissal is done with a callback (i.e. { error = null} above) instead of automatically
  • Actions currently do nothing at all, but that could be fixed (I had no use for them, the code do include everything necessary to render the actions I believe, but none of the interaction).

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

coderforlife
  • 1,378
  • 18
  • 31