From 30f7c15d4b0f7641490eeee3e8a5213054ec9dbe Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 25 Oct 2026 03:45:04 +0000 Subject: [PATCH] Draw Android toasts above sheets and dialogs Android hosted the toasts in ComposeViews inside the activity's decor view. Material3's ModalBottomSheet and every Dialog are separate windows stacked above the activity window, so a toast shown while one was open was drawn underneath it. Each position now gets its own TYPE_APPLICATION window, added through the activity's WindowManager while that position has toasts and removed when it is empty. Windows that share the activity's token are ordered by when they were added, so while toasts are on screen the overlay checks each frame whether another window of the activity was added after it, and re-adds itself if so. The hosts compose against a recomposer the overlay owns, so a toast keeps its animation and swipe state across a re-add. The windows are FLAG_NOT_FOCUSABLE, so a text field in a sheet keeps focus and the keyboard stays up. On API 34+ the touchable region is set to the toast cards, so taps around them reach the screen. Before 34 there is no public way to do that and the band's padding takes taps while a toast shows. The overlay sits above the keyboard and is not given IME insets, so the bottom band reads the keyboard height from the activity's other windows and pads for it. Before, a bottom toast was hidden behind the keyboard. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + .../toastkit/manager/ToastKitManager.kt | 10 +- .../toastkit/presentation/ToastKitHost.kt | 23 +- .../presentation/ToastKitHostInstaller.kt | 270 ++++++++++++++++-- 4 files changed, 271 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47870bc..2d6c88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,3 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** ToastKit's public exceptions now extend the package-owned `ToastKitException` base, which extends `RuntimeException`. Replace ToastKit-specific `InvalidArgumentException` catches with `ToastKitException`. - Composer requirement raised to PHP `^8.4` and NativePHP Mobile `^4.1`. - Pest upgraded to `^4.0`. + +### Fixed + +- Android toasts now draw above Material3 bottom sheets, dialogs and any other window the app opens, including ones opened while a toast is showing. Each position is hosted in its own non-focusable window instead of the activity's decor view, so a text field keeps focus and the keyboard. Bottom toasts sit above the keyboard. On Android 14+ only the toast cards take touches; on Android 10 to 13 the padding around them does too. diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt index 74792ef..9fa7fdf 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt @@ -21,6 +21,9 @@ internal object ToastKitManager { private val activeIds = mutableSetOf() private val uniqueToId = mutableMapOf() + /** Called on the main thread whenever [rendered] changes, so the overlay windows can follow it. */ + var onRenderedChanged: (() -> Unit)? = null + fun show(configuration: ToastKitConfiguration): ToastKitShowResult { val result = synchronized(identityLock) { configuration.uniqueKey?.let { key -> @@ -98,6 +101,7 @@ internal object ToastKitManager { states[id] = state.copy(visible = true) visible.add(state.configuration) rendered.add(state.configuration) + onRenderedChanged?.invoke() schedule(state.configuration) ToastKitEventDispatcher.shown(id) } @@ -126,6 +130,7 @@ internal object ToastKitManager { main.postDelayed({ rendered.removeAll { it.id == id } exiting.remove(id) + onRenderedChanged?.invoke() releaseIdentity(state.configuration) if (promote) promote() }, exitDuration) @@ -165,7 +170,10 @@ internal object ToastKitManager { val index = visible.indexOfFirst { it.id == configuration.id } if (index >= 0) visible[index] = configuration val renderedIndex = rendered.indexOfFirst { it.id == configuration.id } - if (renderedIndex >= 0) rendered[renderedIndex] = configuration + if (renderedIndex >= 0) { + rendered[renderedIndex] = configuration + onRenderedChanged?.invoke() + } } private inline fun onMain(crossinline operation: () -> Unit) { diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt index e18df95..0bd9f79 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -28,6 +28,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.input.pointer.pointerInput @@ -53,12 +56,17 @@ import kotlin.math.abs import kotlinx.coroutines.delay @Composable -internal fun ToastKitHost(position: ToastKitPosition) { +internal fun ToastKitHost( + position: ToastKitPosition, + keyboardHeight: Int = 0, + onCardBounds: (String, Rect?) -> Unit = { _, _ -> }, +) { val items = ToastKitManager.rendered.filter { effectivePosition(it) == position } val vertical = when (position) { ToastKitPosition.TOP -> Arrangement.Top; ToastKitPosition.CENTER -> Arrangement.Center; ToastKitPosition.BOTTOM -> Arrangement.Bottom } val safeArea = when (position) { ToastKitPosition.TOP -> Modifier.statusBarsPadding() - ToastKitPosition.BOTTOM -> Modifier.navigationBarsPadding() + // The overlay window sits above the keyboard, so keep bottom toasts clear of it. + ToastKitPosition.BOTTOM -> Modifier.windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets(bottom = keyboardHeight))) ToastKitPosition.CENTER -> Modifier } Column( @@ -67,13 +75,16 @@ internal fun ToastKitHost(position: ToastKitPosition) { horizontalAlignment = Alignment.CenterHorizontally, ) { items.forEach { toast -> - key(toast.id) { ToastKitCard(toast) } + key(toast.id) { + DisposableEffect(toast.id) { onDispose { onCardBounds(toast.id, null) } } + ToastKitCard(toast, Modifier.onGloballyPositioned { onCardBounds(toast.id, it.boundsInWindow()) }) + } } } } @Composable -private fun ToastKitCard(toast: ToastKitConfiguration) { +private fun ToastKitCard(toast: ToastKitConfiguration, modifier: Modifier = Modifier) { val position = effectivePosition(toast) var x by remember(toast.id) { mutableFloatStateOf(0f) } var y by remember(toast.id) { mutableFloatStateOf(0f) } @@ -134,7 +145,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { val visibility = remember(toast.id) { MutableTransitionState(false) } val shouldBeVisible = toast.id !in ToastKitManager.exiting LaunchedEffect(shouldBeVisible) { visibility.targetState = shouldBeVisible } - AnimatedVisibility(visibleState = visibility, enter = enter, exit = exit) { + AnimatedVisibility(visibleState = visibility, modifier = modifier, enter = enter, exit = exit) { val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp) .then(if (toast.presentation == ToastKitPresentation.STANDARD) Modifier.fillMaxWidth() else Modifier) .semantics { liveRegion = LiveRegionMode.Polite } @@ -430,7 +441,7 @@ private fun resolvedDirection(toast: ToastKitConfiguration): ToastKitDirection = else -> toast.direction } -private fun effectivePosition(toast: ToastKitConfiguration): ToastKitPosition = +internal fun effectivePosition(toast: ToastKitConfiguration): ToastKitPosition = if (toast.presentation == ToastKitPresentation.DYNAMIC) ToastKitPosition.TOP else toast.position private fun directionalEnter(toast: ToastKitConfiguration, spec: androidx.compose.animation.core.FiniteAnimationSpec): EnterTransition = diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt index 3070ecf..8a1413f 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt @@ -3,32 +3,68 @@ package com.victorycodedev.plugins.toastkit.presentation import android.app.Activity import android.app.Application import android.content.Context +import android.graphics.PixelFormat +import android.graphics.Region +import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.view.Choreographer import android.view.Gravity +import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout +import android.view.WindowManager +import android.view.inspector.WindowInspector import androidx.activity.ComponentActivity -import androidx.compose.ui.platform.ComposeView import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.victorycodedev.plugins.toastkit.model.ToastKitPosition +import com.nativephp.mobile.ui.NativeUIThemeProvider import com.victorycodedev.plugins.toastkit.bridge.ToastKitEventDispatcher -import androidx.fragment.app.FragmentActivity +import com.victorycodedev.plugins.toastkit.manager.ToastKitManager +import com.victorycodedev.plugins.toastkit.model.ToastKitPosition +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import java.util.WeakHashMap -import android.os.Handler -import android.os.Looper -import com.nativephp.mobile.ui.NativeUIThemeProvider +import kotlin.math.roundToInt +/** + * Draws toasts in their own application windows instead of the activity's decor view. + * + * Dialogs, including Material3's ModalBottomSheet, are separate windows stacked above the + * activity window, so anything inside the activity's decor view ends up underneath them. + * Each toast position gets a window of its own, added through the activity's WindowManager + * while that position has toasts and removed when it is empty. Windows that share the + * activity's token are z-ordered by when they were added, so while toasts are on screen the + * overlay checks every frame whether another window of the activity was added after it and, + * if so, re-adds itself to get back on top. + * + * The windows are not focusable, so they never take focus or the keyboard away from a text + * field, and taps outside the window go to whatever is underneath. On API 34+ the touchable + * region is narrowed to the toast cards so the padding around them passes taps through too. + * The overlay draws above the keyboard, so bottom toasts are lifted clear of it. + */ internal object ToastKitHostInstaller : Application.ActivityLifecycleCallbacks { - private val attached = WeakHashMap>() + private val overlays = WeakHashMap() private var installed = false @Synchronized fun initialize(context: Context) { if (installed) return (context.applicationContext as? Application)?.registerActivityLifecycleCallbacks(this) + ToastKitManager.onRenderedChanged = ::syncAll installed = true } @@ -36,7 +72,10 @@ internal object ToastKitHostInstaller : Application.ActivityLifecycleCallbacks { (activity as? FragmentActivity)?.let(ToastKitEventDispatcher::bind) ensureAttached(activity) } - override fun onActivityDestroyed(activity: Activity) { attached.remove(activity)?.forEach { (it.parent as? ViewGroup)?.removeView(it) } } + + override fun onActivityDestroyed(activity: Activity) { + overlays.remove(activity)?.destroy() + } fun ensureAttached(activity: Activity) { if (Looper.myLooper() == Looper.getMainLooper()) attach(activity) @@ -44,26 +83,14 @@ internal object ToastKitHostInstaller : Application.ActivityLifecycleCallbacks { } private fun attach(activity: Activity) { - if (attached.containsKey(activity) || activity !is ComponentActivity) return - val decor = activity.window.decorView as? ViewGroup ?: return - val views = ToastKitPosition.entries.map { position -> - ComposeView(activity).apply { - setViewTreeLifecycleOwner(activity) - setViewTreeViewModelStoreOwner(activity) - setViewTreeSavedStateRegistryOwner(activity) - setContent { - val isDark = isSystemInDarkTheme() - MaterialTheme( - colorScheme = NativeUIThemeProvider.resolve(isDark), - typography = NativeUIThemeProvider.resolveTypography(), - ) { ToastKitHost(position) } - } - decor.addView(this, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { - gravity = when (position) { ToastKitPosition.TOP -> Gravity.TOP; ToastKitPosition.CENTER -> Gravity.CENTER; ToastKitPosition.BOTTOM -> Gravity.BOTTOM } - }) - } - } - attached[activity] = views + if (ToastKitManager.onRenderedChanged == null) ToastKitManager.onRenderedChanged = ::syncAll + if (overlays.containsKey(activity) || activity !is ComponentActivity) return + if (activity.isFinishing || activity.isDestroyed) return + overlays[activity] = ToastKitOverlay(activity).also { it.sync() } + } + + private fun syncAll() { + overlays.values.toList().forEach { it.sync() } } override fun onActivityCreated(a: Activity, b: Bundle?) = Unit @@ -72,3 +99,188 @@ internal object ToastKitHostInstaller : Application.ActivityLifecycleCallbacks { override fun onActivityStopped(a: Activity) = Unit override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit } + +private class ToastKitOverlay(private val activity: ComponentActivity) : Choreographer.FrameCallback { + private val windowManager = activity.windowManager + + // The hosts are detached and re-attached to change their z-order. A window's default + // recomposer is cancelled when its root view detaches, so the overlay owns one that lives + // as long as the activity. That keeps each toast's animation and swipe state across a re-add. + private val scope = CoroutineScope(AndroidUiDispatcher.Main + SupervisorJob()) + private val recomposer = Recomposer(scope.coroutineContext).also { recomposer -> + scope.launch { recomposer.runRecomposeAndApplyChanges() } + } + + private val hosts = ToastKitPosition.entries.associateWith(::Host) + private var watching = false + private var destroyed = false + + fun sync() { + if (destroyed) return + if (activity.isFinishing || activity.isDestroyed) return destroy() + val needed = ToastKitManager.rendered.map(::effectivePosition).toSet() + hosts.forEach { (position, host) -> + if (position in needed) host.show() else host.hide() + } + val anyShown = hosts.values.any { it.shown } + if (anyShown) followActivityWindows() + if (anyShown && !watching) { + watching = true + Choreographer.getInstance().postFrameCallback(this) + } + } + + override fun doFrame(frameTimeNanos: Long) { + if (destroyed || hosts.values.none { it.shown }) { + watching = false + return + } + followActivityWindows() + Choreographer.getInstance().postFrameCallback(this) + } + + fun destroy() { + if (destroyed) return + destroyed = true + Choreographer.getInstance().removeFrameCallback(this) + watching = false + hosts.values.forEach { it.destroy() } + recomposer.cancel() + scope.cancel() + } + + /** + * Re-adds any shown host that has another window of this activity stacked above it, and + * passes the keyboard's height to the hosts. + */ + private fun followActivityWindows() { + val shown = hosts.values.filter { it.shown } + if (shown.isEmpty()) return + val roots = try { WindowInspector.getGlobalWindowViews() } catch (_: Throwable) { return } + val ownRoots = hosts.values.map { it.view }.toSet() + val appToken = activity.window.attributes.token ?: return + + // Windows that belong to this activity: anything added with its token (its own window, + // dialogs, sheets) plus sub-windows such as popups and menus attached to one of those. + // Windows of other activities are skipped so a toast never fights an activity above it. + val activityWindows = HashSet() + var lastForeign = -1 + var keyboard = 0 + roots.forEachIndexed { index, root -> + val params = root.layoutParams as? WindowManager.LayoutParams ?: return@forEachIndexed + val belongs = params.token != null && (params.token == appToken || params.token in activityWindows) + if (!belongs) return@forEachIndexed + root.windowToken?.let(activityWindows::add) + if (root in ownRoots || !root.isAttachedToWindow || root.windowVisibility != View.VISIBLE) return@forEachIndexed + lastForeign = index + // WindowManager only reports the keyboard to windows beneath it, and the overlay is + // above it, so its height is read from the window that is being typed in. + root.rootWindowInsets?.let { insets -> + keyboard = maxOf(keyboard, WindowInsetsCompat.toWindowInsetsCompat(insets, root).getInsets(WindowInsetsCompat.Type.ime()).bottom) + } + } + hosts.values.forEach { it.keyboardHeight.intValue = keyboard } + if (lastForeign < 0) return + shown.filter { roots.indexOf(it.view) < lastForeign }.forEach { it.readd() } + } + + private inner class Host(private val position: ToastKitPosition) { + private val cardBounds = mutableMapOf() + val keyboardHeight = mutableIntStateOf(0) + var shown = false + private set + + val view: ComposeView = ComposeView(activity).apply { + setViewTreeLifecycleOwner(activity) + setViewTreeViewModelStoreOwner(activity) + setViewTreeSavedStateRegistryOwner(activity) + setParentCompositionContext(recomposer) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(activity)) + // The root surface only exists once the view is attached, and each re-add is a new one. + addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = updateTouchableRegion() + override fun onViewDetachedFromWindow(v: View) = Unit + }) + setContent { + val isDark = isSystemInDarkTheme() + MaterialTheme( + colorScheme = NativeUIThemeProvider.resolve(isDark), + typography = NativeUIThemeProvider.resolveTypography(), + ) { ToastKitHost(position, keyboardHeight.intValue, ::onCardBounds) } + } + } + + fun show() { + if (shown) return + try { + windowManager.addView(view, layoutParams()) + shown = true + } catch (_: WindowManager.BadTokenException) { + } catch (_: IllegalStateException) { + } + } + + fun hide() { + if (!shown) return + shown = false + try { windowManager.removeViewImmediate(view) } catch (_: IllegalArgumentException) {} + } + + fun readd() { + hide() + show() + } + + fun destroy() { + hide() + view.disposeComposition() + } + + private fun onCardBounds(id: String, bounds: Rect?) { + if (bounds == null) cardBounds.remove(id) else cardBounds[id] = bounds + updateTouchableRegion() + } + + // Without this the whole band (the safe-area and the padding around the cards) would + // swallow taps. Before API 34 there is no public way to narrow a window's touch region. + private fun updateTouchableRegion() { + if (!shown || Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + val region = Region() + cardBounds.values.forEach { bounds -> + region.op( + bounds.left.roundToInt(), bounds.top.roundToInt(), + bounds.right.roundToInt(), bounds.bottom.roundToInt(), + Region.Op.UNION, + ) + } + view.rootSurfaceControl?.setTouchableRegion(region) + } + + private fun layoutParams() = WindowManager.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = when (position) { + ToastKitPosition.TOP -> Gravity.TOP + ToastKitPosition.CENTER -> Gravity.CENTER + ToastKitPosition.BOTTOM -> Gravity.BOTTOM + } + title = "ToastKit:${position.name}" + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + // Lay out under the system bars like the decor view did; ToastKitHost pads for them. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + fitInsetsTypes = 0 + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS + } else { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + } +}