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 + } + } + } +}