From 1b19a54a6063f6c259fe6eef14d732a7a5225e98 Mon Sep 17 00:00:00 2001 From: Fernando Villalba <21fervillalbag@gmail.com> Date: Wed, 8 Jul 2026 15:03:51 -0300 Subject: [PATCH 01/27] feat: add an accessible dismissal path for modal sheets iOS: expose the scrim to VoiceOver as a dismiss button while a non-programmatic close detent exists, and implement accessibilityPerformEscape on the hosting view so the escape gesture dismisses the sheet through the exact scrim-tap path. Android: expose the canvas-drawn scrim as a virtual dismiss button via ExploreByTouchHelper, and surface ACTION_DISMISS on the sheet container so TalkBack's dismiss action closes the sheet from focus within the content. Refs #52 --- android/build.gradle | 3 + .../BottomSheetAccessibility.kt | 104 ++++++++++++ .../BottomSheetHostView.kt | 70 ++++++-- android/src/main/res/values/strings.xml | 6 + .../BottomSheetAccessibilityTest.kt | 151 ++++++++++++++++++ ios/BottomSheetHostingView.swift | 38 ++++- 6 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt create mode 100644 android/src/main/res/values/strings.xml create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt diff --git a/android/build.gradle b/android/build.gradle index bbf9110..2f94992 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -78,6 +78,9 @@ dependencies { implementation "androidx.activity:activity:1.9.0" // Used directly for ViewCompat Escape routing; matches Activity 1.9.0's Core dependency. implementation "androidx.core:core:1.13.0" + // ExploreByTouchHelper, used to expose the canvas-drawn scrim as a virtual + // accessibility node. + implementation "androidx.customview:customview:1.2.0" implementation "androidx.dynamicanimation:dynamicanimation:1.0.0" androidTestImplementation "androidx.test:runner:1.7.0" diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt new file mode 100644 index 0000000..709130e --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt @@ -0,0 +1,104 @@ +package com.swmansion.reactnativebottomsheet + +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.customview.widget.ExploreByTouchHelper + +internal class ScrimAccessibilityHelper( + private val host: View, + private val isDismissAvailable: () -> Boolean, + private val scrimBottom: () -> Float, + private val performDismiss: () -> Boolean, +) : ExploreByTouchHelper(host) { + private var wasVisible = false + + fun updateVisibility() { + val visible = isVirtualScrimVisible() + if (visible == wasVisible) return + + wasVisible = visible + invalidateRoot() + } + + override fun getVirtualViewAt(x: Float, y: Float): Int { + val bottom = scrimBottomInParent() + return if (isDismissAvailable() && x >= 0f && x < host.width && y >= 0f && y < bottom) { + SCRIM_VIRTUAL_VIEW_ID + } else { + INVALID_ID + } + } + + override fun getVisibleVirtualViews(virtualViewIds: MutableList) { + if (isVirtualScrimVisible()) virtualViewIds.add(SCRIM_VIRTUAL_VIEW_ID) + } + + override fun onPopulateNodeForVirtualView( + virtualViewId: Int, + node: AccessibilityNodeInfoCompat, + ) { + node.className = "android.widget.Button" + node.contentDescription = host.context.getString(R.string.bottom_sheet_dismiss) + node.isClickable = true + node.isDismissable = true + node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK) + node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS) + setBoundsInScreenFromBoundsInParent( + node, + Rect(0, 0, host.width, scrimBottomInParent()), + ) + } + + override fun onPerformActionForVirtualView( + virtualViewId: Int, + action: Int, + arguments: Bundle?, + ): Boolean = + virtualViewId == SCRIM_VIRTUAL_VIEW_ID && + isDismissAvailable() && + when (action) { + AccessibilityNodeInfoCompat.ACTION_CLICK, + AccessibilityNodeInfoCompat.ACTION_DISMISS -> performDismiss() + else -> false + } + + private fun isVirtualScrimVisible(): Boolean = + isDismissAvailable() && host.width > 0 && scrimBottomInParent() > 0 + + private fun scrimBottomInParent(): Int = scrimBottom().toInt().coerceIn(0, host.height) + + private companion object { + // Virtual IDs are local to this helper; zero identifies its only virtual child. + const val SCRIM_VIRTUAL_VIEW_ID = 0 + } +} + +internal class SheetDismissAccessibilityDelegate( + private val isDismissAvailable: () -> Boolean, + private val performDismiss: () -> Boolean, +) : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + if (isDismissAvailable()) { + info.isDismissable = true + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS) + } + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + if ( + action == AccessibilityNodeInfoCompat.ACTION_DISMISS && + isDismissAvailable() && + performDismiss() + ) { + return true + } + return super.performAccessibilityAction(host, action, args) + } +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt index 96b0bb6..0d37dc2 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt @@ -4,6 +4,8 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.Rect +import android.view.KeyEvent import android.view.MotionEvent import android.view.VelocityTracker import android.view.View @@ -125,6 +127,13 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private val sheetContainer = FrameLayout(context) private val scrimPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val scrimAccessibilityHelper = + ScrimAccessibilityHelper( + host = this, + isDismissAvailable = { isScrimDismissalAvailable }, + scrimBottom = { currentSheetTop }, + performDismiss = ::attemptScrimDismissal, + ) private var activeAnimation: SpringAnimation? = null private var activeAnimationEmitsSettle = false private var velocityTracker: VelocityTracker? = null @@ -150,6 +159,11 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // only guards against empty input (indexing requires a non-empty array). private var scrimOpacities = listOf(1f) private var scrimProgress = 0f + set(value) { + field = value + scrimAccessibilityHelper.updateVisibility() + } + private var suppressScrimForClosingTarget = false private var scrimPinnedFull = false private var contentHeightMarker: View? = null @@ -180,6 +194,15 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr sheetContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), ) + // The canvas-drawn scrim needs a virtual node to be discoverable by TalkBack. + ViewCompat.setAccessibilityDelegate(this, scrimAccessibilityHelper) + ViewCompat.setAccessibilityDelegate( + sheetContainer, + SheetDismissAccessibilityDelegate( + isDismissAvailable = { isScrimDismissalAvailable }, + performDismiss = ::attemptScrimDismissal, + ), + ) } val sheetChildCount: Int @@ -367,6 +390,25 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr super.dispatchDraw(canvas) } + // MARK: - Accessibility + // + // ExploreByTouchHelper drives the scrim's virtual node from hover (touch + // exploration), key, and focus events, so all three streams are forwarded. + + override fun dispatchHoverEvent(event: MotionEvent): Boolean = + scrimAccessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + scrimAccessibilityHelper.dispatchKeyEvent(event) || super.dispatchKeyEvent(event) + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + scrimAccessibilityHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + } + + private val currentSheetTop: Float + get() = sheetContainer.top + sheetContainer.translationY + private fun layoutSheetChildren(containerWidth: Int, containerHeight: Int) { for (i in 0 until sheetContainer.childCount) { val child = sheetContainer.getChildAt(i) @@ -757,6 +799,18 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private val isTargetDetentOpen: Boolean get() = detentSpecs.getOrNull(targetIndex)?.height?.let { it > 0f } == true + private val scrimDismissTargetIndex: Int? + get() = scrimDismissIndex?.takeIf { isScrimVisible() && isTargetDetentOpen } + + private val isScrimDismissalAvailable: Boolean + get() = scrimDismissTargetIndex != null + + private fun attemptScrimDismissal(): Boolean { + val closeIndex = scrimDismissTargetIndex ?: return false + snapToIndex(closeIndex, 0f) + return true + } + // Request emission follows the resolved target rather than the transient animated position. val isCloseRequestTargetResolvedAndOpen: Boolean get() = isCloseRequestLayoutReady && isTargetDetentOpen @@ -1088,8 +1142,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (event.actionMasked == MotionEvent.ACTION_DOWN) { clearNestedScrollState() } - val sheetTop = sheetContainer.top + sheetContainer.translationY - if (event.actionMasked == MotionEvent.ACTION_DOWN && event.y < sheetTop) { + if (event.actionMasked == MotionEvent.ACTION_DOWN && event.y < currentSheetTop) { if (isScrimVisible()) { initialTouchX = event.x initialTouchY = event.y @@ -1176,21 +1229,17 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (scrimTouchActive) { when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { - val sheetTop = sheetContainer.top + sheetContainer.translationY - if (event.y >= sheetTop || abs(event.y - initialTouchY) > touchSlop) { + if (event.y >= currentSheetTop || abs(event.y - initialTouchY) > touchSlop) { scrimPressed = false } return true } MotionEvent.ACTION_UP -> { - val closeIndex = scrimDismissIndex - val shouldDismiss = scrimPressed && isScrimVisible() + val shouldDismiss = scrimPressed scrimPressed = false scrimTouchActive = false activePointerId = MotionEvent.INVALID_POINTER_ID - if (shouldDismiss && closeIndex != null) { - snapToIndex(closeIndex, 0f) - } + if (shouldDismiss) attemptScrimDismissal() return true } MotionEvent.ACTION_CANCEL -> { @@ -1563,7 +1612,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private fun findScrollableAtTouch(): View? { val containerX = initialTouchX - sheetContainer.left - sheetContainer.translationX - val containerY = initialTouchY - sheetContainer.top - sheetContainer.translationY + val containerY = initialTouchY - currentSheetTop if ( containerX < 0f || containerX >= sheetContainer.width || @@ -1773,6 +1822,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr val interactive = isInteractive pointerEvents = if (interactive) PointerEvents.AUTO else PointerEvents.BOX_NONE interactionListener?.invoke(interactive) + scrimAccessibilityHelper.updateVisibility() } private fun currentSheetHeight(): Float { diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..fc49745 --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Dismiss + diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt new file mode 100644 index 0000000..5837a36 --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt @@ -0,0 +1,151 @@ +package com.swmansion.reactnativebottomsheet + +import android.content.Context +import android.graphics.Rect +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.customview.widget.ExploreByTouchHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class BottomSheetAccessibilityTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `scrim helper exposes its only virtual child with screen bounds`() { + val host = laidOutView(width = 100, height = 200) + val helper = + ScrimAccessibilityHelper( + host = host, + isDismissAvailable = { true }, + scrimBottom = { 80f }, + performDismiss = { true }, + ) + val provider = helper.getAccessibilityNodeProvider(host)!! + + val hostNode = provider.createAccessibilityNodeInfo(ExploreByTouchHelper.HOST_ID)!! + val scrimNode = provider.createAccessibilityNodeInfo(SCRIM_VIRTUAL_VIEW_ID)!! + val bounds = Rect() + scrimNode.getBoundsInScreen(bounds) + + assertEquals(1, hostNode.childCount) + assertEquals("android.widget.Button", scrimNode.className) + assertEquals("Dismiss", scrimNode.contentDescription) + assertTrue(scrimNode.isClickable) + assertTrue(scrimNode.isDismissable) + assertEquals(Rect(0, 0, 100, 80), bounds) + } + + @Test + fun `scrim helper hides its virtual child when dismissal or scrim area is unavailable`() { + var dismissAvailable = false + var scrimBottom = 80f + val host = laidOutView(width = 100, height = 200) + val helper = + ScrimAccessibilityHelper( + host = host, + isDismissAvailable = { dismissAvailable }, + scrimBottom = { scrimBottom }, + performDismiss = { true }, + ) + val provider = helper.getAccessibilityNodeProvider(host)!! + + assertEquals( + 0, + provider.createAccessibilityNodeInfo(ExploreByTouchHelper.HOST_ID)!!.childCount, + ) + + dismissAvailable = true + scrimBottom = 0f + + assertEquals( + 0, + provider.createAccessibilityNodeInfo(ExploreByTouchHelper.HOST_ID)!!.childCount, + ) + } + + @Test + fun `scrim helper routes click and dismiss actions only for its virtual child`() { + var dismissCount = 0 + val host = laidOutView(width = 100, height = 200) + val helper = + ScrimAccessibilityHelper( + host = host, + isDismissAvailable = { true }, + scrimBottom = { 80f }, + performDismiss = { + dismissCount++ + true + }, + ) + val provider = helper.getAccessibilityNodeProvider(host)!! + + assertTrue( + provider.performAction( + SCRIM_VIRTUAL_VIEW_ID, + AccessibilityNodeInfoCompat.ACTION_CLICK, + null, + ) + ) + assertTrue( + provider.performAction( + SCRIM_VIRTUAL_VIEW_ID, + AccessibilityNodeInfoCompat.ACTION_DISMISS, + null, + ) + ) + assertFalse(provider.performAction(42, AccessibilityNodeInfoCompat.ACTION_CLICK, null)) + assertEquals(2, dismissCount) + } + + @Test + fun `sheet delegate exposes and performs dismiss only while available`() { + var dismissAvailable = false + var dismissCount = 0 + val host = View(context) + val delegate = + SheetDismissAccessibilityDelegate( + isDismissAvailable = { dismissAvailable }, + performDismiss = { + dismissCount++ + true + }, + ) + + val unavailableInfo = accessibilityNodeInfo() + delegate.onInitializeAccessibilityNodeInfo(host, unavailableInfo) + assertFalse(unavailableInfo.isDismissable) + assertFalse( + delegate.performAccessibilityAction(host, AccessibilityNodeInfo.ACTION_DISMISS, null) + ) + + dismissAvailable = true + val availableInfo = accessibilityNodeInfo() + delegate.onInitializeAccessibilityNodeInfo(host, availableInfo) + assertTrue(availableInfo.isDismissable) + assertTrue( + delegate.performAccessibilityAction(host, AccessibilityNodeInfo.ACTION_DISMISS, null) + ) + assertEquals(1, dismissCount) + } + + private fun laidOutView(width: Int, height: Int): View = + View(context).apply { layout(0, 0, width, height) } + + @Suppress("DEPRECATION") + private fun accessibilityNodeInfo(): AccessibilityNodeInfoCompat = + AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain()) + + private companion object { + const val SCRIM_VIRTUAL_VIEW_ID = 0 + } +} diff --git a/ios/BottomSheetHostingView.swift b/ios/BottomSheetHostingView.swift index a88bb52..41c099a 100644 --- a/ios/BottomSheetHostingView.swift +++ b/ios/BottomSheetHostingView.swift @@ -109,6 +109,17 @@ private final class ActiveScrollViewState { } } +/// The scrim control, exposed to VoiceOver as a dismiss button while a +/// dismissible modal sheet is open. VoiceOver's default activation simulates +/// a tap at the activation point; sending the control action directly keeps +/// activation reliable even when the sheet overlaps that point mid-settle. +private final class BottomSheetScrimControl: UIControl { + override func accessibilityActivate() -> Bool { + sendActions(for: .touchUpInside) + return true + } +} + @objcMembers public final class BottomSheetHostingView: UIView { public weak var eventDelegate: BottomSheetHostingViewDelegate? @@ -164,7 +175,7 @@ public final class BottomSheetHostingView: UIView { public var animateContentHeight: Bool = true public let sheetContainer = UIView() - private let scrimView = UIControl() + private let scrimView = BottomSheetScrimControl() private var panGesture: UIPanGestureRecognizer! private var activeSpring: CriticalSpring? private var activeSpringTargetIndex: Int = 0 @@ -202,6 +213,9 @@ public final class BottomSheetHostingView: UIView { scrimView.alpha = 0 scrimView.isHidden = true scrimView.addTarget(self, action: #selector(handleScrimPress), for: .touchUpInside) + scrimView.isAccessibilityElement = false + scrimView.accessibilityTraits = .button + scrimView.accessibilityLabel = "Dismiss" addSubview(scrimView) sheetContainer.backgroundColor = .clear @@ -715,16 +729,31 @@ public final class BottomSheetHostingView: UIView { } @objc private func handleScrimPress() { + attemptScrimDismissal() + } + + /// Dismisses a modal sheet to its closed detent through the exact path a + /// scrim tap takes, returning whether a dismissal was actually performed. + @discardableResult + private func attemptScrimDismissal() -> Bool { guard modal, let closedIndex = scrimDismissIndex, targetIndex != closedIndex, activeSpring == nil || currentSheetHeight > 0.5 else { - return + return false } snapToIndex(closedIndex, velocity: 0) + return true + } + + /// VoiceOver's escape gesture (two-finger Z scrub) dismisses a modal sheet, + /// mirroring a scrim tap. Returning false when there is nothing to dismiss + /// lets the gesture keep bubbling to enclosing containers. + override public func accessibilityPerformEscape() -> Bool { + attemptScrimDismissal() } private func snapToIndex( @@ -1721,5 +1750,10 @@ private extension BottomSheetHostingView { func updateInteractionState() { scrimView.isUserInteractionEnabled = modal && (closedIndex != nil) && !scrimView.isHidden + // Expose the scrim to VoiceOver only while tapping it would dismiss the + // sheet; otherwise it would be an inert, unlabeled stop in the + // accessibility tree (a scrim over a programmatic-only close detent is + // decorative, not actionable). + scrimView.isAccessibilityElement = modal && scrimDismissIndex != nil && !scrimView.isHidden } } From 0866fe4c65569fafd3604a3c22a844c9695049bf Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Mon, 14 Sep 2026 20:00:52 +0200 Subject: [PATCH 02/27] fix(android): render the scrim as a real child view Replace the canvas-drawn scrim and its virtual accessibility node with a real native View placed below the sheet container. ExploreByTouchHelper could not reliably combine the virtual scrim with the host's real descendants. Its accessibility provider exposed the scrim while making sheet content unreachable in parts of the TalkBack tree. Forwarding key events from the host to the helper also allowed it to consume Enter before the event reached a focused sheet descendant, including when no modal scrim was active. Using a real child gives the scrim standard Android accessibility, focus, keyboard, and traversal behavior. Expose it as a Dismiss button, limit its accessibility bounds to the area above the sheet, and place it after the sheet container in TalkBack traversal order. Route click, dismiss, Enter, Space, and DPAD Center through the existing scrim dismissal path so they emit onIndexChange without triggering onCloseRequest. Keep the scrim visually full-screen and animate only View.alpha. Change visibility and accessibility properties only when their effective state changes, and use INVISIBLE instead of GONE to avoid extra layout work. The host continues to own touch handling, while the scrim view always rejects touch events, preserving the existing gesture state machine. Keep React Native child counts and Fabric indices scoped to the sheet container despite the additional native child. Remove the host ExploreByTouchHelper integration, manual Canvas drawing, Paint state, and the direct androidx.customview dependency. Add regression coverage for the real accessibility tree, focused content key delivery, scrim semantics and bounds, confirm-key activation, touch routing, disabled states, and separation from Back/Escape close requests. --- android/build.gradle | 3 - .../BottomSheetAccessibility.kt | 86 ++-- .../BottomSheetHostView.kt | 136 ++++--- android/src/main/res/values/strings.xml | 3 +- .../BottomSheetAccessibilityTest.kt | 96 ++--- .../BottomSheetHostViewScrimTest.kt | 379 ++++++++++++++++++ 6 files changed, 521 insertions(+), 182 deletions(-) create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetHostViewScrimTest.kt diff --git a/android/build.gradle b/android/build.gradle index 2f94992..bbf9110 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -78,9 +78,6 @@ dependencies { implementation "androidx.activity:activity:1.9.0" // Used directly for ViewCompat Escape routing; matches Activity 1.9.0's Core dependency. implementation "androidx.core:core:1.13.0" - // ExploreByTouchHelper, used to expose the canvas-drawn scrim as a virtual - // accessibility node. - implementation "androidx.customview:customview:1.2.0" implementation "androidx.dynamicanimation:dynamicanimation:1.0.0" androidTestImplementation "androidx.test:runner:1.7.0" diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt index 709130e..c68c5ec 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibility.kt @@ -5,74 +5,42 @@ import android.os.Bundle import android.view.View import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat -import androidx.customview.widget.ExploreByTouchHelper -internal class ScrimAccessibilityHelper( - private val host: View, +internal class ScrimAccessibilityDelegate( private val isDismissAvailable: () -> Boolean, private val scrimBottom: () -> Float, - private val performDismiss: () -> Boolean, -) : ExploreByTouchHelper(host) { - private var wasVisible = false - - fun updateVisibility() { - val visible = isVirtualScrimVisible() - if (visible == wasVisible) return - - wasVisible = visible - invalidateRoot() - } - - override fun getVirtualViewAt(x: Float, y: Float): Int { - val bottom = scrimBottomInParent() - return if (isDismissAvailable() && x >= 0f && x < host.width && y >= 0f && y < bottom) { - SCRIM_VIRTUAL_VIEW_ID - } else { - INVALID_ID - } - } +) : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + if (!isDismissAvailable()) return - override fun getVisibleVirtualViews(virtualViewIds: MutableList) { - if (isVirtualScrimVisible()) virtualViewIds.add(SCRIM_VIRTUAL_VIEW_ID) - } + info.className = "android.widget.Button" + info.contentDescription = host.contentDescription + info.isClickable = true + info.isDismissable = true + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS) - override fun onPopulateNodeForVirtualView( - virtualViewId: Int, - node: AccessibilityNodeInfoCompat, - ) { - node.className = "android.widget.Button" - node.contentDescription = host.context.getString(R.string.bottom_sheet_dismiss) - node.isClickable = true - node.isDismissable = true - node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK) - node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS) - setBoundsInScreenFromBoundsInParent( - node, - Rect(0, 0, host.width, scrimBottomInParent()), + val bottom = scrimBottom().toInt().coerceIn(0, host.height) + val location = IntArray(2) + host.getLocationOnScreen(location) + info.setBoundsInScreen( + Rect(location[0], location[1], location[0] + host.width, location[1] + bottom) ) } - override fun onPerformActionForVirtualView( - virtualViewId: Int, - action: Int, - arguments: Bundle?, - ): Boolean = - virtualViewId == SCRIM_VIRTUAL_VIEW_ID && + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + if ( isDismissAvailable() && - when (action) { - AccessibilityNodeInfoCompat.ACTION_CLICK, - AccessibilityNodeInfoCompat.ACTION_DISMISS -> performDismiss() - else -> false - } - - private fun isVirtualScrimVisible(): Boolean = - isDismissAvailable() && host.width > 0 && scrimBottomInParent() > 0 - - private fun scrimBottomInParent(): Int = scrimBottom().toInt().coerceIn(0, host.height) - - private companion object { - // Virtual IDs are local to this helper; zero identifies its only virtual child. - const val SCRIM_VIRTUAL_VIEW_ID = 0 + (action == AccessibilityNodeInfoCompat.ACTION_CLICK || + action == AccessibilityNodeInfoCompat.ACTION_DISMISS) + ) { + return host.performClick() + } + return super.performAccessibilityAction(host, action, args) } } diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt index 0d37dc2..8de3f68 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt @@ -1,10 +1,7 @@ package com.swmansion.reactnativebottomsheet import android.content.Context -import android.graphics.Canvas import android.graphics.Color -import android.graphics.Paint -import android.graphics.Rect import android.view.KeyEvent import android.view.MotionEvent import android.view.VelocityTracker @@ -126,14 +123,31 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // MARK: - Internal private val sheetContainer = FrameLayout(context) - private val scrimPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val scrimAccessibilityHelper = - ScrimAccessibilityHelper( - host = this, - isDismissAvailable = { isScrimDismissalAvailable }, - scrimBottom = { currentSheetTop }, - performDismiss = ::attemptScrimDismissal, - ) + private val scrimView = + object : View(context) { + override fun onTouchEvent(event: MotionEvent): Boolean = false + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (!isScrimConfirmKey(keyCode)) return super.onKeyDown(keyCode, event) + + if (event.repeatCount == 0) isPressed = true + return true + } + + override fun performClick(): Boolean { + if (!isScrimAccessibilityAvailable) return false + super.performClick() + return attemptScrimDismissal() + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (!isScrimConfirmKey(keyCode)) return super.onKeyUp(keyCode, event) + + val shouldClick = isPressed + isPressed = false + return shouldClick && performClick() + } + } private var activeAnimation: SpringAnimation? = null private var activeAnimationEmitsSettle = false private var velocityTracker: VelocityTracker? = null @@ -161,9 +175,13 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private var scrimProgress = 0f set(value) { field = value - scrimAccessibilityHelper.updateVisibility() + scrimView.alpha = value + updateScrimPresentationState() } + private var isScrimRendered = false + private var isScrimAccessibilityEnabled = false + private var suppressScrimForClosingTarget = false private var scrimPinnedFull = false private var contentHeightMarker: View? = null @@ -190,12 +208,30 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr pointerEvents = PointerEvents.BOX_NONE sheetContainer.clipChildren = false sheetContainer.clipToPadding = false + sheetContainer.id = View.generateViewId() + scrimView.apply { + alpha = 0f + visibility = View.INVISIBLE + setBackgroundColor(scrimColor) + contentDescription = context.getString(R.string.bottom_sheet_dismiss) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + accessibilityTraversalAfter = sheetContainer.id + } + ViewCompat.setAccessibilityDelegate( + scrimView, + ScrimAccessibilityDelegate( + isDismissAvailable = { isScrimAccessibilityAvailable }, + scrimBottom = { currentSheetTop }, + ), + ) + super.addView( + scrimView, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), + ) super.addView( sheetContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), ) - // The canvas-drawn scrim needs a virtual node to be discoverable by TalkBack. - ViewCompat.setAccessibilityDelegate(this, scrimAccessibilityHelper) ViewCompat.setAccessibilityDelegate( sheetContainer, SheetDismissAccessibilityDelegate( @@ -223,7 +259,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // MARK: - Child view management override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) { - if (child === sheetContainer) { + if (child === scrimView || child === sheetContainer) { super.addView(child, index, params) } else { sheetContainer.addView(child, index, params) @@ -231,7 +267,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr } override fun removeView(view: View) { - if (view === sheetContainer) { + if (view === scrimView || view === sheetContainer) { super.removeView(view) } else { sheetContainer.removeView(view) @@ -338,6 +374,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr refreshContentHeightMarker() refreshDetentsFromLayout() hasPerformedHostLayoutSinceAttach = true + scrimView.layout(0, 0, w, h) layoutSheetContainer(w, h) if (!hasLaidOut && detentSpecs.isNotEmpty()) { @@ -385,27 +422,6 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr notifyCloseRequestStateChanged() } - override fun dispatchDraw(canvas: Canvas) { - drawScrim(canvas) - super.dispatchDraw(canvas) - } - - // MARK: - Accessibility - // - // ExploreByTouchHelper drives the scrim's virtual node from hover (touch - // exploration), key, and focus events, so all three streams are forwarded. - - override fun dispatchHoverEvent(event: MotionEvent): Boolean = - scrimAccessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event) - - override fun dispatchKeyEvent(event: KeyEvent): Boolean = - scrimAccessibilityHelper.dispatchKeyEvent(event) || super.dispatchKeyEvent(event) - - override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { - super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) - scrimAccessibilityHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect) - } - private val currentSheetTop: Float get() = sheetContainer.top + sheetContainer.translationY @@ -436,6 +452,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr lastAppliedMaxDetentHeight = maxHeight sheetContainer.layout(0, containerTop, viewWidth, containerTop + maxHeight.toInt()) layoutSheetChildren(viewWidth, maxHeight.toInt()) + updateScrimPresentationState() } // MARK: - Prop setters @@ -486,7 +503,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr fun setScrimColor(color: Int?) { scrimColor = color ?: Color.TRANSPARENT - invalidate() + scrimView.setBackgroundColor(scrimColor) } fun setScrimOpacities(values: List) { @@ -1011,6 +1028,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr ) { if (index < 0 || index >= detentSpecs.size) return targetIndex = index + updateScrimPresentationState() if (!isTargetingClosedDetent) { suppressScrimForClosingTarget = false } @@ -1740,7 +1758,6 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private fun updateScrim(position: Float = currentSheetHeight()) { if (!modal) { scrimProgress = 0f - invalidate() return } @@ -1760,12 +1777,10 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // re-anchor settles. if (scrimPinnedFull) { scrimProgress = fullyOpenScrimOpacity() - invalidate() return } scrimProgress = scrimOpacityAt(position) - invalidate() } /** The opacity at the tallest detent, held while the sheet re-anchors. */ @@ -1812,7 +1827,6 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private fun hideScrim() { scrimProgress = 0f - invalidate() } internal val isInteractive: Boolean @@ -1822,7 +1836,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr val interactive = isInteractive pointerEvents = if (interactive) PointerEvents.AUTO else PointerEvents.BOX_NONE interactionListener?.invoke(interactive) - scrimAccessibilityHelper.updateVisibility() + updateScrimPresentationState() } private fun currentSheetHeight(): Float { @@ -1832,15 +1846,37 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private fun isScrimVisible(): Boolean = modal && scrimProgress > 0.001f - private fun drawScrim(canvas: Canvas) { - if (!modal || scrimProgress <= 0.001f) { - return + private val isScrimAccessibilityAvailable: Boolean + get() = isScrimDismissalAvailable && width > 0 && currentSheetTop > 0f + + private fun isScrimConfirmKey(keyCode: Int): Boolean = + keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_SPACE || + keyCode == KeyEvent.KEYCODE_DPAD_CENTER + + private fun updateScrimPresentationState() { + val rendered = isScrimVisible() + if (rendered != isScrimRendered) { + isScrimRendered = rendered + scrimView.visibility = if (rendered) View.VISIBLE else View.INVISIBLE } - val alpha = (Color.alpha(scrimColor) * scrimProgress).toInt().coerceIn(0, 255) - scrimPaint.color = - Color.argb(alpha, Color.red(scrimColor), Color.green(scrimColor), Color.blue(scrimColor)) - canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), scrimPaint) + val accessibilityEnabled = isScrimAccessibilityAvailable + if (accessibilityEnabled == isScrimAccessibilityEnabled) return + + isScrimAccessibilityEnabled = accessibilityEnabled + scrimView.importantForAccessibility = + if (accessibilityEnabled) { + View.IMPORTANT_FOR_ACCESSIBILITY_YES + } else { + View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + scrimView.isFocusable = accessibilityEnabled + scrimView.isClickable = accessibilityEnabled + if (!accessibilityEnabled) { + scrimView.clearFocus() + scrimView.isPressed = false + } } companion object { diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index fc49745..019019e 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ - + Dismiss diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt index 5837a36..c1510d4 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetAccessibilityTest.kt @@ -4,8 +4,8 @@ import android.content.Context import android.graphics.Rect import android.view.View import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat -import androidx.customview.widget.ExploreByTouchHelper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals @@ -21,23 +21,23 @@ class BottomSheetAccessibilityTest { private val context = ApplicationProvider.getApplicationContext() @Test - fun `scrim helper exposes its only virtual child with screen bounds`() { - val host = laidOutView(width = 100, height = 200) - val helper = - ScrimAccessibilityHelper( - host = host, + fun `scrim delegate exposes button semantics and visible bounds`() { + val scrim = + laidOutView(width = 100, height = 200).apply { + contentDescription = "Dismiss" + isClickable = true + } + val delegate = + ScrimAccessibilityDelegate( isDismissAvailable = { true }, scrimBottom = { 80f }, - performDismiss = { true }, ) - val provider = helper.getAccessibilityNodeProvider(host)!! + ViewCompat.setAccessibilityDelegate(scrim, delegate) - val hostNode = provider.createAccessibilityNodeInfo(ExploreByTouchHelper.HOST_ID)!! - val scrimNode = provider.createAccessibilityNodeInfo(SCRIM_VIRTUAL_VIEW_ID)!! + val scrimNode = scrim.createAccessibilityNodeInfo() val bounds = Rect() scrimNode.getBoundsInScreen(bounds) - assertEquals(1, hostNode.childCount) assertEquals("android.widget.Button", scrimNode.className) assertEquals("Dismiss", scrimNode.contentDescription) assertTrue(scrimNode.isClickable) @@ -46,65 +46,29 @@ class BottomSheetAccessibilityTest { } @Test - fun `scrim helper hides its virtual child when dismissal or scrim area is unavailable`() { + fun `scrim delegate does not expose or perform dismissal while unavailable`() { var dismissAvailable = false - var scrimBottom = 80f - val host = laidOutView(width = 100, height = 200) - val helper = - ScrimAccessibilityHelper( - host = host, + var clickCount = 0 + val scrim = + laidOutView(width = 100, height = 200).apply { + isClickable = true + setOnClickListener { clickCount++ } + } + val delegate = + ScrimAccessibilityDelegate( isDismissAvailable = { dismissAvailable }, - scrimBottom = { scrimBottom }, - performDismiss = { true }, + scrimBottom = { 80f }, ) - val provider = helper.getAccessibilityNodeProvider(host)!! + ViewCompat.setAccessibilityDelegate(scrim, delegate) - assertEquals( - 0, - provider.createAccessibilityNodeInfo(ExploreByTouchHelper.HOST_ID)!!.childCount, - ) + val unavailableInfo = scrim.createAccessibilityNodeInfo() + assertFalse(unavailableInfo.isDismissable) + assertFalse(scrim.performAccessibilityAction(AccessibilityNodeInfoCompat.ACTION_DISMISS, null)) dismissAvailable = true - scrimBottom = 0f - - assertEquals( - 0, - provider.createAccessibilityNodeInfo(ExploreByTouchHelper.HOST_ID)!!.childCount, - ) - } - - @Test - fun `scrim helper routes click and dismiss actions only for its virtual child`() { - var dismissCount = 0 - val host = laidOutView(width = 100, height = 200) - val helper = - ScrimAccessibilityHelper( - host = host, - isDismissAvailable = { true }, - scrimBottom = { 80f }, - performDismiss = { - dismissCount++ - true - }, - ) - val provider = helper.getAccessibilityNodeProvider(host)!! - - assertTrue( - provider.performAction( - SCRIM_VIRTUAL_VIEW_ID, - AccessibilityNodeInfoCompat.ACTION_CLICK, - null, - ) - ) - assertTrue( - provider.performAction( - SCRIM_VIRTUAL_VIEW_ID, - AccessibilityNodeInfoCompat.ACTION_DISMISS, - null, - ) - ) - assertFalse(provider.performAction(42, AccessibilityNodeInfoCompat.ACTION_CLICK, null)) - assertEquals(2, dismissCount) + assertTrue(scrim.performAccessibilityAction(AccessibilityNodeInfoCompat.ACTION_DISMISS, null)) + assertTrue(scrim.performAccessibilityAction(AccessibilityNodeInfoCompat.ACTION_CLICK, null)) + assertEquals(2, clickCount) } @Test @@ -144,8 +108,4 @@ class BottomSheetAccessibilityTest { @Suppress("DEPRECATION") private fun accessibilityNodeInfo(): AccessibilityNodeInfoCompat = AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain()) - - private companion object { - const val SCRIM_VIRTUAL_VIEW_ID = 0 - } } diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetHostViewScrimTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetHostViewScrimTest.kt new file mode 100644 index 0000000..0dfde5b --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetHostViewScrimTest.kt @@ -0,0 +1,379 @@ +package com.swmansion.reactnativebottomsheet + +import android.app.Activity +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import kotlin.math.roundToInt +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class BottomSheetHostViewScrimTest { + @Before + fun useLocalReactNativeFeatureFlags() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @Test + fun `scrim is a full size child below sheet content and mirrors visual props`() { + withActivity { activity -> + val host = configuredHost(activity) + val sheetChild = View(activity) + host.addSheetChild(sheetChild, 0) + val color = Color.argb(128, 12, 34, 56) + host.setScrimColor(color) + host.setScrimOpacities(listOf(0f, 0.4f)) + activity.setContentView(host) + layout(host) + + val scrim = scrim(host) + val sheetContainer = sheetContainer(host) + assertSame(scrim, host.getChildAt(0)) + assertSame(sheetContainer, host.getChildAt(1)) + assertSame(sheetChild, host.getSheetChildAt(0)) + assertEquals(1, host.sheetChildCount) + assertEquals(HOST_WIDTH, scrim.width) + assertEquals(HOST_HEIGHT, scrim.height) + assertEquals(color, (scrim.background as ColorDrawable).color) + assertEquals(0.4f, scrim.alpha, 0.001f) + assertEquals(View.VISIBLE, scrim.visibility) + + host.setScrimOpacities(listOf(0f, 0.7f)) + assertEquals(0.7f, scrim.alpha, 0.001f) + assertFalse(scrim.isLayoutRequested) + } + } + + @Test + fun `accessibility tree contains both sheet content and dismiss scrim`() { + withActivity { activity -> + val host = configuredHost(activity) + val sheetChild = + View(activity).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Sheet content" + } + host.addSheetChild(sheetChild, 0) + activity.setContentView(host) + layout(host) + + val hostAccessibleChildren = arrayListOf() + host.addChildrenForAccessibility(hostAccessibleChildren) + val sheetAccessibleChildren = arrayListOf() + sheetContainer(host).addChildrenForAccessibility(sheetAccessibleChildren) + + assertEquals(2, hostAccessibleChildren.size) + assertTrue(hostAccessibleChildren.contains(scrim(host))) + assertTrue(hostAccessibleChildren.contains(sheetContainer(host))) + assertTrue(sheetAccessibleChildren.contains(sheetChild)) + } + } + + @Test + fun `scrim exposes dismiss button semantics above the sheet in traversal order`() { + withActivity { activity -> + val host = configuredHost(activity) + activity.setContentView(host) + layout(host) + val scrim = scrim(host) + val node = scrim.createAccessibilityNodeInfo() + val bounds = Rect() + node.getBoundsInScreen(bounds) + val expectedBottom = + HOST_HEIGHT - (OPEN_DETENT_DP * activity.resources.displayMetrics.density).roundToInt() + + assertEquals("android.widget.Button", node.className) + assertEquals("Dismiss", node.contentDescription) + assertTrue(node.isClickable) + assertTrue(node.isDismissable) + assertTrue(node.actionList.any { it.id == AccessibilityNodeInfo.ACTION_CLICK }) + assertTrue(node.actionList.any { it.id == AccessibilityNodeInfo.ACTION_DISMISS }) + assertEquals(Rect(0, 0, HOST_WIDTH, expectedBottom), bounds) + assertEquals(sheetContainer(host).id, scrim.accessibilityTraversalAfter) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, scrim.importantForAccessibility) + assertTrue(scrim.isFocusable) + assertTrue(scrim.isClickable) + } + } + + @Test + fun `accessibility click and dismiss each snap closed without a close request`() { + val accessibilityActions = + listOf(AccessibilityNodeInfo.ACTION_CLICK, AccessibilityNodeInfo.ACTION_DISMISS) + accessibilityActions.forEach { action -> + withActivity { activity -> + val listener = RecordingListener() + val host = configuredHost(activity, listener = listener) + activity.setContentView(host) + layout(host) + + val scrim = scrim(host) + assertTrue(scrim.performAccessibilityAction(action, null)) + assertEquals(listOf(0), listener.indexChanges) + assertEquals(0, listener.closeRequestCount) + assertFalse(scrim.isClickable) + } + } + } + + @Test + fun `confirm keys on focused scrim each snap closed exactly once`() { + val confirmKeys = + listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_DPAD_CENTER) + confirmKeys.forEach { keyCode -> + withActivity { activity -> + val listener = RecordingListener() + val host = configuredHost(activity, listener = listener) + activity.setContentView(host) + layout(host) + val scrim = scrim(host) + assertTrue(scrim.requestFocus()) + + assertTrue(host.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))) + assertTrue(host.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))) + + assertEquals(listOf(0), listener.indexChanges) + assertEquals(0, listener.closeRequestCount) + } + } + } + + @Test + fun `focused sheet content receives confirm keys regardless of scrim availability`() { + listOf(false, true).forEach { modal -> + withActivity { activity -> + val host = configuredHost(activity, modal = modal) + var downCount = 0 + val sheetChild = + object : View(activity) { + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + downCount++ + return true + } + } + .apply { isFocusableInTouchMode = true } + host.addSheetChild(sheetChild, 0) + activity.setContentView(host) + sheetChild.measure( + View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY), + ) + layout(host) + assertTrue(sheetChild.requestFocus()) + + host.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A)) + assertEquals("Control: A reaches content when modal=$modal", 1, downCount) + listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_DPAD_CENTER) + .forEachIndexed { index, keyCode -> + host.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + assertEquals("Confirm key must reach content when modal=$modal", index + 2, downCount) + } + } + } + } + + @Test + fun `host dismisses a scrim tap while sheet content keeps its own touch`() { + withActivity { activity -> + val listener = RecordingListener() + val host = configuredHost(activity, listener = listener) + activity.setContentView(host) + layout(host) + + dispatchTap(host, y = 100f) + + assertEquals(listOf(0), listener.indexChanges) + assertEquals(0, listener.closeRequestCount) + } + + withActivity { activity -> + val listener = RecordingListener() + var contentTouchCount = 0 + val content = + object : View(activity) { + override fun onTouchEvent(event: MotionEvent): Boolean { + contentTouchCount++ + return true + } + } + val host = configuredHost(activity, listener = listener) + host.addSheetChild(content, 0) + val openDetentPx = (OPEN_DETENT_DP * activity.resources.displayMetrics.density).roundToInt() + content.measure( + View.MeasureSpec.makeMeasureSpec(HOST_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(openDetentPx, View.MeasureSpec.EXACTLY), + ) + activity.setContentView(host) + layout(host) + + dispatchTap(host, y = HOST_HEIGHT - openDetentPx / 2f) + + assertEquals(2, contentTouchCount) + assertTrue(listener.indexChanges.isEmpty()) + assertFalse(scrim(host).onTouchEvent(motionEvent(MotionEvent.ACTION_DOWN, 100f))) + } + } + + @Test + fun `scrim disables accessibility and activation when dismissal is unavailable`() { + listOf( + HostState(modal = false), + HostState(index = 0), + HostState(openDetent = 1.0, openDetentKind = "percentage"), + HostState(closedDetentProgrammatic = true), + ) + .forEach { state -> + withActivity { activity -> + val listener = RecordingListener() + val host = + configuredHost( + activity, + listener = listener, + modal = state.modal, + index = state.index, + openDetent = state.openDetent, + openDetentKind = state.openDetentKind, + closedDetentProgrammatic = state.closedDetentProgrammatic, + ) + activity.setContentView(host) + layout(host) + val scrim = scrim(host) + + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, scrim.importantForAccessibility) + assertFalse(scrim.isFocusable) + assertFalse(scrim.isClickable) + assertFalse(scrim.performClick()) + assertTrue(listener.indexChanges.isEmpty()) + assertEquals(0, listener.closeRequestCount) + assertFalse(scrim.visibility == View.GONE) + } + } + } + + @Test + fun `disabling a modal scrim clears focus and pressed state without removing the view`() { + withActivity { activity -> + val host = configuredHost(activity) + activity.setContentView(host) + layout(host) + val scrim = scrim(host) + assertTrue(scrim.requestFocus()) + scrim.isPressed = true + + host.modal = false + + assertFalse(scrim.hasFocus()) + assertFalse(scrim.isPressed) + assertFalse(scrim.isFocusable) + assertFalse(scrim.isClickable) + assertEquals(View.INVISIBLE, scrim.visibility) + assertEquals(2, host.childCount) + } + } + + private fun configuredHost( + activity: Activity, + listener: RecordingListener? = null, + modal: Boolean = true, + index: Int = 1, + openDetent: Double = OPEN_DETENT_DP.toDouble(), + openDetentKind: String = "points", + closedDetentProgrammatic: Boolean = false, + ) = + BottomSheetHostView(activity).apply { + this.listener = listener + animateIn = false + this.modal = modal + setDetents( + listOf( + mapOf( + "value" to 0.0, + "kind" to "points", + "programmatic" to closedDetentProgrammatic, + ), + mapOf("value" to openDetent, "kind" to openDetentKind, "programmatic" to false), + ) + ) + setIndex(index) + } + + private fun layout(host: ViewGroup) { + host.measure( + View.MeasureSpec.makeMeasureSpec(HOST_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HOST_HEIGHT, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, HOST_WIDTH, HOST_HEIGHT) + } + + private fun dispatchTap(host: View, y: Float) { + host.dispatchTouchEvent(motionEvent(MotionEvent.ACTION_DOWN, y)) + host.dispatchTouchEvent(motionEvent(MotionEvent.ACTION_UP, y)) + } + + private fun motionEvent(action: Int, y: Float): MotionEvent = + MotionEvent.obtain(1L, 2L, action, 100f, y, 0) + + private fun scrim(host: BottomSheetHostView): View = host.getChildAt(0) + + private fun sheetContainer(host: BottomSheetHostView): ViewGroup = host.getChildAt(1) as ViewGroup + + private inline fun withActivity(block: (Activity) -> Unit) { + Robolectric.buildActivity(Activity::class.java).setup().use { controller -> + val activity = controller.get() + try { + block(activity) + } finally { + (activity.findViewById(android.R.id.content) as? ViewGroup)?.let { content -> + (content.getChildAt(0) as? BottomSheetHostView)?.destroy() + } + } + } + } + + private data class HostState( + val modal: Boolean = true, + val index: Int = 1, + val openDetent: Double = OPEN_DETENT_DP.toDouble(), + val openDetentKind: String = "points", + val closedDetentProgrammatic: Boolean = false, + ) + + private class RecordingListener : BottomSheetViewListener { + val indexChanges = mutableListOf() + var closeRequestCount = 0 + + override fun onIndexChange(index: Int) { + indexChanges.add(index) + } + + override fun onSettle(index: Int) = Unit + + override fun onPositionChange(position: Double, index: Double) = Unit + + override fun onCloseRequest() { + closeRequestCount++ + } + } + + private companion object { + const val HOST_WIDTH = 600 + const val HOST_HEIGHT = 1000 + const val OPEN_DETENT_DP = 100 + } +} From 64ecdf76bd9084c1d56a16a7877b534b018333ca Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Fri, 18 Sep 2026 17:17:05 +0200 Subject: [PATCH 03/27] fix(android): route close requests to the visual top portal Portal close ownership previously followed registration order within each React root. That could route Back or Escape to a sheet below another active portal and allowed roots in the same host window to make conflicting ownership decisions. Add a window-scoped presentation coordinator that tracks active portals, resolves their current React roots and native hierarchy paths, and selects a unique top presentation from observable drawing order. Reconcile ownership before drawing so z-order changes take effect without requiring sheet state or layout updates. When visual order cannot be proven, keep registration order as a deterministic close fallback without treating it as visual top. Centralize presentation ownership so close-request routing and portal accessibility can consume the same Active and Top presentation decisions instead of deriving potentially conflicting owners independently. Move active-presentation tracking out of close-request code so presentation lifecycle and ordering are independent of handlers and input policy. Close routing now consumes the shared assignment while preserving closing-through-settle ownership, handlerless top blocking, synchronous owner transfer, and predictive Back and Escape capture semantics. Add coverage for multiple React roots in one window, independent windows, native drawing order, unknown order, hierarchy migration, stale entries, and observable Back and Escape routing. --- .../BottomSheetHostView.kt | 72 +-- .../reactnativebottomsheet/BottomSheetView.kt | 46 +- .../CloseRequestPresentationTracker.kt | 38 -- .../PortalCloseRequestController.kt | 96 ++-- .../PortalCloseRequestCoordinator.kt | 218 ++----- .../PortalCloseRequestRoutingContext.kt | 2 +- .../presentation/NativePortalOrderResolver.kt | 79 +++ .../presentation/PortalPresentationContext.kt | 49 ++ .../PortalPresentationController.kt | 76 +++ .../PortalPresentationCoordinator.kt | 153 +++++ .../PresentationLifecycleTracker.kt | 38 ++ .../BottomSheetViewCloseRequestTest.kt | 63 +- .../BottomSheetViewManagerCloseRequestTest.kt | 3 +- .../PortalCloseRequestControllerTest.kt | 15 +- .../PortalCloseRequestCoordinatorTest.kt | 538 +++++++----------- .../NativePortalOrderResolverTest.kt | 153 +++++ .../PortalPresentationContextTest.kt | 66 +++ .../PortalPresentationControllerTest.kt | 103 ++++ .../PortalPresentationCoordinatorTest.kt | 248 ++++++++ .../PresentationLifecycleTrackerTest.kt} | 47 +- 20 files changed, 1417 insertions(+), 686 deletions(-) delete mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTracker.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolver.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContext.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationController.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTracker.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolverTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContextTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinatorTest.kt rename android/src/test/java/com/swmansion/reactnativebottomsheet/{closerequest/CloseRequestPresentationTrackerTest.kt => presentation/PresentationLifecycleTrackerTest.kt} (54%) diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt index 8de3f68..dec463d 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt @@ -25,7 +25,7 @@ import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.RootView import com.facebook.react.uimanager.StateWrapper import com.facebook.react.views.view.ReactViewGroup -import com.swmansion.reactnativebottomsheet.closerequest.CloseRequestPresentationTracker +import com.swmansion.reactnativebottomsheet.presentation.PresentationLifecycleTracker import kotlin.math.abs import kotlin.math.roundToInt @@ -88,10 +88,10 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr var interactionListener: ((Boolean) -> Unit)? = null /** - * Invokes the callback immediately when assigned and whenever later host state can change close - * request qualification. + * Publishes neutral presentation and resolved-target changes immediately on assignment and + * whenever layout or animation changes them. */ - var closeRequestStateChangedListener: (() -> Unit)? = null + var presentationStateChangedListener: (() -> Unit)? = null set(value) { field = value value?.invoke() @@ -191,7 +191,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private var pendingInitialContentDetentPreDrawListener: ViewTreeObserver.OnPreDrawListener? = null private var pendingInitialContentDetentFrames = 0 private var hasPerformedHostLayoutSinceAttach = false - private val closeRequestPresentationTracker = CloseRequestPresentationTracker() + private val presentationLifecycleTracker = PresentationLifecycleTracker() private val contentHeightMarkerLayoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> @@ -319,7 +319,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr override fun onAttachedToWindow() { super.onAttachedToWindow() hasPerformedHostLayoutSinceAttach = false - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() // Native geometry (cap, frame) is derived from the window; recompute on // (re)attach — including the inline<->overlay reparent — and ask for a // fresh insets pass. Fabric may assign unchanged bounds before attaching @@ -351,7 +351,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr override fun onDetachedFromWindow() { removeCallbacks(ensureHostLayoutAfterAttach) hasPerformedHostLayoutSinceAttach = false - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() // Release the listener from the soon-to-be-replaced observer and clear our // references so a later re-attach registers on the new live observer. removePendingInitialContentDetentObserver() @@ -388,7 +388,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr pendingInitialContentDetentSnap = true sheetContainer.translationY = resolvedMaxDetentHeight(h) emitPosition() - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() observePendingInitialContentDetent() return } @@ -404,22 +404,22 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr emitPosition() snapToIndex(targetIndex, 0f, emitIndexChange = false, emitSettle = true) } else { - closeRequestPresentationTracker.onTransitionSettled() + presentationLifecycleTracker.onTransitionSettled() sheetContainer.translationY = translationY(targetIndex) emitPosition() - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() } return } if (activeAnimation != null || isPanning) { - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() return } - closeRequestPresentationTracker.onTransitionSettled() + presentationLifecycleTracker.onTransitionSettled() sheetContainer.translationY = translationY(targetIndex) updateShadowState(sheetContainer.translationY) - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() } private val currentSheetTop: Float @@ -480,7 +480,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (!hasLaidOut) { pendingIndex = newIndex targetIndex = newIndex - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() return } @@ -491,7 +491,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // already resolvable) or keep waiting for the content to measure. targetIndex = newIndex.coerceIn(0, detentSpecs.size - 1) if (!trySnapPendingInitialContentDetent()) { - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() observePendingInitialContentDetent() } return @@ -572,8 +572,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr activeDragDetentSpecs = null } if (hasLaidOut && isInvalidContentDetentTarget(targetIndex)) { - closeRequestPresentationTracker.onInvalidTarget() - notifyCloseRequestStateChanged() + presentationLifecycleTracker.onInvalidTarget() + notifyPresentationStateChanged() updateScrim() return } @@ -583,7 +583,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (trySnapPendingInitialContentDetent()) { return } - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() updateScrim() return } @@ -641,11 +641,11 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr val shouldAnimateHeight = shouldAnimateContentHeight(targetIndex) if (kotlin.math.abs(targetHeight - currentVisibleHeight) <= 0.5f) { // No meaningful change. - closeRequestPresentationTracker.onTransitionSettled() + presentationLifecycleTracker.onTransitionSettled() sheetContainer.translationY = targetTy emitPosition() } else if (!shouldAnimateHeight) { - closeRequestPresentationTracker.onTransitionSettled() + presentationLifecycleTracker.onTransitionSettled() sheetContainer.translationY = targetTy emitPosition() } else { @@ -670,7 +670,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr } requestLayout() - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() updateScrim() } @@ -805,7 +805,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private val isTargetingClosedDetent: Boolean get() = detentSpecs.getOrNull(targetIndex)?.height == 0f - private val isCloseRequestLayoutReady: Boolean + private val isLayoutReady: Boolean get() = hasPerformedHostLayoutSinceAttach && isAttachedToWindow && @@ -830,18 +830,18 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // Request emission follows the resolved target rather than the transient animated position. val isCloseRequestTargetResolvedAndOpen: Boolean - get() = isCloseRequestLayoutReady && isTargetDetentOpen + get() = isLayoutReady && isTargetDetentOpen - /** Keeps the modal input boundary through a visible animated close. */ - val isCloseRequestPresentationActive: Boolean + /** Keeps the Active presentation through a visible animated close. */ + val isPresentationActive: Boolean get() = - closeRequestPresentationTracker.isPresentationActive( + presentationLifecycleTracker.isPresentationActive( isTargetDetentOpen = isTargetDetentOpen, - isCloseRequestLayoutReady = isCloseRequestLayoutReady, + isLayoutReady = isLayoutReady, ) - private fun notifyCloseRequestStateChanged() { - closeRequestStateChangedListener?.invoke() + private fun notifyPresentationStateChanged() { + presentationStateChangedListener?.invoke() } private fun snapCandidateIndices(includeIndex: Int? = null): List { @@ -1076,8 +1076,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr hideScrim() } emitPosition() - closeRequestPresentationTracker.onTransitionSettled() - notifyCloseRequestStateChanged() + presentationLifecycleTracker.onTransitionSettled() + notifyPresentationStateChanged() updateInteractionState() if (emitSettle) listener?.onSettle(index) } @@ -1085,13 +1085,13 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr activeAnimation = spring val visibleHeight = resolvedMaxDetentHeight() - currentTy - closeRequestPresentationTracker.onAnimationStarted( + presentationLifecycleTracker.onAnimationStarted( isTargetOpen = !isTargetingClosedDetent, visibleHeight = visibleHeight, ) // Publish only after the replacement spring is assigned. Re-anchoring a close must not create // a transient pass-through window between canceling the old animation and starting the new. - notifyCloseRequestStateChanged() + notifyPresentationStateChanged() // Report the index change as soon as the snap is committed, not when it // finishes: targetIndex is already set, and a programmatic snap's start is // known to the caller. onSettle remains the signal for movement end. @@ -1732,8 +1732,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr detentSpecs = emptyList() targetIndex = 0 hasPerformedHostLayoutSinceAttach = false - closeRequestPresentationTracker.onHostDestroyed() - notifyCloseRequestStateChanged() + presentationLifecycleTracker.onHostDestroyed() + notifyPresentationStateChanged() pendingIndex = null hasLaidOut = false isPanning = false @@ -1751,7 +1751,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr sheetContainer.removeAllViews() stateWrapper = null lastShadowOffsetY = Float.NaN - closeRequestStateChangedListener = null + presentationStateChangedListener = null listener = null } diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt index f4d07d9..dd5626f 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt @@ -33,6 +33,7 @@ import com.swmansion.reactnativebottomsheet.closerequest.CloseRequestInputState import com.swmansion.reactnativebottomsheet.closerequest.OverlayCloseRequestController import com.swmansion.reactnativebottomsheet.closerequest.PortalCloseRequestController import com.swmansion.reactnativebottomsheet.closerequest.findActivity +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationController /** * Fabric-mounted bottom-sheet view. It is a thin coordinator around a single [BottomSheetHostView] @@ -74,11 +75,13 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven ) private val overlayCloseRequestController = OverlayCloseRequestController(emitCloseRequest = ::emitCloseRequest) + private val portalPresentationController = + PortalPresentationController(this, portalCloseRequestController::onPresentationChanged) init { pointerEvents = PointerEvents.BOX_NONE host.interactionListener = { interactive -> updateOverlayTouchability(interactive) } - host.closeRequestStateChangedListener = ::refreshCloseRequestControllers + host.presentationStateChangedListener = ::refreshPresentationRouting attachHostInline() // The overlay dialog's window is bound to the host activity, so we follow the // activity lifecycle: tear the window down before the activity is destroyed @@ -144,7 +147,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven get() = host.modal set(value) { host.modal = value - refreshCloseRequestControllers() + refreshPresentationRouting() } var scrollableExpandNegotiation: Int @@ -172,19 +175,20 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven fun setHasCloseRequestHandler(value: Boolean) { if (value == hasCloseRequestHandler) return hasCloseRequestHandler = value - refreshCloseRequestControllers() + refreshPresentationRouting() } fun setNativeOverlay(value: Boolean) { if (value == nativeOverlay) return nativeOverlay = value if (value) { + portalPresentationController.clear() portalCloseRequestController.clear() presentOverlay() } else { dismissOverlay() } - refreshCloseRequestControllers() + refreshPresentationRouting() } // MARK: - Inline vs overlay presentation @@ -198,21 +202,22 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven if (nativeOverlay && overlayDialog == null) { presentOverlay() } - refreshCloseRequestControllers() - portalCloseRequestController.scheduleRoutingContextSync() + refreshPresentationRouting() + portalPresentationController.scheduleHierarchySync() } override fun onDetachedFromWindow() { isViewAttached = false + portalPresentationController.clear() portalCloseRequestController.clear() overlayCloseRequestController.unbind() - refreshCloseRequestControllers() + refreshPresentationRouting() super.onDetachedFromWindow() } override fun onWindowFocusChanged(hasWindowFocus: Boolean) { super.onWindowFocusChanged(hasWindowFocus) - refreshCloseRequestControllers() + refreshPresentationRouting() } override fun dispatchKeyEvent(event: KeyEvent): Boolean { @@ -239,6 +244,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven if (host.parent === this) { host.layout(0, 0, right - left, bottom - top) } + portalPresentationController.syncHierarchy() } private fun attachHostInline() { @@ -290,7 +296,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven // so no interaction-listener transition is guaranteed after show(). Reapply the current // state after the final window configuration resets the dialog to its safe initial flags. overlayInteractive = host.isInteractive - refreshCloseRequestControllers() + refreshPresentationRouting() } catch (_: RuntimeException) { // Show failed (e.g. the activity went away mid-present). Dismiss so the // partially-created window can't leak, then fall back to inline. @@ -395,7 +401,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven private fun updateOverlayTouchability(interactive: Boolean) { if (interactive == overlayInteractive) return overlayInteractive = interactive - refreshCloseRequestControllers() + refreshPresentationRouting() } private fun Window.setOverlayWindowAlpha(interactive: Boolean) { @@ -411,20 +417,24 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven return true } - private fun refreshCloseRequestControllers() { + private fun refreshPresentationRouting() { val state = CloseRequestInputState( isAttached = isViewAttached, isLifecycleActive = isReactHostResumed, isModal = modal, hasCloseRequestHandler = hasCloseRequestHandler, - isPresentationActive = host.isCloseRequestPresentationActive, + isPresentationActive = host.isPresentationActive, isTargetResolvedAndOpen = host.isCloseRequestTargetResolvedAndOpen, ) portalCloseRequestController.update( state = state, usesPortalPresentation = modal && !nativeOverlay, ) + portalPresentationController.update( + isPortal = isViewAttached && modal && !nativeOverlay, + isActive = host.isPresentationActive, + ) overlayCloseRequestController.update( state = state, usesOverlayDialog = nativeOverlay, @@ -441,24 +451,25 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven if (nativeOverlay && overlayDialog == null) { presentOverlay() } - refreshCloseRequestControllers() - portalCloseRequestController.scheduleRoutingContextSync() + refreshPresentationRouting() + portalPresentationController.scheduleHierarchySync() } override fun onHostPause() { isReactHostResumed = false - refreshCloseRequestControllers() + refreshPresentationRouting() } override fun onHostDestroy() { isReactHostResumed = false + portalPresentationController.clear() portalCloseRequestController.clear() // Dismiss before the activity's window token is destroyed to avoid a leaked // window. `nativeOverlay` is left intact so `onHostResume` can restore it; // the host falls back to inline parenting in the meantime. if (overlayDialog != null) { dismissOverlay() - refreshCloseRequestControllers() + refreshPresentationRouting() } } @@ -468,11 +479,12 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven isViewAttached = false isReactHostResumed = false hasCloseRequestHandler = false + portalPresentationController.dispose() portalCloseRequestController.dispose() overlayCloseRequestController.dispose() themedReactContext?.removeLifecycleEventListener(this) host.interactionListener = null - host.closeRequestStateChangedListener = null + host.presentationStateChangedListener = null overlayDialog?.let { if (it.isShowing) it.dismiss() } overlayDialog = null overlayRoot = null diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTracker.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTracker.kt deleted file mode 100644 index 1d62c11..0000000 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTracker.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.swmansion.reactnativebottomsheet.closerequest - -private const val CLOSE_REQUEST_VISIBLE_EPSILON_PX = 0.5f - -/** - * Tracks whether a visibly closing sheet still owns Back/Escape input. - * - * Close request eligibility follows the target detent, but a visible sheet animating toward a - * closed target must retain input ownership until the transition settles. Centralizing that - * lifecycle here keeps layout and animation paths consistent. - */ -internal class CloseRequestPresentationTracker { - private var closingPresentationActive = false - - fun onAnimationStarted( - isTargetOpen: Boolean, - visibleHeight: Float, - ) { - closingPresentationActive = - !isTargetOpen && - (closingPresentationActive || visibleHeight > CLOSE_REQUEST_VISIBLE_EPSILON_PX) - } - - fun onTransitionSettled() = resetClosingPresentation() - - fun onInvalidTarget() = resetClosingPresentation() - - fun onHostDestroyed() = resetClosingPresentation() - - private fun resetClosingPresentation() { - closingPresentationActive = false - } - - fun isPresentationActive( - isTargetDetentOpen: Boolean, - isCloseRequestLayoutReady: Boolean, - ): Boolean = isCloseRequestLayoutReady && (isTargetDetentOpen || closingPresentationActive) -} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestController.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestController.kt index 6fd014c..66381c5 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestController.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestController.kt @@ -7,6 +7,9 @@ import androidx.activity.OnBackPressedDispatcher import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationAssignment +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationContext +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationCoordinator /** Owns close request routing for one portal [BottomSheetView]. */ internal class PortalCloseRequestController( @@ -19,7 +22,8 @@ internal class PortalCloseRequestController( private var disposed = false private var closeRequestRoutingContext: PortalCloseRequestRoutingContext? = null - private var closeRequestRegistration: PortalCloseRequestCoordinator.Registration? = null + private var presentationContext: PortalPresentationContext? = null + private var presentationAssignment = PortalPresentationAssignment.NONE private var assignedCloseRequestAction = CloseRequestInputAction.PASS_THROUGH private var hasBackCallbackPresentationEnded = false private var isReconcilingBackCallback = false @@ -44,8 +48,6 @@ internal class PortalCloseRequestController( dispatchEscape(event) } - private val syncRoutingContextRunnable = Runnable { syncRoutingContext() } - fun update( state: CloseRequestInputState, usesPortalPresentation: Boolean, @@ -56,23 +58,34 @@ internal class PortalCloseRequestController( syncRoutingContext() } - fun scheduleRoutingContextSync() { + fun onPresentationChanged( + context: PortalPresentationContext?, + assignment: PortalPresentationAssignment, + ) { if (disposed) return - view.removeCallbacks(syncRoutingContextRunnable) - view.post(syncRoutingContextRunnable) + if ( + context?.windowRoot !== presentationContext?.windowRoot || + assignment == PortalPresentationAssignment.NONE + ) { + presentationContext?.windowRoot?.let { PortalCloseRequestCoordinator.release(it, this) } + } + presentationContext = context + presentationAssignment = assignment + syncRoutingContext() } fun dispatchEscape(event: KeyEvent): Boolean { if (disposed || !usesPortalPresentation || !inputState.isModal || !inputState.isAttached) return false - val portalRoot = closeRequestRoutingContext?.rootView ?: return false - if (portalRoot !== view.rootView) return false + val context = presentationContext ?: return false + val portalRoot = context.windowRoot + if (context.windowRoot !== view.rootView) return false + PortalPresentationCoordinator.reconcile(portalRoot) return PortalCloseRequestCoordinator.dispatchEscape(portalRoot, event) } fun clear() { usesPortalPresentation = false - view.removeCallbacks(syncRoutingContextRunnable) clearRoutingContext() } @@ -80,7 +93,6 @@ internal class PortalCloseRequestController( if (disposed) return disposed = true usesPortalPresentation = false - view.removeCallbacks(syncRoutingContextRunnable) clearRoutingContext() } @@ -94,7 +106,7 @@ internal class PortalCloseRequestController( if ( disposed || assignedCloseRequestAction != CloseRequestInputAction.EMIT_CLOSE_REQUEST || - currentPortalState().actionIfRoutingOwner != CloseRequestInputAction.EMIT_CLOSE_REQUEST + currentPortalAction() != CloseRequestInputAction.EMIT_CLOSE_REQUEST ) { return false } @@ -105,7 +117,12 @@ internal class PortalCloseRequestController( if (disposed) return val resolvedRoutingContext = view - .takeIf { usesPortalPresentation && inputState.isAttached && inputState.isModal } + .takeIf { + usesPortalPresentation && + inputState.isAttached && + inputState.isModal && + presentationContext != null + } ?.resolvePortalCloseRequestRoutingContext(currentActivity()) val previousRoutingContext = closeRequestRoutingContext if (!routingContextsAreIdentical(resolvedRoutingContext, previousRoutingContext)) { @@ -113,26 +130,6 @@ internal class PortalCloseRequestController( previousRoutingContext?.lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver) closeRequestRoutingContext = resolvedRoutingContext resolvedRoutingContext?.lifecycleOwner?.lifecycle?.addObserver(lifecycleObserver) - - if (previousRoutingContext?.rootView !== resolvedRoutingContext?.rootView) { - closeRequestRegistration?.remove() - closeRequestRegistration = null - if (resolvedRoutingContext != null) { - closeRequestRegistration = - PortalCloseRequestCoordinator.register( - resolvedRoutingContext.rootView, - this, - currentPortalState(), - ) - } - } else if (resolvedRoutingContext != null && closeRequestRegistration == null) { - closeRequestRegistration = - PortalCloseRequestCoordinator.register( - resolvedRoutingContext.rootView, - this, - currentPortalState(), - ) - } } reconcileInputHandling() } @@ -140,11 +137,17 @@ internal class PortalCloseRequestController( private fun reconcileInputHandling() { if (disposed) return ensureEscapeListener() - closeRequestRegistration?.update(currentPortalState()) + presentationContext?.windowRoot?.let { windowRoot -> + if (presentationAssignment != PortalPresentationAssignment.NONE) { + PortalCloseRequestCoordinator.assign(windowRoot, this, currentPortalAction()) + } else { + PortalCloseRequestCoordinator.release(windowRoot, this) + } + } reconcileBackCallback() } - private fun currentPortalState(): PortalCloseRequestState { + private fun currentPortalAction(): CloseRequestInputAction { val currentRoutingContext = closeRequestRoutingContext val isLifecycleActive = currentRoutingContext @@ -158,24 +161,10 @@ internal class PortalCloseRequestController( usesPortalPresentation && inputState.isAttached && currentRoutingContext != null && - currentRoutingContext.rootView === view.rootView, + currentRoutingContext.windowRoot === view.rootView, isLifecycleActive = isLifecycleActive, ) - val isRoutingOwnerCandidate = - effectiveInputState.isAttached && - effectiveInputState.isModal && - effectiveInputState.isLifecycleActive && - effectiveInputState.isPresentationActive - - return PortalCloseRequestState( - isRoutingOwnerCandidate = isRoutingOwnerCandidate, - actionIfRoutingOwner = - if (isRoutingOwnerCandidate) { - resolveCloseRequestInputAction(effectiveInputState) - } else { - CloseRequestInputAction.PASS_THROUGH - }, - ) + return resolveCloseRequestInputAction(effectiveInputState) } /** @@ -279,8 +268,9 @@ internal class PortalCloseRequestController( removeInputHandlers() closeRequestRoutingContext?.lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver) closeRequestRoutingContext = null - closeRequestRegistration?.remove() - closeRequestRegistration = null + presentationContext?.windowRoot?.let { PortalCloseRequestCoordinator.release(it, this) } + presentationContext = null + presentationAssignment = PortalPresentationAssignment.NONE assignedCloseRequestAction = CloseRequestInputAction.PASS_THROUGH } finally { isReconcilingBackCallback = wasReconciling @@ -317,6 +307,6 @@ internal class PortalCloseRequestController( if (first == null || second == null) return first === second return first.dispatcherOwner === second.dispatcherOwner && first.lifecycleOwner === second.lifecycleOwner && - first.rootView === second.rootView + first.windowRoot === second.windowRoot } } diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinator.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinator.kt index 0967846..1cc68ee 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinator.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinator.kt @@ -5,202 +5,78 @@ import android.view.View import java.lang.ref.WeakReference import java.util.WeakHashMap -internal data class PortalCloseRequestState( - val isRoutingOwnerCandidate: Boolean, - val actionIfRoutingOwner: CloseRequestInputAction, -) { - init { - require(isRoutingOwnerCandidate || actionIfRoutingOwner == CloseRequestInputAction.PASS_THROUGH) - } -} - internal interface PortalCloseRequestParticipant { fun onAssignedActionChanged(action: CloseRequestInputAction) fun emitCloseRequestIfEligible(): Boolean } -/** - * Coordinates portal close request ownership across every provider mounted in one Android root. - * - * Membership order is structural: the newest registered portal is highest. State updates never move - * an entry. The highest current routing owner candidate blocks every lower portal, even when it has - * no `onCloseRequest` handler and therefore cannot emit a request itself. - */ +/** Applies input policy to the shared presentation assignment; owns no membership or ordering. */ internal object PortalCloseRequestCoordinator { - internal interface Registration { - fun update(state: PortalCloseRequestState) - - fun remove() - } - - private class Entry( - participant: PortalCloseRequestParticipant, - initialState: PortalCloseRequestState, - ) { - val participant = WeakReference(participant) - var state = initialState - var isRegistered = true - var assignedAction = CloseRequestInputAction.PASS_THROUGH - } - - private class RootState { - val entries = mutableListOf() + private class InputState { + var owner: WeakReference? = null + var action = CloseRequestInputAction.PASS_THROUGH val escapeDispatcher = EscapeCloseRequestDispatcher() - var routingOwner: Entry? = null - var capturedEscapeRoutingOwner: WeakReference? = null + var capturedOwner: WeakReference? = null } - private class RegistrationImpl( - private val entry: Entry, - root: View, - ) : Registration { - private val rootReference = WeakReference(root) + private val inputStatesByWindowRoot = WeakHashMap() - override fun update(state: PortalCloseRequestState) { - if (!entry.isRegistered || entry.state == state) return - entry.state = state - val currentRoot = rootReference.get() ?: return - val currentRootState = statesByRoot[currentRoot] ?: return - reconcileRoutingOwnership(currentRoot, currentRootState) + fun assign( + windowRoot: View, + owner: PortalCloseRequestParticipant, + action: CloseRequestInputAction, + ) { + val state = inputStatesByWindowRoot.getOrPut(windowRoot, ::InputState) + val previous = state.owner?.get() + if (previous !== owner) { + state.escapeDispatcher.degradeCapturedCloseRequest() + previous?.onAssignedActionChanged(CloseRequestInputAction.PASS_THROUGH) + state.owner = WeakReference(owner) } - - override fun remove() { - if (!entry.isRegistered) return - entry.isRegistered = false - - val currentRoot = rootReference.get() - val currentRootState = currentRoot?.let(statesByRoot::get) - currentRootState?.let(::degradeCapturedEscapeIfNeeded) - - if (entry.assignedAction != CloseRequestInputAction.PASS_THROUGH) { - entry.assignedAction = CloseRequestInputAction.PASS_THROUGH - entry.participant.get()?.onAssignedActionChanged(CloseRequestInputAction.PASS_THROUGH) - } - - if (currentRoot == null || currentRootState == null) return - currentRootState.entries.remove(entry) - reconcileRoutingOwnership(currentRoot, currentRootState) + if (action != CloseRequestInputAction.EMIT_CLOSE_REQUEST) { + state.escapeDispatcher.degradeCapturedCloseRequest() + } + if (previous !== owner || state.action != action) { + state.action = action + owner.onAssignedActionChanged(action) } } - private val statesByRoot = WeakHashMap() - - fun register( - root: View, - participant: PortalCloseRequestParticipant, - initialState: PortalCloseRequestState, - ): Registration { - statesByRoot[root]?.let { reconcileRoutingOwnership(root, it) } - val rootState = statesByRoot.getOrPut(root, ::RootState) - val entry = Entry(participant, initialState) - rootState.entries.add(entry) - reconcileRoutingOwnership(root, rootState) - return RegistrationImpl(entry, root) + fun release(windowRoot: View, owner: PortalCloseRequestParticipant) { + val state = inputStatesByWindowRoot[windowRoot] ?: return + if (state.owner?.get() !== owner) return + state.owner = null + state.action = CloseRequestInputAction.PASS_THROUGH + state.escapeDispatcher.degradeCapturedCloseRequest() + owner.onAssignedActionChanged(CloseRequestInputAction.PASS_THROUGH) + // Keep a captured Escape through transfers, so its terminal up cannot target a new owner. + if (!state.escapeDispatcher.hasCapturedPress) inputStatesByWindowRoot.remove(windowRoot) } - fun dispatchEscape(root: View, event: KeyEvent): Boolean { - val rootState = statesByRoot[root] ?: return false - reconcileRoutingOwnership(root, rootState) - if (statesByRoot[root] !== rootState) return false - - val routingOwnerAtDispatch = rootState.routingOwner + fun dispatchEscape(windowRoot: View, event: KeyEvent): Boolean { + val state = inputStatesByWindowRoot[windowRoot] ?: return false + val owner = state.owner?.get() + if (owner == null) state.escapeDispatcher.degradeCapturedCloseRequest() val handled = - rootState.escapeDispatcher.dispatch( + state.escapeDispatcher.dispatch( event = event, resolveInitialAction = { - rootState.capturedEscapeRoutingOwner = routingOwnerAtDispatch?.let(::WeakReference) - routingOwnerAtDispatch?.takeIf { it.isRegistered }?.state?.actionIfRoutingOwner - ?: CloseRequestInputAction.PASS_THROUGH + state.capturedOwner = owner?.let(::WeakReference) + if (owner != null) state.action else CloseRequestInputAction.PASS_THROUGH }, emitCloseRequestIfEligible = { - val capturedEscapeRoutingOwner = rootState.capturedEscapeRoutingOwner?.get() - if ( - capturedEscapeRoutingOwner != null && - capturedEscapeRoutingOwner.isRegistered && - rootState.routingOwner === capturedEscapeRoutingOwner && - capturedEscapeRoutingOwner.state.actionIfRoutingOwner == - CloseRequestInputAction.EMIT_CLOSE_REQUEST - ) { - capturedEscapeRoutingOwner.participant.get()?.emitCloseRequestIfEligible() == true - } else { - false - } + val captured = state.capturedOwner?.get() + captured != null && + captured === state.owner?.get() && + state.action == CloseRequestInputAction.EMIT_CLOSE_REQUEST && + captured.emitCloseRequestIfEligible() }, ) - - if (!rootState.escapeDispatcher.hasCapturedPress) { - rootState.capturedEscapeRoutingOwner = null + if (!state.escapeDispatcher.hasCapturedPress) { + state.capturedOwner = null + if (state.owner?.get() == null) inputStatesByWindowRoot.remove(windowRoot) } - return handled } - - private fun reconcileRoutingOwnership(root: View, rootState: RootState) { - removeStaleEntries(rootState) - if (rootState.entries.isEmpty()) { - clearRootState(root, rootState) - return - } - - val nextRoutingOwner = - rootState.entries.asReversed().firstOrNull { entry -> - entry.isRegistered && entry.state.isRoutingOwnerCandidate - } - rootState.routingOwner = nextRoutingOwner - degradeCapturedEscapeIfNeeded(rootState) - - // Put stale owners into pass-through before assigning the new routing owner action so two - // portal OnBackPressedCallbacks are never transiently eligible in the same root. - rootState.entries.forEach { entry -> - if ( - entry !== nextRoutingOwner && entry.assignedAction != CloseRequestInputAction.PASS_THROUGH - ) { - entry.assignedAction = CloseRequestInputAction.PASS_THROUGH - entry.participant.get()?.onAssignedActionChanged(CloseRequestInputAction.PASS_THROUGH) - } - } - - nextRoutingOwner?.let { entry -> - val nextAction = entry.state.actionIfRoutingOwner - if (entry.assignedAction != nextAction) { - entry.assignedAction = nextAction - entry.participant.get()?.onAssignedActionChanged(nextAction) - } - } - } - - private fun degradeCapturedEscapeIfNeeded(rootState: RootState) { - if (!rootState.escapeDispatcher.hasCapturedPress) return - val capturedEscapeRoutingOwner = rootState.capturedEscapeRoutingOwner?.get() - if ( - capturedEscapeRoutingOwner == null || - !capturedEscapeRoutingOwner.isRegistered || - rootState.routingOwner !== capturedEscapeRoutingOwner || - capturedEscapeRoutingOwner.state.actionIfRoutingOwner != - CloseRequestInputAction.EMIT_CLOSE_REQUEST - ) { - rootState.escapeDispatcher.degradeCapturedCloseRequest() - } - } - - private fun removeStaleEntries(rootState: RootState) { - rootState.entries.removeAll { entry -> - val isDead = !entry.isRegistered || entry.participant.get() == null - if (isDead) { - entry.isRegistered = false - entry.assignedAction = CloseRequestInputAction.PASS_THROUGH - } - isDead - } - } - - private fun clearRootState(root: View, rootState: RootState) { - rootState.escapeDispatcher.clear() - rootState.capturedEscapeRoutingOwner = null - rootState.routingOwner = null - if (statesByRoot[root] === rootState) { - statesByRoot.remove(root) - } - } } diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestRoutingContext.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestRoutingContext.kt index 5a0b1a2..b7bf5ff 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestRoutingContext.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestRoutingContext.kt @@ -14,7 +14,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner internal data class PortalCloseRequestRoutingContext( val dispatcherOwner: OnBackPressedDispatcherOwner?, val lifecycleOwner: LifecycleOwner?, - val rootView: View, + val windowRoot: View, ) /** diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolver.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolver.kt new file mode 100644 index 0000000..1a24932 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolver.kt @@ -0,0 +1,79 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.view.ViewGroup +import android.widget.FrameLayout +import com.facebook.react.ReactRootView +import com.facebook.react.runtime.ReactSurfaceView +import com.facebook.react.views.view.ReactViewGroup + +internal enum class NativePortalOrder { + ABOVE, + BELOW, + EQUAL, + UNKNOWN, +} + +/** + * Native drawing order is not always observable. Registration and accessibility traversal order are + * not evidence of which portal is visually Top. + */ +internal object NativePortalOrderResolver { + fun compare( + first: PortalPresentationContext, + second: PortalPresentationContext, + ): NativePortalOrder { + if ( + first.windowPath.last().resolvePortalPresentationContext() != first || + second.windowPath.last().resolvePortalPresentationContext() != second + ) + return NativePortalOrder.UNKNOWN + if (first.windowRoot !== second.windowRoot || first.windowToken !== second.windowToken) + return NativePortalOrder.UNKNOWN + if ( + (first.windowPath + second.windowPath).any { + it.animation?.hasEnded() == false || (it as? ViewGroup)?.layoutTransition?.isRunning == true + } + ) + return NativePortalOrder.UNKNOWN + val commonLength = first.windowPath.zip(second.windowPath).takeWhile { (a, b) -> a === b }.size + val pathsAreIdentical = + commonLength == first.windowPath.size && commonLength == second.windowPath.size + if (pathsAreIdentical) return NativePortalOrder.EQUAL + if (commonLength == first.windowPath.size) return NativePortalOrder.BELOW + if (commonLength == second.windowPath.size) return NativePortalOrder.ABOVE + val parent = + first.windowPath[commonLength - 1] as? ViewGroup ?: return NativePortalOrder.UNKNOWN + // Android's custom-order enable flag is protected, even where the one-argument + // getChildDrawingOrder hook is public. Restrict proof to known drawing contracts; + // arbitrary subclasses may override dispatchDraw or leave their custom hook disabled. + if ( + parent.javaClass != FrameLayout::class.java && + parent.javaClass != ReactViewGroup::class.java && + parent.javaClass != ReactRootView::class.java && + parent.javaClass != ReactSurfaceView::class.java + ) { + return NativePortalOrder.UNKNOWN + } + val firstZ = first.windowPath[commonLength].z + val secondZ = second.windowPath[commonLength].z + if (!firstZ.isFinite() || !secondZ.isFinite()) return NativePortalOrder.UNKNOWN + if (firstZ > secondZ) return NativePortalOrder.ABOVE + if (firstZ < secondZ) return NativePortalOrder.BELOW + val order = + (0 until parent.childCount).map { + // Older RN versions expose Paper zIndex here; Fabric supplies ordered native children. + if (parent is ReactViewGroup) parent.getZIndexMappedChildIndex(it) else it + } + if (order.toSet().size != parent.childCount || order.any { it !in 0 until parent.childCount }) { + return NativePortalOrder.UNKNOWN + } + return if ( + order.indexOf(parent.indexOfChild(first.windowPath[commonLength])) > + order.indexOf(parent.indexOfChild(second.windowPath[commonLength])) + ) { + NativePortalOrder.ABOVE + } else { + NativePortalOrder.BELOW + } + } +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContext.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContext.kt new file mode 100644 index 0000000..9168f6f --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContext.kt @@ -0,0 +1,49 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.os.IBinder +import android.view.View +import android.view.ViewGroup +import com.facebook.react.uimanager.RootViewUtil + +/** A current hierarchy snapshot, never retained by the shared registry. */ +internal data class PortalPresentationContext( + val reactRoot: ViewGroup, + val windowRoot: View, + val windowToken: IBinder, + val path: List, + val windowPath: List, +) + +/** The nearest RN event root is the boundary, including RN content hosted in a Dialog. */ +internal fun View.resolvePortalPresentationContext(): PortalPresentationContext? { + if (!isAttachedToWindow) return null + val token = windowToken ?: return null + // RootViewUtil asserts when a hierarchy without an RN root reaches ViewRootImpl. + val reactRoot = + try { + RootViewUtil.getRootView(this) as? ViewGroup + } catch (_: AssertionError) { + null + } ?: return null + if (!reactRoot.isAttachedToWindow || reactRoot.windowToken !== token) return null + val windowRoot = rootView + val ancestors = mutableListOf() + var current: View = this + while (true) { + ancestors.add(current) + if (current === windowRoot) break + val parent = current.parent as? ViewGroup ?: return null + if (parent.indexOfChild(current) < 0) return null + current = parent + } + val windowPath = ancestors.asReversed() + val rootIndex = windowPath.indexOf(reactRoot) + if (rootIndex < 0) return null + return PortalPresentationContext( + reactRoot, + windowRoot, + token, + windowPath.drop(rootIndex), + windowPath, + ) +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationController.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationController.kt new file mode 100644 index 0000000..8ac17d9 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationController.kt @@ -0,0 +1,76 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.view.View + +/** Owns one portal registration independently of its input and accessibility policies. */ +internal class PortalPresentationController( + private val view: View, + private val onAssignmentChanged: + (PortalPresentationContext?, PortalPresentationAssignment) -> Unit, +) { + private var isPortal = false + private var isActive = false + private var disposed = false + private var context: PortalPresentationContext? = null + private var registration: PortalPresentationCoordinator.Registration? = null + private var assignment = PortalPresentationAssignment.NONE + private val observer: (PortalPresentationAssignment) -> Unit = { + assignment = it + onAssignmentChanged(context, it) + } + private val syncHierarchyRunnable = Runnable { syncHierarchy() } + + fun update(isPortal: Boolean, isActive: Boolean) { + if (disposed) return + this.isPortal = isPortal + this.isActive = isActive + syncHierarchy() + } + + fun syncHierarchy() { + if (disposed) return + val resolved = view.takeIf { isPortal }?.resolvePortalPresentationContext() + if ( + resolved == null || + resolved.reactRoot !== context?.reactRoot || + resolved.windowRoot !== context?.windowRoot || + resolved.windowToken !== context?.windowToken + ) { + removeRegistration() + } + context = resolved + if (resolved != null) { + if (registration?.update(isActive) != true) { + registration = PortalPresentationCoordinator.register(view, isActive, observer) + } + } + onAssignmentChanged(context, assignment) + } + + fun scheduleHierarchySync() { + if (disposed) return + view.removeCallbacks(syncHierarchyRunnable) + view.post(syncHierarchyRunnable) + } + + fun clear() { + isPortal = false + view.removeCallbacks(syncHierarchyRunnable) + removeRegistration() + } + + fun dispose() { + if (disposed) return + clear() + disposed = true + } + + private fun removeRegistration() { + val previous = registration + registration = null + previous?.remove() + context = null + assignment = PortalPresentationAssignment.NONE + onAssignmentChanged(null, assignment) + } +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt new file mode 100644 index 0000000..8c10209 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt @@ -0,0 +1,153 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.view.View +import android.view.ViewTreeObserver +import androidx.annotation.UiThread +import java.lang.ref.WeakReference +import java.util.WeakHashMap + +internal enum class PortalPresentationAssignment { + NONE, + TOP, + CLOSE_FALLBACK, +} + +/** One neutral membership and native-order authority, with weak anchors, roots and observers. */ +@UiThread +internal object PortalPresentationCoordinator { + internal interface Registration { + /** False means the hierarchy or observer expired; the controller must resolve it again. */ + fun update(isActive: Boolean): Boolean + + fun remove() + } + + private class Entry( + anchor: View, + context: PortalPresentationContext, + var isActive: Boolean, + observer: (PortalPresentationAssignment) -> Unit, + ) { + val anchor = WeakReference(anchor) + val reactRoot = WeakReference(context.reactRoot) + val windowRoot = WeakReference(context.windowRoot) + val windowToken = WeakReference(context.windowToken) + val observer = WeakReference(observer) + var isRegistered = true + var assignment = PortalPresentationAssignment.NONE + + fun assign(value: PortalPresentationAssignment) { + if (assignment == value) return + assignment = value + observer.get()?.invoke(value) + } + } + + private class RegistrationImpl(private val entry: Entry, root: View) : Registration { + private val root = WeakReference(root) + + override fun update(isActive: Boolean): Boolean { + if (!entry.isRegistered) return false + entry.isActive = isActive + root.get()?.let(::reconcile) + return entry.isRegistered + } + + override fun remove() { + if (!entry.isRegistered) return + entry.isRegistered = false + entry.assign(PortalPresentationAssignment.NONE) + root.get()?.let(::reconcile) + } + } + + private class WindowState(root: View) : ViewTreeObserver.OnPreDrawListener { + val entries = mutableListOf() + private val root = WeakReference(root) + private var observer: WeakReference? = null + + fun observeDrawing(view: View) { + val current = view.viewTreeObserver + if (observer?.get() === current) return + stopObserving() + if (current.isAlive) { + current.addOnPreDrawListener(this) + observer = WeakReference(current) + } + } + + override fun onPreDraw(): Boolean { + // This runs before every draw while registrations exist, so keep reconciliation cheap. + // Z and native child order may change without layout, requiring this refresh before the + // new order is displayed; do not derive Active from drawing. + val view = root.get() + if (view != null) reconcile(view) else stopObserving() + return true + } + + fun stopObserving() { + observer?.get()?.takeIf { it.isAlive }?.removeOnPreDrawListener(this) + observer = null + } + } + + private val windows = WeakHashMap() + + fun register( + anchor: View, + isActive: Boolean, + observer: (PortalPresentationAssignment) -> Unit, + ): Registration? { + val context = anchor.resolvePortalPresentationContext() ?: return null + val entry = Entry(anchor, context, isActive, observer) + windows.getOrPut(context.windowRoot) { WindowState(context.windowRoot) }.entries.add(entry) + reconcile(context.windowRoot) + return RegistrationImpl(entry, context.windowRoot) + } + + fun reconcile(root: View) { + val window = windows[root] ?: return + val entries = window.entries + val contexts = mutableMapOf() + entries.toList().forEach { entry -> + val context = entry.anchor.get()?.resolvePortalPresentationContext() + if ( + !entry.isRegistered || + entry.observer.get() == null || + context == null || + context.reactRoot !== entry.reactRoot.get() || + context.windowRoot !== root || + context.windowRoot !== entry.windowRoot.get() || + context.windowToken !== entry.windowToken.get() + ) { + entry.isRegistered = false + entry.assign(PortalPresentationAssignment.NONE) + entries.remove(entry) + } else if (entry.isActive) { + contexts[entry] = context + } + } + if (entries.isEmpty()) { + window.stopObserving() + windows.remove(root) + return + } + window.observeDrawing(root) + val top = + contexts.keys.singleOrNull { candidate -> + contexts.all { (other, context) -> + candidate === other || + NativePortalOrderResolver.compare(contexts.getValue(candidate), context) == + NativePortalOrder.ABOVE + } + } + val closeOwner = top ?: entries.lastOrNull { it in contexts } + // Registration order is only a Close fallback, never evidence for Top. + // Withdraw the previous assignment synchronously before enabling its successor. + entries.filter { it !== closeOwner }.forEach { it.assign(PortalPresentationAssignment.NONE) } + closeOwner?.assign( + if (top != null) PortalPresentationAssignment.TOP + else PortalPresentationAssignment.CLOSE_FALLBACK + ) + } +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTracker.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTracker.kt new file mode 100644 index 0000000..be415e3 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTracker.kt @@ -0,0 +1,38 @@ +package com.swmansion.reactnativebottomsheet.presentation + +private const val PRESENTATION_VISIBLE_EPSILON_PX = 0.5f + +/** + * Tracks the neutral Active presentation through opening and closing. + * + * An open target is Active once layout is ready. A visible sheet animating toward a closed target + * remains Active until the transition settles. Centralizing that lifecycle here keeps layout and + * animation paths consistent. + */ +internal class PresentationLifecycleTracker { + private var closingPresentationActive = false + + fun onAnimationStarted( + isTargetOpen: Boolean, + visibleHeight: Float, + ) { + closingPresentationActive = + !isTargetOpen && + (closingPresentationActive || visibleHeight > PRESENTATION_VISIBLE_EPSILON_PX) + } + + fun onTransitionSettled() = resetClosingPresentation() + + fun onInvalidTarget() = resetClosingPresentation() + + fun onHostDestroyed() = resetClosingPresentation() + + private fun resetClosingPresentation() { + closingPresentationActive = false + } + + fun isPresentationActive( + isTargetDetentOpen: Boolean, + isLayoutReady: Boolean, + ): Boolean = isLayoutReady && (isTargetDetentOpen || closingPresentationActive) +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt index 13da8e9..ac9cc77 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt @@ -31,6 +31,7 @@ import com.facebook.react.uimanager.events.BatchEventDispatchedListener import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.EventDispatcherListener +import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -173,7 +174,7 @@ class BottomSheetViewCloseRequestTest { val listener = CountingBottomSheetListener() val sheet = configuredOpenSheet(activity, listener) container.addView(sheet) - activity.setContentView(container) + activity.setReactContentView(container) layoutPortal(sheet) owner.onBackPressedDispatcher.onBackPressed() assertEscape(activity::dispatchKeyEvent, expectedHandled = true) @@ -195,7 +196,7 @@ class BottomSheetViewCloseRequestTest { var navigationCount = 0 activity.onBackPressedDispatcher.addCallback(navigationCallback { navigationCount++ }) val root = FrameLayout(activity) - activity.setContentView(root) + activity.setReactContentView(root) val listener = CountingBottomSheetListener() val sheet = configuredOpenSheet(activity, listener) root.addView(sheet) @@ -215,22 +216,43 @@ class BottomSheetViewCloseRequestTest { } @Test - fun `most recently attached portal receives Back and Escape`() { + fun `visually highest portal receives Back and Escape regardless of registration order`() { withActivity { activity -> + var hostBackCount = 0 + activity.onBackPressedDispatcher.addCallback(navigationCallback { hostBackCount++ }) val root = FrameLayout(activity) val lowerListener = CountingBottomSheetListener() val upperListener = CountingBottomSheetListener() val lowerSheet = configuredOpenSheet(activity, lowerListener) val upperSheet = configuredOpenSheet(activity, upperListener) + lowerSheet.setDetents( + listOf( + mapOf("value" to 0.0, "kind" to "points", "programmatic" to true), + mapOf("value" to 300.0, "kind" to "points", "programmatic" to false), + ) + ) + lowerSheet.elevation = 10f root.addView(lowerSheet) root.addView(upperSheet) - activity.setContentView(root) + activity.setReactContentView(root) layoutPortal(lowerSheet) layoutPortal(upperSheet) activity.onBackPressedDispatcher.onBackPressed() assertEscape(activity::dispatchKeyEvent, expectedHandled = true) - assertEquals(0, lowerListener.closeRequestCount) - assertEquals(2, upperListener.closeRequestCount) + assertEquals(2, lowerListener.closeRequestCount) + assertEquals(0, upperListener.closeRequestCount) + lowerSheet.setDetents( + listOf(mapOf("value" to 300.0, "kind" to "points", "programmatic" to false)) + ) + activity.onBackPressedDispatcher.onBackPressed() + assertEscape(activity::dispatchKeyEvent, expectedHandled = true) + assertEquals(4, lowerListener.closeRequestCount) + assertEquals(0, upperListener.closeRequestCount) + lowerSheet.setHasCloseRequestHandler(false) + activity.onBackPressedDispatcher.onBackPressed() + assertEscape(activity::dispatchKeyEvent, expectedHandled = false) + assertEquals(1, hostBackCount) + assertEquals(0, upperListener.closeRequestCount) upperSheet.destroy() lowerSheet.destroy() } @@ -256,7 +278,7 @@ class BottomSheetViewCloseRequestTest { } root.addView(lowerSheet) root.addView(upperSheet) - activity.setContentView(root) + activity.setReactContentView(root) layoutPortal(lowerSheet) layoutPortal(upperSheet) upperSheet.setIndex(1) @@ -280,7 +302,7 @@ class BottomSheetViewCloseRequestTest { val upperSheet = configuredContentSheet(activity, upperListener, upperContent) root.addView(lowerSheet) root.addView(upperSheet) - activity.setContentView(root) + activity.setReactContentView(root) layoutView(root) lowerSheet.isFocusableInTouchMode = true assertTrue(lowerSheet.requestFocus()) @@ -311,8 +333,10 @@ class BottomSheetViewCloseRequestTest { } @Test - fun `inactive upper sheet passes requests lower and resumes ownership`() { + fun `paused input lifecycle does not change the visibly Top presentation`() { withActivity { activity -> + var hostBackCount = 0 + activity.onBackPressedDispatcher.addCallback(navigationCallback { hostBackCount++ }) val upperOwner = MutableTestDispatcherOwner().apply { resume() } val root = FrameLayout(activity) val upperContainer = FrameLayout(activity) @@ -325,19 +349,20 @@ class BottomSheetViewCloseRequestTest { root.addView(lowerSheet) upperContainer.addView(upperSheet) root.addView(upperContainer) - activity.setContentView(root) + activity.setReactContentView(root) layoutView(root) lowerSheet.isFocusableInTouchMode = true assertTrue(lowerSheet.requestFocus()) upperOwner.pause() activity.onBackPressedDispatcher.onBackPressed() - assertEscape(activity::dispatchKeyEvent, expectedHandled = true) - assertEquals(2, lowerListener.closeRequestCount) + assertEscape(activity::dispatchKeyEvent, expectedHandled = false) + assertEquals(1, hostBackCount) + assertEquals(0, lowerListener.closeRequestCount) assertEquals(0, upperListener.closeRequestCount) upperOwner.resumeFromPause() upperOwner.onBackPressedDispatcher.onBackPressed() assertEscape(activity::dispatchKeyEvent, expectedHandled = true) - assertEquals(2, lowerListener.closeRequestCount) + assertEquals(0, lowerListener.closeRequestCount) assertEquals(2, upperListener.closeRequestCount) upperSheet.destroy() lowerSheet.destroy() @@ -416,7 +441,7 @@ class BottomSheetViewCloseRequestTest { val listener = CountingBottomSheetListener() val sheet = configuredOpenSheet(themedContext, listener) sheet.eventDispatcher = NoOpEventDispatcher - activity.setContentView(sheet) + activity.setReactContentView(sheet) layoutPortal(sheet) sheet.onHostResume() sheet.setNativeOverlay(true) @@ -448,7 +473,7 @@ class BottomSheetViewCloseRequestTest { val listener = CountingBottomSheetListener() val sheet = configuredOpenSheet(themedContext, listener) sheet.eventDispatcher = NoOpEventDispatcher - activity.setContentView(sheet) + activity.setReactContentView(sheet) layoutPortal(sheet) sheet.setNativeOverlay(true) layoutPortal(sheet) @@ -506,7 +531,7 @@ class BottomSheetViewCloseRequestTest { hasCloseRequestHandler: Boolean = true, ) = configuredOpenSheet(activity, listener, hasCloseRequestHandler).also { - activity.setContentView(it) + activity.setReactContentView(it) layoutPortal(it) } @@ -528,7 +553,7 @@ class BottomSheetViewCloseRequestTest { sheet.setScrimOpacities(listOf(0f, 1f)) configure(sheet) sheet.eventDispatcher = NoOpEventDispatcher - activity.setContentView(sheet) + activity.setReactContentView(sheet) layoutPortal(sheet) sheet.setNativeOverlay(true) shadowOf(Looper.getMainLooper()).idle() @@ -539,6 +564,10 @@ class BottomSheetViewCloseRequestTest { return NativeOverlayTestFixture(sheet, dialog, reactContext) } + private fun Activity.setReactContentView(content: View) { + setContentView(TestReactRoot(this).apply { addView(content) }) + } + private fun layoutPortal(sheet: BottomSheetView) { layoutView(sheet) sheet.isFocusableInTouchMode = true diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManagerCloseRequestTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManagerCloseRequestTest.kt index 6bd306c..afafebf 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManagerCloseRequestTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManagerCloseRequestTest.kt @@ -22,6 +22,7 @@ import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.EventDispatcherListener import com.facebook.react.viewmanagers.BottomSheetViewManagerDelegate +import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -64,7 +65,7 @@ class BottomSheetViewManagerCloseRequestTest { ), ) manager.setIndex(sheet, 1) - activity.setContentView(sheet) + activity.setContentView(TestReactRoot(activity).apply { addView(sheet) }) layout(sheet) reactContext.onHostResume(activity) eventDispatcher.eventNames.clear() diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestControllerTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestControllerTest.kt index dd0bec6..0da9e84 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestControllerTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestControllerTest.kt @@ -8,6 +8,8 @@ import android.view.View import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationController +import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -28,7 +30,7 @@ class PortalCloseRequestControllerTest { var closeRequestCount = 0 activity.onBackPressedDispatcher.addCallback(countingCallback { fallbackCount++ }) val portal = View(activity).apply { isFocusableInTouchMode = true } - activity.setContentView(portal) + activity.setContentView(TestReactRoot(activity).apply { addView(portal) }) assertTrue(portal.requestFocus()) val controller = PortalCloseRequestController( @@ -40,17 +42,22 @@ class PortalCloseRequestControllerTest { }, ) + val presentation = PortalPresentationController(portal, controller::onPresentationChanged) controller.update(inputState(), usesPortalPresentation = true) + presentation.update(isPortal = true, isActive = true) activity.onBackPressedDispatcher.onBackPressed() assertEquals(1, closeRequestCount) + presentation.clear() controller.clear() activity.onBackPressedDispatcher.onBackPressed() assertEquals(1, fallbackCount) assertEquals(1, closeRequestCount) controller.update(inputState(), usesPortalPresentation = true) + presentation.update(isPortal = true, isActive = true) activity.onBackPressedDispatcher.onBackPressed() + presentation.dispose() controller.dispose() activity.onBackPressedDispatcher.onBackPressed() @@ -66,7 +73,7 @@ class PortalCloseRequestControllerTest { var closeRequestCount = 0 activity.onBackPressedDispatcher.addCallback(countingCallback { fallbackCount++ }) val portal = EscapeDispatchingView(activity).apply { isFocusableInTouchMode = true } - activity.setContentView(portal) + activity.setContentView(TestReactRoot(activity).apply { addView(portal) }) layoutView(portal) assertTrue(portal.requestFocus()) val controller = @@ -80,7 +87,9 @@ class PortalCloseRequestControllerTest { ) portal.dispatchEscape = controller::dispatchEscape + val presentation = PortalPresentationController(portal, controller::onPresentationChanged) controller.update(inputState(), usesPortalPresentation = true) + presentation.update(isPortal = true, isActive = true) activity.onBackPressedDispatcher.onBackPressed() assertEquals(1, closeRequestCount) assertEscape(activity::dispatchKeyEvent, expectedHandled = true) @@ -107,12 +116,14 @@ class PortalCloseRequestControllerTest { assertEquals(2, closeRequestCount) controller.update(inputState(), usesPortalPresentation = true) + presentation.update(isPortal = true, isActive = true) activity.onBackPressedDispatcher.onBackPressed() assertEquals(3, closeRequestCount) assertEscape(activity::dispatchKeyEvent, expectedHandled = true) assertEquals(2, fallbackCount) assertEquals(4, closeRequestCount) + presentation.dispose() controller.dispose() } } diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinatorTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinatorTest.kt index 6e63141..066ec0a 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinatorTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/PortalCloseRequestCoordinatorTest.kt @@ -1,12 +1,16 @@ package com.swmansion.reactnativebottomsheet.closerequest -import android.app.Activity import android.view.KeyEvent +import android.view.View import android.widget.FrameLayout +import androidx.activity.BackEventCompat +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationController +import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -17,387 +21,259 @@ import org.robolectric.annotation.Config @Config(sdk = [35]) class PortalCloseRequestCoordinatorTest { @Test - fun `newest routing owner candidate wins while non-candidates are skipped`() { - withActivity { activity -> - val root = FrameLayout(activity) - val lower = TestParticipant() - val unresolvedUpper = TestParticipant() - val zeroUpper = TestParticipant() - val lowerRegistration = register(root, lower, routingOwnerCandidate = true) - val unresolvedRegistration = register(root, unresolvedUpper, routingOwnerCandidate = false) - val zeroRegistration = register(root, zeroUpper, routingOwnerCandidate = false) - - try { - assertTrue(lower.isInputHandlingEnabled) - assertFalse(unresolvedUpper.isInputHandlingEnabled) - assertFalse(zeroUpper.isInputHandlingEnabled) - assertHandledEscape(root) - assertEquals(1, lower.requestCount) - - zeroRegistration.update(portalState(routingOwnerCandidate = true)) - assertFalse(lower.isInputHandlingEnabled) - assertTrue(zeroUpper.isInputHandlingEnabled) - assertHandledEscape(root, 100L) - assertEquals(1, lower.requestCount) - assertEquals(1, zeroUpper.requestCount) - - zeroRegistration.update(portalState(routingOwnerCandidate = false)) - assertTrue(lower.isInputHandlingEnabled) - assertHandledEscape(root, 110L) - assertEquals(2, lower.requestCount) - } finally { - zeroRegistration.remove() - unresolvedRegistration.remove() - lowerRegistration.remove() - } + fun `higher handlerless presentation blocks lower and handler updates keep visual ownership`() { + Fixture().use { fixture -> + val upper = fixture.portal().apply { view.elevation = 10f } + val lower = fixture.portal() + upper.update(portalInputState(hasHandler = false)) + fixture.back() + assertFalse(lower.escapeDown()) + assertFalse(lower.escapeUp()) + assertEquals(1, fixture.hostBackCount) + assertEquals(0, lower.requests) + upper.update(portalInputState()) + fixture.back() + assertTrue(lower.escapeDown()) + assertTrue(lower.escapeUp()) + assertEquals(2, upper.requests) + assertEquals(0, lower.requests) } } @Test - fun `routing owner without a handler blocks lower and handler toggles do not reorder it`() { - withActivity { activity -> - val root = FrameLayout(activity) - val lower = TestParticipant() - val upper = TestParticipant() - val lowerRegistration = register(root, lower, routingOwnerCandidate = true) - val upperRegistration = register(root, upper, routingOwnerCandidate = true, canEmit = false) - - try { - assertFalse(lower.isInputHandlingEnabled) - assertFalse(upper.isInputHandlingEnabled) - assertFalse(PortalCloseRequestCoordinator.dispatchEscape(root, escapeDown(120L))) - assertFalse(PortalCloseRequestCoordinator.dispatchEscape(root, escapeUp(120L))) - - upperRegistration.update(portalState(routingOwnerCandidate = true, canEmit = true)) - assertTrue(upper.isInputHandlingEnabled) - assertHandledEscape(root, 130L) - assertEquals(0, lower.requestCount) - assertEquals(1, upper.requestCount) - - upperRegistration.update(portalState(routingOwnerCandidate = true, canEmit = false)) - upperRegistration.update(portalState(routingOwnerCandidate = true, canEmit = true)) - assertTrue(upper.isInputHandlingEnabled) - assertFalse(lower.isInputHandlingEnabled) - } finally { - upperRegistration.remove() - lowerRegistration.remove() - } + fun `closing owner consumes until settle and then transfers to lower`() { + Fixture().use { fixture -> + val lower = fixture.portal() + val upper = fixture.portal() + upper.update(portalInputState(targetOpen = false)) + fixture.back() + assertTrue(lower.escapeDown()) + assertTrue(lower.escapeUp()) + assertEquals(0, upper.requests) + assertEquals(0, lower.requests) + assertEquals(0, fixture.hostBackCount) + + upper.update(portalInputState(active = false, targetOpen = false)) + fixture.back() + assertTrue(lower.escapeDown()) + assertTrue(lower.escapeUp()) + assertEquals(2, lower.requests) } } @Test - fun `consuming upper routing owner blocks lower without emitting`() { - withActivity { activity -> - val root = FrameLayout(activity) - val lower = TestParticipant() - val upper = TestParticipant() - val lowerRegistration = register(root, lower, routingOwnerCandidate = true) - val upperRegistration = - PortalCloseRequestCoordinator.register( - root, - upper, - portalState(routingOwnerCandidate = true, action = CloseRequestInputAction.CONSUME), - ) - - try { - assertFalse(lower.isInputHandlingEnabled) - assertEquals(CloseRequestInputAction.CONSUME, upper.action) - assertHandledEscape(root, 135L) - assertEquals(0, lower.requestCount) - assertEquals(0, upper.requestCount) - - upperRegistration.update(portalState(routingOwnerCandidate = false)) - assertTrue(lower.isInputHandlingEnabled) - assertHandledEscape(root, 136L) - assertEquals(1, lower.requestCount) - } finally { - upperRegistration.remove() - lowerRegistration.remove() - } + fun `opening Active presentation handles input before any settle`() { + Fixture().use { fixture -> + val portal = fixture.portal(portalInputState(active = false)) + fixture.back() + portal.update(portalInputState()) + fixture.back() + assertEquals(1, fixture.hostBackCount) + assertEquals(1, portal.requests) } } @Test - fun `same routing owner changes action without passing through`() { - withActivity { activity -> - val root = FrameLayout(activity) - val participant = TestParticipant() - val registration = register(root, participant, routingOwnerCandidate = true) - - try { - registration.update( - portalState( - routingOwnerCandidate = true, - action = CloseRequestInputAction.CONSUME, - ) - ) - registration.update( - portalState( - routingOwnerCandidate = true, - action = CloseRequestInputAction.EMIT_CLOSE_REQUEST, - ) - ) - - assertEquals( - listOf( - CloseRequestInputAction.EMIT_CLOSE_REQUEST, - CloseRequestInputAction.CONSUME, - CloseRequestInputAction.EMIT_CLOSE_REQUEST, - ), - participant.actionChanges, - ) - assertEquals(listOf(true, true, true), participant.inputHandlingChanges) - assertTrue(participant.isInputHandlingEnabled) - } finally { - registration.remove() - } + fun `unknown order uses stable shared Close fallback`() { + Fixture(unknownOrder = true).use { fixture -> + val first = fixture.portal() + val second = fixture.portal() + first.update(portalInputState(hasHandler = false)) + first.update(portalInputState()) + fixture.back() + assertTrue(first.escapeDown()) + assertTrue(first.escapeUp()) + assertEquals(0, first.requests) + assertEquals(2, second.requests) } } @Test - fun `inactive upper passes new requests lower and resume restores its position`() { - withActivity { activity -> - val root = FrameLayout(activity) - val lower = TestParticipant() - val upper = TestParticipant() - val lowerRegistration = register(root, lower, routingOwnerCandidate = true) - val upperRegistration = register(root, upper, routingOwnerCandidate = true) - - try { - upperRegistration.update(portalState(routingOwnerCandidate = false)) - assertTrue(lower.isInputHandlingEnabled) - assertHandledEscape(root, 140L) - assertEquals(1, lower.requestCount) - - upperRegistration.update(portalState(routingOwnerCandidate = true)) - assertTrue(upper.isInputHandlingEnabled) - assertFalse(lower.isInputHandlingEnabled) - assertHandledEscape(root, 150L) - assertEquals(1, upper.requestCount) - - upperRegistration.update(portalState(routingOwnerCandidate = false)) - assertTrue(lower.isInputHandlingEnabled) - } finally { - upperRegistration.remove() - lowerRegistration.remove() - } + fun `captured Escape cannot emit after ownership leaves and returns`() { + Fixture().use { fixture -> + val lower = fixture.portal() + val upper = fixture.portal() + assertTrue(lower.escapeDown()) + upper.update(portalInputState(active = false)) + upper.update(portalInputState()) + assertTrue(lower.escapeUp()) + assertEquals(0, lower.requests) + assertEquals(0, upper.requests) + assertTrue(lower.escapeDown()) + assertTrue(lower.escapeUp()) + assertEquals(1, upper.requests) } } @Test - fun `identical updates are no-ops and updates after remove are ignored`() { - withActivity { activity -> - val root = FrameLayout(activity) - val participant = TestParticipant() - val initialState = portalState(routingOwnerCandidate = true) - val registration = PortalCloseRequestCoordinator.register(root, participant, initialState) - - assertEquals(listOf(true), participant.inputHandlingChanges) - registration.update(initialState) - assertEquals(listOf(true), participant.inputHandlingChanges) - - registration.remove() - assertEquals(listOf(true, false), participant.inputHandlingChanges) - registration.update(initialState) - registration.remove() - assertEquals(listOf(true, false), participant.inputHandlingChanges) + fun `captured Escape cannot emit after handler loss and restoration`() { + Fixture().use { fixture -> + val portal = fixture.portal() + assertTrue(portal.escapeDown()) + portal.update(portalInputState(hasHandler = false)) + portal.update(portalInputState()) + assertTrue(portal.escapeUp()) + assertEquals(0, portal.requests) } } @Test - fun `initial action assignment is synchronous and pass-through is not redundantly reported`() { - withActivity { activity -> - val closeRequestRoot = FrameLayout(activity) - val closeRequestParticipant = TestParticipant() - val closeRequestRegistration = - register(closeRequestRoot, closeRequestParticipant, routingOwnerCandidate = true) - val passThroughRoot = FrameLayout(activity) - val passThroughParticipant = TestParticipant() - val passThroughRegistration = - register(passThroughRoot, passThroughParticipant, routingOwnerCandidate = false) - - try { - assertEquals(listOf(true), closeRequestParticipant.inputHandlingChanges) - assertTrue(closeRequestParticipant.isInputHandlingEnabled) - assertTrue(passThroughParticipant.inputHandlingChanges.isEmpty()) - assertFalse(passThroughParticipant.isInputHandlingEnabled) - } finally { - passThroughRegistration.remove() - closeRequestRegistration.remove() - } + fun `removing Escape owner consumes the captured up without retargeting`() { + Fixture().use { fixture -> + val lower = fixture.portal() + val upper = fixture.portal() + assertTrue(lower.escapeDown()) + upper.dispose() + assertTrue(lower.escapeUp()) + assertEquals(0, lower.requests) + fixture.back() + assertEquals(1, lower.requests) } } @Test - fun `routing owner transitions disable before enabling and remove disables before promoting lower`() { - withActivity { activity -> - val root = FrameLayout(activity) - val transitions = mutableListOf() - val lower = TestParticipant("lower", transitions) - val upper = TestParticipant("upper", transitions) - val lowerRegistration = register(root, lower, routingOwnerCandidate = true) - val upperRegistration = register(root, upper, routingOwnerCandidate = false) - - try { - transitions.clear() - upperRegistration.update(portalState(routingOwnerCandidate = true)) - assertEquals(listOf("lower:false", "upper:true"), transitions) - - transitions.clear() - upperRegistration.remove() - assertEquals(listOf("upper:false", "lower:true"), transitions) - } finally { - upperRegistration.remove() - lowerRegistration.remove() - } + fun `predictive Back remains captured when a higher presentation appears`() { + Fixture().use { fixture -> + val lower = fixture.portal() + fixture.activity.onBackPressedDispatcher.dispatchOnBackStarted( + BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT) + ) + val upper = fixture.portal() + fixture.back() + assertEquals(0, lower.requests) + assertEquals(0, upper.requests) + assertEquals(0, fixture.hostBackCount) + fixture.back() + assertEquals(1, upper.requests) } } @Test - fun `roots are isolated and migration creates a new membership position`() { - withActivity { activity -> - val firstRoot = FrameLayout(activity) - val secondRoot = FrameLayout(activity) - val firstLower = TestParticipant() - val migrating = TestParticipant() - val isolated = TestParticipant() - val firstRegistration = register(firstRoot, firstLower, routingOwnerCandidate = true) - var migratingRegistration = register(firstRoot, migrating, routingOwnerCandidate = true) - val isolatedRegistration = register(secondRoot, isolated, routingOwnerCandidate = true) - - try { - assertTrue(migrating.isInputHandlingEnabled) - assertTrue(isolated.isInputHandlingEnabled) - - migratingRegistration.remove() - assertTrue(firstLower.isInputHandlingEnabled) - migratingRegistration = register(secondRoot, migrating, routingOwnerCandidate = true) - assertTrue(migrating.isInputHandlingEnabled) - assertFalse(isolated.isInputHandlingEnabled) - assertTrue(firstLower.isInputHandlingEnabled) - assertEquals(listOf(true, false, true), migrating.inputHandlingChanges) - } finally { - migratingRegistration.remove() - isolatedRegistration.remove() - firstRegistration.remove() - } + fun `a rendered elevation change transfers Back without a sheet state or layout update`() { + Fixture().use { fixture -> + val lower = fixture.portal() + val upper = fixture.portal() + lower.view.elevation = 10f + lower.view.viewTreeObserver.dispatchOnPreDraw() + fixture.back() + assertEquals(1, lower.requests) + assertEquals(0, upper.requests) } } @Test - fun `routing ownership or emission loss is terminal for a captured Escape press`() { - withActivity { activity -> - val root = FrameLayout(activity) - val lower = TestParticipant() - val upper = TestParticipant() - val lowerRegistration = register(root, lower, routingOwnerCandidate = true) - val upperRegistration = register(root, upper, routingOwnerCandidate = true) - - try { - assertTrue(PortalCloseRequestCoordinator.dispatchEscape(root, escapeDown(170L))) - upperRegistration.update(portalState(routingOwnerCandidate = false)) - upperRegistration.update(portalState(routingOwnerCandidate = true)) - assertTrue(PortalCloseRequestCoordinator.dispatchEscape(root, escapeUp(170L))) - assertEquals(0, lower.requestCount) - assertEquals(0, upper.requestCount) - - assertTrue(PortalCloseRequestCoordinator.dispatchEscape(root, escapeDown(180L))) - upperRegistration.update(portalState(routingOwnerCandidate = true, canEmit = false)) - upperRegistration.update(portalState(routingOwnerCandidate = true, canEmit = true)) - assertTrue(PortalCloseRequestCoordinator.dispatchEscape(root, escapeUp(180L))) - assertEquals(0, upper.requestCount) - - assertHandledEscape(root, 190L) - assertEquals(1, upper.requestCount) - } finally { - upperRegistration.remove() - lowerRegistration.remove() - } + fun `raising a handlerless presentation before drawing releases Back to the host`() { + Fixture().use { fixture -> + val lower = fixture.portal(portalInputState(hasHandler = false)) + val upper = fixture.portal() + lower.view.translationZ = 10f + lower.view.viewTreeObserver.dispatchOnPreDraw() + fixture.back() + assertEquals(1, fixture.hostBackCount) + assertEquals(0, upper.requests) + assertEquals(0, lower.requests) + lower.view.translationZ = 0f + lower.view.viewTreeObserver.dispatchOnPreDraw() + fixture.back() + assertEquals(1, upper.requests) } } @Test - fun `state rejects emission without routing owner candidacy`() { - assertThrows(IllegalArgumentException::class.java) { - PortalCloseRequestState( - isRoutingOwnerCandidate = false, - actionIfRoutingOwner = CloseRequestInputAction.EMIT_CLOSE_REQUEST, + fun `visual transfer and return cannot reactivate predictive emission`() { + Fixture().use { fixture -> + val lower = fixture.portal() + val upper = fixture.portal() + fixture.activity.onBackPressedDispatcher.dispatchOnBackStarted( + BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT) ) + lower.view.elevation = 10f + lower.view.viewTreeObserver.dispatchOnPreDraw() + lower.view.elevation = 0f + lower.view.viewTreeObserver.dispatchOnPreDraw() + fixture.back() + assertEquals(0, lower.requests) + assertEquals(0, upper.requests) + fixture.back() + assertEquals(1, upper.requests) } } - private fun register( - root: FrameLayout, - participant: TestParticipant, - routingOwnerCandidate: Boolean, - canEmit: Boolean = routingOwnerCandidate, - ): PortalCloseRequestCoordinator.Registration = - PortalCloseRequestCoordinator.register( - root, - participant, - portalState(routingOwnerCandidate, canEmit), - ) + private class Fixture(unknownOrder: Boolean = false) : AutoCloseable { + private val activityController = + Robolectric.buildActivity(ComponentActivity::class.java).setup() + val activity = activityController.get() + private val root = TestReactRoot(activity) + val parent = if (unknownOrder) root else FrameLayout(activity).also(root::addView) + private val portals = mutableListOf() + var hostBackCount = 0 + + init { + activity.setContentView(root) + activity.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + hostBackCount++ + } + } + ) + } - private fun portalState( - routingOwnerCandidate: Boolean, - canEmit: Boolean = routingOwnerCandidate, - action: CloseRequestInputAction? = null, - ) = - PortalCloseRequestState( - isRoutingOwnerCandidate = routingOwnerCandidate, - actionIfRoutingOwner = - action - ?: if (routingOwnerCandidate && canEmit) { - CloseRequestInputAction.EMIT_CLOSE_REQUEST - } else { - CloseRequestInputAction.PASS_THROUGH - }, - ) + fun portal(state: CloseRequestInputState = portalInputState()): Portal = + Portal(this).also { + portals.add(it) + it.update(state) + } - private fun assertHandledEscape(root: FrameLayout, downTime: Long = 90L) { - assertTrue(PortalCloseRequestCoordinator.dispatchEscape(root, escapeDown(downTime))) - assertTrue(PortalCloseRequestCoordinator.dispatchEscape(root, escapeUp(downTime))) - } + fun back() = activity.onBackPressedDispatcher.onBackPressed() - private fun escapeDown(downTime: Long) = - KeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE, 0) + override fun close() { + portals.asReversed().forEach { it.dispose() } + activityController.close() + } + } - private fun escapeUp(downTime: Long) = - KeyEvent(downTime, downTime + 1, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ESCAPE, 0) + private class Portal(fixture: Fixture) { + val view = View(fixture.activity).also(fixture.parent::addView) + var requests = 0 + private val close = + PortalCloseRequestController(view, { fixture.activity }) { + requests++ + true + } + val presentation = PortalPresentationController(view, close::onPresentationChanged) - private fun withActivity(block: (Activity) -> Unit) { - val controller = Robolectric.buildActivity(Activity::class.java).setup() - try { - block(controller.get()) - } finally { - controller.close() + fun update(state: CloseRequestInputState) { + close.update(state, usesPortalPresentation = true) + presentation.update( + isPortal = state.isAttached && state.isModal, + isActive = state.isPresentationActive, + ) } - } -} -private class TestParticipant( - private val name: String? = null, - private val sharedTransitions: MutableList? = null, -) : PortalCloseRequestParticipant { - var action = CloseRequestInputAction.PASS_THROUGH - var isInputHandlingEnabled = false - var locallyEligible = true - var requestCount = 0 - val actionChanges = mutableListOf() - val inputHandlingChanges = mutableListOf() + fun escapeDown() = + close.dispatchEscape(KeyEvent(10, 10, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE, 0)) - override fun onAssignedActionChanged(action: CloseRequestInputAction) { - this.action = action - actionChanges.add(action) - val enabled = action != CloseRequestInputAction.PASS_THROUGH - isInputHandlingEnabled = enabled - inputHandlingChanges.add(enabled) - if (name != null) sharedTransitions?.add("$name:$enabled") - } + fun escapeUp() = + close.dispatchEscape(KeyEvent(10, 11, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ESCAPE, 0)) - override fun emitCloseRequestIfEligible(): Boolean { - if (action != CloseRequestInputAction.EMIT_CLOSE_REQUEST || !locallyEligible) return false - requestCount++ - return true + fun dispose() { + presentation.dispose() + close.dispose() + } } } + +private fun portalInputState( + hasHandler: Boolean = true, + active: Boolean = true, + targetOpen: Boolean = true, +) = + CloseRequestInputState( + isAttached = true, + isLifecycleActive = true, + isModal = true, + hasCloseRequestHandler = hasHandler, + isPresentationActive = active, + isTargetResolvedAndOpen = targetOpen, + ) diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolverTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolverTest.kt new file mode 100644 index 0000000..de78aa5 --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/NativePortalOrderResolverTest.kt @@ -0,0 +1,153 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.app.Activity +import android.view.View +import android.view.animation.TranslateAnimation +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.ReactRootView +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.views.view.ReactViewGroup +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [24, 35]) +class NativePortalOrderResolverTest { + @Test + fun `unobservable custom drawing and transitions cannot publish strict order`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = FrameLayout(activity.get()) + val first = View(activity.get()) + val second = View(activity.get()) + parent.addView(first) + parent.addView(second) + root.addView(parent) + activity.get().setContentView(root) + second.startAnimation(TranslateAnimation(0f, 10f, 0f, 0f).apply { duration = 1000 }) + assertEquals(NativePortalOrder.UNKNOWN, compare(second, first)) + second.clearAnimation() + assertEquals(NativePortalOrder.ABOVE, compare(second, first)) + + val custom = + object : FrameLayout(activity.get()) { + init { + isChildrenDrawingOrderEnabled = true + } + + override fun getChildDrawingOrder(childCount: Int, drawingPosition: Int) = + childCount - 1 - drawingPosition + } + parent.removeAllViews() + custom.addView(first) + custom.addView(second) + parent.addView(custom) + assertEquals(NativePortalOrder.UNKNOWN, compare(second, first)) + } finally { + activity.close() + } + } + + @Test + fun `ordinary React root exposes its native drawing order`() { + ReactNativeFeatureFlagsForTests.setUp() + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = ReactRootView(activity.get()) + val first = View(activity.get()) + val second = View(activity.get()) + root.addView(first) + root.addView(second) + activity.get().setContentView(root) + assertEquals(NativePortalOrder.ABOVE, compare(second, first)) + } finally { + activity.close() + } + } + + @Test + fun `divergent branches use the drawing order at their common ancestor`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = FrameLayout(activity.get()) + val lowerBranch = FrameLayout(activity.get()) + val upperBranch = FrameLayout(activity.get()) + val lower = View(activity.get()) + val upper = View(activity.get()) + lowerBranch.addView(lower) + upperBranch.addView(upper) + parent.addView(lowerBranch) + parent.addView(upperBranch) + root.addView(parent) + activity.get().setContentView(root) + + assertEquals(NativePortalOrder.ABOVE, compare(upper, lower)) + assertEquals(NativePortalOrder.BELOW, compare(lower, upper)) + parent.bringChildToFront(lowerBranch) + assertEquals(NativePortalOrder.ABOVE, compare(lower, upper)) + upperBranch.translationZ = 10f + lower.elevation = 100f + assertEquals(NativePortalOrder.ABOVE, compare(upper, lower)) + } finally { + activity.close() + } + } + + private fun compare(first: View, second: View) = + NativePortalOrderResolver.compare( + requireNotNull(first.resolvePortalPresentationContext()), + requireNotNull(second.resolvePortalPresentationContext()), + ) + + @Test + fun `React Native exposes its effective child order`() { + ReactNativeFeatureFlagsForTests.setUp() + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = ReactViewGroup(activity.get()) + val first = View(activity.get()) + val second = View(activity.get()) + parent.addView(first) + parent.addView(second) + root.addView(parent) + activity.get().setContentView(root) + assertEquals(NativePortalOrder.ABOVE, compare(second, first)) + } finally { + activity.close() + } + } + + @Test + fun `nested anchors are ordered but detached snapshots cannot prove order`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val outer = FrameLayout(activity.get()) + val inner = View(activity.get()) + outer.addView(inner) + root.addView(outer) + activity.get().setContentView(root) + assertEquals(NativePortalOrder.ABOVE, compare(inner, outer)) + assertEquals(NativePortalOrder.BELOW, compare(outer, inner)) + assertEquals(NativePortalOrder.EQUAL, compare(inner, inner)) + val stale = requireNotNull(inner.resolvePortalPresentationContext()) + outer.removeView(inner) + assertEquals( + NativePortalOrder.UNKNOWN, + NativePortalOrderResolver.compare( + stale, + requireNotNull(outer.resolvePortalPresentationContext()), + ), + ) + } finally { + activity.close() + } + } +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContextTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContextTest.kt new file mode 100644 index 0000000..469185b --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationContextTest.kt @@ -0,0 +1,66 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.app.Activity +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.uimanager.RootView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PortalPresentationContextTest { + @Test + fun `attached native content never falls back to the Activity decor`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val anchor = View(activity.get()) + activity.get().setContentView(anchor) + assertNull(anchor.resolvePortalPresentationContext()) + } finally { + activity.close() + } + } + + @Test + fun `nearest attached React root supplies the exact path independently of the window root`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val outer = TestReactRoot(activity.get()) + val inner = TestReactRoot(activity.get()) + val branch = FrameLayout(activity.get()) + val anchor = View(activity.get()) + branch.addView(anchor) + inner.addView(branch) + outer.addView(inner) + activity.get().setContentView(outer) + + val context = requireNotNull(anchor.resolvePortalPresentationContext()) + + assertSame(inner, context.reactRoot) + assertEquals(listOf(inner, branch, anchor), context.path) + assertSame(anchor.rootView, context.windowRoot) + assertSame(anchor.windowToken, context.windowToken) + branch.removeView(anchor) + assertNull(anchor.resolvePortalPresentationContext()) + } finally { + activity.close() + } + } +} + +internal class TestReactRoot(context: Context) : FrameLayout(context), RootView { + override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) = Unit + + override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) = Unit + + override fun handleException(t: Throwable) = throw t +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt new file mode 100644 index 0000000..c81ca10 --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt @@ -0,0 +1,103 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.app.Activity +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PortalPresentationControllerTest { + @Test + fun `hierarchy sync refreshes the path and withdraws old root before joining another`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val container = FrameLayout(activity.get()) + val firstRoot = TestReactRoot(activity.get()) + val secondRoot = TestReactRoot(activity.get()) + val branch = FrameLayout(activity.get()) + val anchor = View(activity.get()) + firstRoot.addView(anchor) + firstRoot.addView(branch) + container.addView(firstRoot) + container.addView(secondRoot) + activity.get().setContentView(container) + var current: PortalPresentationContext? = null + val assignments = mutableListOf>() + val controller = + PortalPresentationController(anchor) { context, assignment -> + current = context + assignments.add(context?.reactRoot to assignment) + } + controller.update(isPortal = true, isActive = true) + firstRoot.removeView(anchor) + branch.addView(anchor) + controller.syncHierarchy() + assertEquals(listOf(firstRoot, branch, anchor), current?.path) + + assignments.clear() + branch.removeView(anchor) + secondRoot.addView(anchor) + controller.scheduleHierarchySync() + shadowOf(Looper.getMainLooper()).idle() + assertSame(secondRoot, current?.reactRoot) + val oldRelease = assignments.indexOf(firstRoot to PortalPresentationAssignment.NONE) + val newClaim = assignments.indexOf(secondRoot to PortalPresentationAssignment.TOP) + assertTrue(oldRelease >= 0 && newClaim > oldRelease) + + controller.scheduleHierarchySync() + controller.clear() + shadowOf(Looper.getMainLooper()).idle() + assertNull(current) + controller.dispose() + } finally { + activity.close() + } + } + + @Test + fun `only an attached portal registers and clear and dispose withdraw synchronously`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val anchor = View(activity.get()) + var context: PortalPresentationContext? = null + var assignment = PortalPresentationAssignment.NONE + val controller = + PortalPresentationController(anchor) { resolved, selected -> + context = resolved + assignment = selected + } + controller.update(isPortal = true, isActive = true) + assertNull(context) + root.addView(anchor) + activity.get().setContentView(root) + controller.syncHierarchy() + assertEquals(PortalPresentationAssignment.TOP, assignment) + controller.update(isPortal = false, isActive = true) + assertNull(context) + assertEquals(PortalPresentationAssignment.NONE, assignment) + controller.update(isPortal = true, isActive = true) + controller.clear() + assertNull(context) + controller.update(isPortal = true, isActive = true) + controller.dispose() + controller.update(isPortal = true, isActive = true) + assertNull(context) + assertEquals(PortalPresentationAssignment.NONE, assignment) + } finally { + activity.close() + } + } +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinatorTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinatorTest.kt new file mode 100644 index 0000000..4cff397 --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinatorTest.kt @@ -0,0 +1,248 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.ref.WeakReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PortalPresentationCoordinatorTest { + @Test + fun `one host window has one Top while other windows keep independent ownership`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val otherActivity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val windowContent = FrameLayout(activity.get()) + val firstRoot = TestReactRoot(activity.get()) + val secondRoot = TestReactRoot(activity.get()) + val otherRoot = TestReactRoot(otherActivity.get()) + val first = View(activity.get()).also(firstRoot::addView) + val second = View(activity.get()).also(secondRoot::addView) + val other = View(otherActivity.get()).also(otherRoot::addView) + windowContent.addView(firstRoot) + windowContent.addView(secondRoot) + activity.get().setContentView(windowContent) + otherActivity.get().setContentView(otherRoot) + var firstAssignment = PortalPresentationAssignment.NONE + var secondAssignment = PortalPresentationAssignment.NONE + var otherAssignment = PortalPresentationAssignment.NONE + val firstObserver: (PortalPresentationAssignment) -> Unit = { firstAssignment = it } + val secondObserver: (PortalPresentationAssignment) -> Unit = { secondAssignment = it } + val otherObserver: (PortalPresentationAssignment) -> Unit = { otherAssignment = it } + val firstRegistration = + requireNotNull(PortalPresentationCoordinator.register(first, true, firstObserver)) + val secondRegistration = + requireNotNull(PortalPresentationCoordinator.register(second, true, secondObserver)) + val otherRegistration = + requireNotNull(PortalPresentationCoordinator.register(other, true, otherObserver)) + assertEquals(PortalPresentationAssignment.NONE, firstAssignment) + assertEquals(PortalPresentationAssignment.TOP, secondAssignment) + assertEquals(PortalPresentationAssignment.TOP, otherAssignment) + secondRegistration.remove() + assertEquals(PortalPresentationAssignment.TOP, firstAssignment) + assertEquals(PortalPresentationAssignment.TOP, otherAssignment) + firstRegistration.remove() + otherRegistration.remove() + } finally { + otherActivity.close() + activity.close() + } + } + + @Test + fun `expired observer and detached anchor release ownership on reconciliation`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = FrameLayout(activity.get()) + val lower = View(activity.get()) + val upper = View(activity.get()) + parent.addView(lower) + parent.addView(upper) + root.addView(parent) + activity.get().setContentView(root) + val assignments = mutableListOf() + val observer: (PortalPresentationAssignment) -> Unit = { assignments.add(it) } + val lowerRegistration = + requireNotNull(PortalPresentationCoordinator.register(lower, true, observer)) + val (expiredRegistration, weakObserver) = registerAbandonedObserver(upper) + repeat(20) { + if (weakObserver.get() != null) { + System.gc() + System.runFinalization() + } + } + assertNull("the shared coordinator must not retain the observer", weakObserver.get()) + root.viewTreeObserver.dispatchOnPreDraw() + assertEquals( + listOf( + PortalPresentationAssignment.TOP, + PortalPresentationAssignment.NONE, + PortalPresentationAssignment.TOP, + ), + assignments, + ) + assertFalse(expiredRegistration.update(true)) + parent.removeView(lower) + root.viewTreeObserver.dispatchOnPreDraw() + assertEquals(PortalPresentationAssignment.NONE, assignments.last()) + assertFalse(lowerRegistration.update(true)) + } finally { + activity.close() + } + } + + @Test + fun `drawing observation resumes after the last registration is removed`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = FrameLayout(activity.get()).also(root::addView) + val lower = View(activity.get()).also(parent::addView) + val upper = View(activity.get()).also(parent::addView) + activity.get().setContentView(root) + val changes = mutableListOf() + val lowerObserver: (PortalPresentationAssignment) -> Unit = { changes.add("lower:$it") } + val upperObserver: (PortalPresentationAssignment) -> Unit = { changes.add("upper:$it") } + var lowerRegistration = + requireNotNull(PortalPresentationCoordinator.register(lower, true, lowerObserver)) + lowerRegistration.remove() + changes.clear() + root.viewTreeObserver.dispatchOnPreDraw() + assertEquals(emptyList(), changes) + + lowerRegistration = + requireNotNull(PortalPresentationCoordinator.register(lower, true, lowerObserver)) + val upperRegistration = + requireNotNull(PortalPresentationCoordinator.register(upper, true, upperObserver)) + changes.clear() + lower.elevation = 10f + root.viewTreeObserver.dispatchOnPreDraw() + assertEquals(listOf("upper:NONE", "lower:TOP"), changes) + lowerRegistration.remove() + upperRegistration.remove() + changes.clear() + lower.elevation = 0f + root.viewTreeObserver.dispatchOnPreDraw() + assertEquals(emptyList(), changes) + } finally { + activity.close() + } + } + + @Test + fun `equal anchors publish no Top and only the newest registration gets Close fallback`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val anchor = View(activity.get()) + root.addView(anchor) + activity.get().setContentView(root) + var first = PortalPresentationAssignment.NONE + var second = PortalPresentationAssignment.NONE + val firstObserver: (PortalPresentationAssignment) -> Unit = { first = it } + val secondObserver: (PortalPresentationAssignment) -> Unit = { second = it } + val firstRegistration = + requireNotNull(PortalPresentationCoordinator.register(anchor, true, firstObserver)) + val secondRegistration = + requireNotNull(PortalPresentationCoordinator.register(anchor, true, secondObserver)) + assertEquals(PortalPresentationAssignment.NONE, first) + assertEquals(PortalPresentationAssignment.CLOSE_FALLBACK, second) + secondRegistration.remove() + assertEquals(PortalPresentationAssignment.TOP, first) + firstRegistration.remove() + firstRegistration.update(true) + assertEquals(PortalPresentationAssignment.NONE, first) + } finally { + activity.close() + } + } + + private fun registerAbandonedObserver( + anchor: View + ): Pair< + PortalPresentationCoordinator.Registration, + WeakReference<(PortalPresentationAssignment) -> Unit>, + > { + val assignments = mutableListOf() + val observer: (PortalPresentationAssignment) -> Unit = { assignments.add(it) } + return requireNotNull(PortalPresentationCoordinator.register(anchor, true, observer)) to + WeakReference(observer) + } + + @Test + fun `unknown order has only a Close fallback and updates keep registration position`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + // An arbitrary RootView implementation does not expose its drawing contract. + val root = TestReactRoot(activity.get()) + val first = View(activity.get()) + val second = View(activity.get()) + root.addView(first) + root.addView(second) + activity.get().setContentView(root) + val changes = mutableListOf() + val firstObserver: (PortalPresentationAssignment) -> Unit = { changes.add("first:$it") } + val secondObserver: (PortalPresentationAssignment) -> Unit = { changes.add("second:$it") } + var firstRegistration = + requireNotNull(PortalPresentationCoordinator.register(first, true, firstObserver)) + val secondRegistration = + requireNotNull(PortalPresentationCoordinator.register(second, true, secondObserver)) + assertEquals(listOf("first:TOP", "first:NONE", "second:CLOSE_FALLBACK"), changes) + changes.clear() + firstRegistration.update(false) + firstRegistration.update(true) + assertEquals(listOf("second:TOP", "second:CLOSE_FALLBACK"), changes) + + changes.clear() + firstRegistration.remove() + firstRegistration = + requireNotNull(PortalPresentationCoordinator.register(first, true, firstObserver)) + assertEquals(listOf("second:TOP", "second:NONE", "first:CLOSE_FALLBACK"), changes) + firstRegistration.remove() + secondRegistration.remove() + } finally { + activity.close() + } + } + + @Test + fun `visual Top owns the assignment and transfers before enabling its successor`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = FrameLayout(activity.get()) + val lower = View(activity.get()) + val upper = View(activity.get()) + parent.addView(lower) + parent.addView(upper) + root.addView(parent) + activity.get().setContentView(root) + val changes = mutableListOf() + val upperObserver: (PortalPresentationAssignment) -> Unit = { changes.add("upper:$it") } + val lowerObserver: (PortalPresentationAssignment) -> Unit = { changes.add("lower:$it") } + val upperRegistration = + requireNotNull(PortalPresentationCoordinator.register(upper, true, upperObserver)) + val lowerRegistration = + requireNotNull(PortalPresentationCoordinator.register(lower, true, lowerObserver)) + assertEquals(listOf("upper:TOP"), changes) + + changes.clear() + upperRegistration.update(false) + assertEquals(listOf("upper:NONE", "lower:TOP"), changes) + lowerRegistration.remove() + upperRegistration.remove() + } finally { + activity.close() + } + } +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTrackerTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTrackerTest.kt similarity index 54% rename from android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTrackerTest.kt rename to android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTrackerTest.kt index a64c4cf..930ecb4 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/closerequest/CloseRequestPresentationTrackerTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PresentationLifecycleTrackerTest.kt @@ -1,33 +1,42 @@ -package com.swmansion.reactnativebottomsheet.closerequest +package com.swmansion.reactnativebottomsheet.presentation import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class CloseRequestPresentationTrackerTest { +class PresentationLifecycleTrackerTest { @Test - fun `visible closing animation owns Back and Escape input`() { - val tracker = CloseRequestPresentationTracker() + fun `open target becomes Active as soon as layout is ready`() { + val tracker = PresentationLifecycleTracker() + + assertFalse(tracker.isPresentationActive(isTargetDetentOpen = true, isLayoutReady = false)) + assertTrue(tracker.isPresentationActive(isTargetDetentOpen = true, isLayoutReady = true)) + assertFalse(tracker.isPresentationActive(isTargetDetentOpen = false, isLayoutReady = true)) + } + + @Test + fun `visible closing animation stays Active while layout is ready`() { + val tracker = PresentationLifecycleTracker() tracker.onAnimationStarted(isTargetOpen = false, visibleHeight = 1f) assertTrue( tracker.isPresentationActive( isTargetDetentOpen = false, - isCloseRequestLayoutReady = true, + isLayoutReady = true, ) ) assertFalse( tracker.isPresentationActive( isTargetDetentOpen = false, - isCloseRequestLayoutReady = false, + isLayoutReady = false, ) ) } @Test - fun `reanchoring a closing animation retains ownership`() { - val tracker = CloseRequestPresentationTracker() + fun `reanchoring a closing animation retains Active state`() { + val tracker = PresentationLifecycleTracker() tracker.onAnimationStarted(isTargetOpen = false, visibleHeight = 120f) tracker.onAnimationStarted(isTargetOpen = false, visibleHeight = 0f) @@ -35,28 +44,28 @@ class CloseRequestPresentationTrackerTest { assertTrue( tracker.isPresentationActive( isTargetDetentOpen = false, - isCloseRequestLayoutReady = true, + isLayoutReady = true, ) ) } @Test - fun `closing animation beginning at zero does not claim input`() { - val tracker = CloseRequestPresentationTracker() + fun `closing animation beginning at zero does not become Active`() { + val tracker = PresentationLifecycleTracker() tracker.onAnimationStarted(isTargetOpen = false, visibleHeight = 0f) assertFalse( tracker.isPresentationActive( isTargetDetentOpen = false, - isCloseRequestLayoutReady = true, + isLayoutReady = true, ) ) } @Test - fun `retargeting to an open detent clears closing ownership`() { - val tracker = CloseRequestPresentationTracker() + fun `retargeting to an open detent clears closing Active state`() { + val tracker = PresentationLifecycleTracker() tracker.onAnimationStarted(isTargetOpen = false, visibleHeight = 120f) tracker.onAnimationStarted(isTargetOpen = true, visibleHeight = 80f) @@ -64,20 +73,20 @@ class CloseRequestPresentationTrackerTest { assertTrue( tracker.isPresentationActive( isTargetDetentOpen = true, - isCloseRequestLayoutReady = true, + isLayoutReady = true, ) ) assertFalse( tracker.isPresentationActive( isTargetDetentOpen = false, - isCloseRequestLayoutReady = true, + isLayoutReady = true, ) ) } @Test - fun `settled transition and terminal conditions reset ownership`() { - val tracker = CloseRequestPresentationTracker() + fun `settled transition and terminal conditions reset Active state`() { + val tracker = PresentationLifecycleTracker() listOf( tracker::onTransitionSettled, @@ -90,7 +99,7 @@ class CloseRequestPresentationTrackerTest { assertFalse( tracker.isPresentationActive( isTargetDetentOpen = false, - isCloseRequestLayoutReady = true, + isLayoutReady = true, ) ) } From a5cace72307808c7fe20e6d91e9a54c9d390554e Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Fri, 18 Sep 2026 17:19:22 +0200 Subject: [PATCH 04/27] test(android): cover dismissal from focused sheet content Verify that the sheet-owned accessibility action commits the closed detent without emitting a close request when focus is inside sheet content. --- ...antDismissAccessibilityInstrumentedTest.kt | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 android/src/androidTest/java/com/swmansion/reactnativebottomsheet/SheetDescendantDismissAccessibilityInstrumentedTest.kt diff --git a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/SheetDescendantDismissAccessibilityInstrumentedTest.kt b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/SheetDescendantDismissAccessibilityInstrumentedTest.kt new file mode 100644 index 0000000..4b3e071 --- /dev/null +++ b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/SheetDescendantDismissAccessibilityInstrumentedTest.kt @@ -0,0 +1,241 @@ +package com.swmansion.reactnativebottomsheet + +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.UiAutomation +import android.graphics.Rect +import android.os.SystemClock +import android.view.View +import android.view.accessibility.AccessibilityManager +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.Button +import androidx.activity.ComponentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import java.util.ArrayDeque +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SheetDescendantDismissAccessibilityInstrumentedTest { + @Before + fun useLocalReactNativeFeatureFlags() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @Test + fun focusedSheetDescendantCanDismissThroughItsAccessibilityAncestor() { + val indexChanges = CopyOnWriteArrayList() + val closeRequestCount = AtomicInteger() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val uiAutomation = instrumentation.uiAutomation + val originalUiAutomationFlags = uiAutomation.serviceInfo.flags + lateinit var host: BottomSheetHostView + + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + try { + scenario.onActivity { activity -> + host = + BottomSheetHostView(activity).apply { + listener = RecordingAccessibilityListener(indexChanges, closeRequestCount) + animateIn = false + modal = true + setDetents( + listOf( + mapOf("value" to 0.0, "kind" to "points", "programmatic" to false), + mapOf("value" to 300.0, "kind" to "points", "programmatic" to false), + ) + ) + setIndex(1) + } + val focusedChild = + Button(activity).apply { + contentDescription = FOCUSED_CHILD_DESCRIPTION + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + measure( + View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(96, View.MeasureSpec.EXACTLY), + ) + } + host.addSheetChild(focusedChild, 0) + activity.setContentView(host) + } + + instrumentation.waitForIdleSync() + uiAutomation.requestTouchExploration() + awaitTouchExplorationEnabled() + val child = awaitNodeWithContentDescription(FOCUSED_CHILD_DESCRIPTION) + assertTrue( + "UiAutomation must be able to place accessibility focus on the sheet descendant: " + + child.debugState(), + child.performAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS), + ) + + val focusedNode = awaitAccessibilityFocus(FOCUSED_CHILD_DESCRIPTION) + assertFalse( + "The focused descendant must not appear to own an action implemented only by its parent", + focusedNode.hasAction(AccessibilityNodeInfo.ACTION_DISMISS), + ) + assertFalse( + "Dispatching ACTION_DISMISS to the focused descendant itself must not be mistaken for " + + "TalkBack's ancestor lookup", + focusedNode.performAction(AccessibilityNodeInfo.ACTION_DISMISS), + ) + instrumentation.waitForIdleSync() + assertTrue(indexChanges.isEmpty()) + assertEquals(0, closeRequestCount.get()) + + val dismissOwner = focusedNode.firstAncestorWithAction(AccessibilityNodeInfo.ACTION_DISMISS) + assertNotNull( + "ACTION_DISMISS must be discoverable on an accessibility ancestor of focused content", + dismissOwner, + ) + assertTrue( + "The action owner must be important for accessibility, not visible only because of " + + "UiAutomation fetch flags", + dismissOwner!!.isImportantForAccessibility, + ) + assertTrue( + "Performing the discovered ancestor action must commit accessible dismissal", + dismissOwner.performAction(AccessibilityNodeInfo.ACTION_DISMISS), + ) + instrumentation.waitForIdleSync() + + assertEquals(listOf(0), indexChanges) + assertEquals(0, closeRequestCount.get()) + } finally { + uiAutomation.restoreFlags(originalUiAutomationFlags) + scenario.onActivity { host.destroy() } + } + } + } + + private fun UiAutomation.requestTouchExploration() { + val updatedServiceInfo = serviceInfo + updatedServiceInfo.flags = + updatedServiceInfo.flags or AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE + serviceInfo = updatedServiceInfo + } + + private fun UiAutomation.restoreFlags(flags: Int) { + val restoredServiceInfo = serviceInfo + restoredServiceInfo.flags = flags + serviceInfo = restoredServiceInfo + } + + private fun awaitTouchExplorationEnabled() { + val accessibilityManager = + InstrumentationRegistry.getInstrumentation() + .targetContext + .getSystemService(AccessibilityManager::class.java) + val deadline = SystemClock.uptimeMillis() + NODE_TIMEOUT_MS + while ( + !accessibilityManager.isTouchExplorationEnabled && SystemClock.uptimeMillis() < deadline + ) { + SystemClock.sleep(NODE_POLL_INTERVAL_MS) + } + assertTrue( + "UiAutomation did not enable touch exploration; serviceFlags=" + + InstrumentationRegistry.getInstrumentation().uiAutomation.serviceInfo.flags, + accessibilityManager.isTouchExplorationEnabled, + ) + } + + private fun awaitNodeWithContentDescription(description: String): AccessibilityNodeInfo { + return awaitNode("No accessibility node with content description '$description'") { root -> + findNode(root, description) + } + } + + private fun awaitAccessibilityFocus(description: String): AccessibilityNodeInfo { + return awaitNode("Accessibility focus did not move to '$description'") { root -> + root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)?.takeIf { + it.contentDescription?.toString() == description + } + } + } + + private fun awaitNode( + failureMessage: String, + find: (AccessibilityNodeInfo) -> AccessibilityNodeInfo?, + ): AccessibilityNodeInfo { + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + val deadline = SystemClock.uptimeMillis() + NODE_TIMEOUT_MS + do { + uiAutomation.rootInActiveWindow?.let { root -> + find(root)?.let { + return it + } + } + SystemClock.sleep(NODE_POLL_INTERVAL_MS) + } while (SystemClock.uptimeMillis() < deadline) + + fail(failureMessage) + throw AssertionError("unreachable") + } + + private fun findNode(root: AccessibilityNodeInfo, description: String): AccessibilityNodeInfo? { + val pending = ArrayDeque() + pending.add(root) + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + if (node.contentDescription?.toString() == description) return node + for (index in 0 until node.childCount) { + node.getChild(index)?.let(pending::addLast) + } + } + return null + } + + private fun AccessibilityNodeInfo.hasAction(actionId: Int): Boolean = actionList.any { + it.id == actionId + } + + private fun AccessibilityNodeInfo.debugState(): String { + val bounds = Rect().also(::getBoundsInScreen) + return "visible=$isVisibleToUser enabled=$isEnabled focusable=$isFocusable " + + "accessibilityFocused=$isAccessibilityFocused important=$isImportantForAccessibility " + + "bounds=$bounds actions=${actionList.map { it.id }}" + } + + private fun AccessibilityNodeInfo.firstAncestorWithAction(actionId: Int): AccessibilityNodeInfo? { + var ancestor = parent + while (ancestor != null) { + if (ancestor.hasAction(actionId)) return ancestor + ancestor = ancestor.parent + } + return null + } + + private class RecordingAccessibilityListener( + private val indexChanges: MutableList, + private val closeRequestCount: AtomicInteger, + ) : BottomSheetViewListener { + override fun onIndexChange(index: Int) { + indexChanges.add(index) + } + + override fun onSettle(index: Int) = Unit + + override fun onPositionChange(position: Double, index: Double) = Unit + + override fun onCloseRequest() { + closeRequestCount.incrementAndGet() + } + } + + private companion object { + const val FOCUSED_CHILD_DESCRIPTION = "Focused sheet action" + const val NODE_TIMEOUT_MS = 5_000L + const val NODE_POLL_INTERVAL_MS = 50L + } +} From d0b015547920314a789f2f89046f8307b69ba2db Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Fri, 18 Sep 2026 18:18:19 +0200 Subject: [PATCH 05/27] fix(android): isolate portal modal accessibility Mask branches outside coordinator-selected React root paths and restore application importance safely. Reconcile Fabric and Paper mount batches, preserve multiple retained paths, and cover the behavior with focused JVM tests. --- .../reactnativebottomsheet/BottomSheetView.kt | 3 + ...PortalAccessibilityIsolationCoordinator.kt | 131 +++++++ .../PortalAccessibilityReconciler.kt | 77 ++++ .../ReactNativeMountReconciliationAdapter.kt | 135 +++++++ .../PortalPresentationCoordinator.kt | 86 ++++- .../BottomSheetViewPortalAccessibilityTest.kt | 108 ++++++ ...alAccessibilityIsolationCoordinatorTest.kt | 145 ++++++++ .../PortalAccessibilityReconcilerTest.kt | 328 ++++++++++++++++++ ...actNativeMountReconciliationAdapterTest.kt | 144 ++++++++ ...rtalPresentationAccessibilityPolicyTest.kt | 86 +++++ 10 files changed, 1239 insertions(+), 4 deletions(-) create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinator.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconciler.kt create mode 100644 android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapter.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconcilerTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapterTest.kt create mode 100644 android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationAccessibilityPolicyTest.kt diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt index dd5626f..9eac76b 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt @@ -29,6 +29,7 @@ import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.reactnativebottomsheet.accessibility.PortalAccessibilityIsolationCoordinator import com.swmansion.reactnativebottomsheet.closerequest.CloseRequestInputState import com.swmansion.reactnativebottomsheet.closerequest.OverlayCloseRequestController import com.swmansion.reactnativebottomsheet.closerequest.PortalCloseRequestController @@ -77,6 +78,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven OverlayCloseRequestController(emitCloseRequest = ::emitCloseRequest) private val portalPresentationController = PortalPresentationController(this, portalCloseRequestController::onPresentationChanged) + private val portalAccessibilityIsolationLease = PortalAccessibilityIsolationCoordinator.acquire() init { pointerEvents = PointerEvents.BOX_NONE @@ -480,6 +482,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven isReactHostResumed = false hasCloseRequestHandler = false portalPresentationController.dispose() + portalAccessibilityIsolationLease.release() portalCloseRequestController.dispose() overlayCloseRequestController.dispose() themedReactContext?.removeLifecycleEventListener(this) diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinator.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinator.kt new file mode 100644 index 0000000..34a0f17 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinator.kt @@ -0,0 +1,131 @@ +package com.swmansion.reactnativebottomsheet.accessibility + +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationCoordinator +import com.swmansion.reactnativebottomsheet.presentation.PortalRetainedPresentation +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.IdentityHashMap +import java.util.WeakHashMap + +/** Owns root-scoped masking while one or more BottomSheetViews can publish portal policy. */ +@UiThread +internal object PortalAccessibilityIsolationCoordinator { + internal interface Lease { + fun release() + } + + private class RootState( + windowRoot: View, + root: ViewGroup, + ) { + private val windowRoot = WeakReference(windowRoot) + val reconciler = PortalAccessibilityReconciler(root) + private val root = WeakReference(root) + private var mountCallback: MountReconciliationCallback? = null + + fun belongsTo(windowRoot: View): Boolean = this.windowRoot.get() === windowRoot + + fun observeMounts() { + if (mountCallback != null) return + val root = root.get() ?: return + val windowRoot = windowRoot.get() ?: return + val callbacks = ReactNativeMountReconciliationAdapter.resolve(root) ?: return + val callback = MountReconciliationCallback(windowRoot, root) + callback.observation = + ReactNativeMountReconciliationAdapter.shared.observe(callbacks, callback) + mountCallback = callback + } + + fun release() { + mountCallback?.remove() + mountCallback = null + reconciler.reconcile(emptyList()) + } + } + + private class MountReconciliationCallback( + windowRoot: View, + root: ViewGroup, + ) : () -> Unit { + private val windowRoot = WeakReference(windowRoot) + private val root = WeakReference(root) + var observation: ReactNativeMountReconciliationAdapter.Observation? = null + + override fun invoke() { + val windowRoot = windowRoot.get() + if (windowRoot == null || root.get() == null) { + remove() + return + } + PortalPresentationCoordinator.reconcile( + windowRoot, + forceAccessibilityPolicyReconciliation = true, + ) + } + + fun remove() { + observation?.remove() + observation = null + } + } + + private val roots = WeakHashMap() + private var leaseCount = 0 + private var policyObservation: PortalPresentationCoordinator.PolicyObservation? = null + + fun acquire(): Lease { + leaseCount++ + if (leaseCount == 1) { + policyObservation = + PortalPresentationCoordinator.observeAccessibilityPolicy(::onPolicyChanged) + } + return object : Lease { + private var active = true + + override fun release() { + if (!active) return + active = false + leaseCount-- + if (leaseCount == 0) { + policyObservation?.remove() + policyObservation = null + roots.values.toList().forEach(RootState::release) + roots.clear() + } + } + } + } + + private fun onPolicyChanged( + windowRoot: View, + retainedPresentations: List, + ) { + val retainedByRoot = IdentityHashMap>() + retainedPresentations.forEach { retained -> + retainedByRoot.getOrPut(retained.reactRoot) { mutableListOf() }.add(retained.anchor) + } + val desiredRoots = identitySet() + retainedByRoot.forEach { (root, anchors) -> + val state = roots.getOrPut(root) { RootState(windowRoot, root) } + if (state.reconciler.reconcile(anchors)) { + state.observeMounts() + desiredRoots.add(root) + } else { + state.release() + roots.remove(root) + } + } + roots.entries + .filter { (root, state) -> state.belongsTo(windowRoot) && root !in desiredRoots } + .map { it.key } + .forEach { root -> + roots.remove(root)?.release() + } + } + + private fun identitySet(): MutableSet = + Collections.newSetFromMap(IdentityHashMap()) +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconciler.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconciler.kt new file mode 100644 index 0000000..6474b8f --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconciler.kt @@ -0,0 +1,77 @@ +package com.swmansion.reactnativebottomsheet.accessibility + +import android.view.View +import android.view.ViewGroup +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.IdentityHashMap + +/** Applies the accessibility path cut for one nearest React Native root. */ +internal class PortalAccessibilityReconciler(root: ViewGroup) { + private class MaskRecord(view: View, var restoreCandidate: Int) { + val view = WeakReference(view) + val coordinatorWrite = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + + private val root = WeakReference(root) + private val maskRecords = mutableListOf() + + fun reconcile(retainedAnchors: Collection): Boolean { + val root = root.get() + val retainedChildren = IdentityHashMap>() + retainedAnchors.forEach { anchor -> + val path = root?.let { pathFromRoot(anchor, it) } ?: return@forEach + path.zipWithNext().forEach { (parent, child) -> + val group = parent as? ViewGroup ?: return@forEach + retainedChildren.getOrPut(group) { identitySet() }.add(child) + } + } + val desiredMasks = identitySet() + + retainedChildren.forEach { (parent, retained) -> + for (index in 0 until parent.childCount) { + parent.getChildAt(index).takeIf { it !in retained }?.let(desiredMasks::add) + } + } + desiredMasks.forEach { view -> + val record = maskRecords.firstOrNull { it.view.get() === view } + if (record == null) { + // If the application already owns the same NO_HIDE_DESCENDANTS scalar, that write is + // indistinguishable from ours. The last distinguishable value remains the best restore + // candidate, as required by ADR-0002's explicit same-value limitation. + maskRecords.add(MaskRecord(view, view.importantForAccessibility)) + view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } else if (view.importantForAccessibility != record.coordinatorWrite) { + record.restoreCandidate = view.importantForAccessibility + view.importantForAccessibility = record.coordinatorWrite + } + } + val records = maskRecords.iterator() + while (records.hasNext()) { + val record = records.next() + val view = record.view.get() + if (view == null || view !in desiredMasks) { + if (view?.importantForAccessibility == record.coordinatorWrite) { + view.importantForAccessibility = record.restoreCandidate + } + records.remove() + } + } + return retainedChildren.isNotEmpty() + } + + private fun pathFromRoot(anchor: View, root: ViewGroup): List? { + val reversed = mutableListOf() + var current = anchor + while (true) { + reversed.add(current) + if (current === root) return reversed.asReversed() + val parent = current.parent as? ViewGroup ?: return null + if (parent.indexOfChild(current) < 0) return null + current = parent + } + } + + private fun identitySet(): MutableSet = + Collections.newSetFromMap(IdentityHashMap()) +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapter.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapter.kt new file mode 100644 index 0000000..91828a5 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapter.kt @@ -0,0 +1,135 @@ +@file:OptIn(com.facebook.react.common.annotations.UnstableReactNativeAPI::class) +@file:Suppress("DEPRECATION") + +package com.swmansion.reactnativebottomsheet.accessibility + +import android.os.Handler +import android.os.Looper +import android.view.ViewGroup +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.UIManagerListener +import com.facebook.react.uimanager.ReactRoot +import com.facebook.react.uimanager.UIBlock +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.uimanager.common.ViewUtil +import java.util.IdentityHashMap + +/** Small compatibility boundary around React Native's unstable UIManager listener surface. */ +internal interface ReactNativeMountCallbacks { + val identity: Any + val isFabric: Boolean + + fun addListener(listener: UIManagerListener) + + fun removeListener(listener: UIManagerListener) + + fun enqueueAfterPaperMount(block: () -> Unit) +} + +internal class ReactNativeMountReconciliationAdapter( + private val postToUiThread: (Runnable) -> Unit = { + Handler(Looper.getMainLooper()).post(it) + Unit + } +) { + internal interface Observation { + fun remove() + } + + private inner class ManagerState(val boundary: ReactNativeMountCallbacks) { + val callbacks = mutableListOf<() -> Unit>() + var reconciliationPosted = false + var active = true + val listener = + object : UIManagerListener { + override fun willDispatchViewUpdates(uiManager: UIManager) { + if (boundary.isFabric || !active) return + boundary.enqueueAfterPaperMount { + scheduleReconciliation() + } + } + + override fun willMountItems(uiManager: UIManager) = Unit + + override fun didMountItems(uiManager: UIManager) { + if (boundary.isFabric) scheduleReconciliation() + } + + override fun didDispatchMountItems(uiManager: UIManager) = Unit + + override fun didScheduleMountItems(uiManager: UIManager) = Unit + } + + fun scheduleReconciliation() { + if (!active || reconciliationPosted) return + reconciliationPosted = true + postToUiThread( + Runnable { + reconciliationPosted = false + if (active) callbacks.toList().forEach { it() } + } + ) + } + } + + private val managers = IdentityHashMap() + + fun observe( + boundary: ReactNativeMountCallbacks, + reconcile: () -> Unit, + ): Observation { + val state = + managers.getOrPut(boundary.identity) { + ManagerState(boundary).also { boundary.addListener(it.listener) } + } + state.callbacks.add(reconcile) + return object : Observation { + private var active = true + + override fun remove() { + if (!active) return + active = false + state.callbacks.remove(reconcile) + if (state.callbacks.isEmpty()) { + state.active = false + managers.remove(state.boundary.identity) + state.boundary.removeListener(state.listener) + } + } + } + } + + companion object { + val shared = ReactNativeMountReconciliationAdapter() + + fun resolve(root: ViewGroup): ReactNativeMountCallbacks? { + val reactContext = + runCatching { UIManagerHelper.getReactContext(root) }.getOrNull() ?: return null + val managerType = (root as? ReactRoot)?.getUIManagerType() ?: ViewUtil.getUIManagerType(root) + val manager = UIManagerHelper.getUIManager(reactContext, managerType) ?: return null + if (managerType != UIManagerType.FABRIC && manager !is UIManagerModule) return null + return UIManagerMountCallbacks(manager, managerType == UIManagerType.FABRIC) + } + } +} + +private class UIManagerMountCallbacks( + private val manager: UIManager, + override val isFabric: Boolean, +) : ReactNativeMountCallbacks { + override val identity: Any = manager + + override fun addListener(listener: UIManagerListener) { + manager.addUIManagerEventListener(listener) + } + + override fun removeListener(listener: UIManagerListener) { + manager.removeUIManagerEventListener(listener) + } + + override fun enqueueAfterPaperMount(block: () -> Unit) { + (manager as UIManagerModule).addUIBlock(UIBlock { block() }) + } +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt index 8c10209..0ccd80c 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationCoordinator.kt @@ -1,6 +1,7 @@ package com.swmansion.reactnativebottomsheet.presentation import android.view.View +import android.view.ViewGroup import android.view.ViewTreeObserver import androidx.annotation.UiThread import java.lang.ref.WeakReference @@ -12,6 +13,15 @@ internal enum class PortalPresentationAssignment { CLOSE_FALLBACK, } +internal data class PortalRetainedPresentation(val reactRoot: ViewGroup, val anchor: View) + +internal fun interface PortalAccessibilityPolicyObserver { + fun onPolicyChanged( + windowRoot: View, + retainedPresentations: List, + ) +} + /** One neutral membership and native-order authority, with weak anchors, roots and observers. */ @UiThread internal object PortalPresentationCoordinator { @@ -22,6 +32,10 @@ internal object PortalPresentationCoordinator { fun remove() } + internal interface PolicyObservation { + fun remove() + } + private class Entry( anchor: View, context: PortalPresentationContext, @@ -49,7 +63,7 @@ internal object PortalPresentationCoordinator { override fun update(isActive: Boolean): Boolean { if (!entry.isRegistered) return false entry.isActive = isActive - root.get()?.let(::reconcile) + root.get()?.let { reconcile(it, forceAccessibilityPolicyReconciliation = true) } return entry.isRegistered } @@ -57,12 +71,23 @@ internal object PortalPresentationCoordinator { if (!entry.isRegistered) return entry.isRegistered = false entry.assign(PortalPresentationAssignment.NONE) - root.get()?.let(::reconcile) + root.get()?.let { reconcile(it, forceAccessibilityPolicyReconciliation = true) } } } + private class AccessibilityPolicySnapshot(paths: List>) { + private val paths = paths.map { path -> path.map(::WeakReference) } + + fun matches(current: List>): Boolean = + paths.size == current.size && + paths.zip(current).all { (previous, next) -> + previous.size == next.size && previous.zip(next).all { (old, new) -> old.get() === new } + } + } + private class WindowState(root: View) : ViewTreeObserver.OnPreDrawListener { val entries = mutableListOf() + var accessibilityPolicySnapshot = AccessibilityPolicySnapshot(emptyList()) private val root = WeakReference(root) private var observer: WeakReference? = null @@ -92,6 +117,23 @@ internal object PortalPresentationCoordinator { } private val windows = WeakHashMap() + private val accessibilityPolicyObservers = mutableListOf() + + fun observeAccessibilityPolicy(observer: PortalAccessibilityPolicyObserver): PolicyObservation { + accessibilityPolicyObservers.add(observer) + windows.keys.toList().forEach { + reconcile(it, forceAccessibilityPolicyReconciliation = true) + } + return object : PolicyObservation { + private var observing = true + + override fun remove() { + if (!observing) return + observing = false + accessibilityPolicyObservers.remove(observer) + } + } + } fun register( anchor: View, @@ -101,11 +143,14 @@ internal object PortalPresentationCoordinator { val context = anchor.resolvePortalPresentationContext() ?: return null val entry = Entry(anchor, context, isActive, observer) windows.getOrPut(context.windowRoot) { WindowState(context.windowRoot) }.entries.add(entry) - reconcile(context.windowRoot) + reconcile(context.windowRoot, forceAccessibilityPolicyReconciliation = true) return RegistrationImpl(entry, context.windowRoot) } - fun reconcile(root: View) { + fun reconcile( + root: View, + forceAccessibilityPolicyReconciliation: Boolean = false, + ) { val window = windows[root] ?: return val entries = window.entries val contexts = mutableMapOf() @@ -128,6 +173,13 @@ internal object PortalPresentationCoordinator { } } if (entries.isEmpty()) { + publishAccessibilityPolicy( + root, + window, + emptyList(), + emptyList(), + forceAccessibilityPolicyReconciliation, + ) window.stopObserving() windows.remove(root) return @@ -149,5 +201,31 @@ internal object PortalPresentationCoordinator { if (top != null) PortalPresentationAssignment.TOP else PortalPresentationAssignment.CLOSE_FALLBACK ) + val retainedEntries = top?.let(::listOf) ?: contexts.keys.toList() + val retainedContexts = retainedEntries.mapNotNull(contexts::get) + publishAccessibilityPolicy( + root, + window, + retainedEntries.zip(retainedContexts).mapNotNull { (entry, context) -> + val anchor = entry.anchor.get() ?: return@mapNotNull null + PortalRetainedPresentation(context.reactRoot, anchor) + }, + retainedContexts.map(PortalPresentationContext::path), + forceAccessibilityPolicyReconciliation, + ) + } + + private fun publishAccessibilityPolicy( + root: View, + window: WindowState, + retainedPresentations: List, + retainedPaths: List>, + force: Boolean, + ) { + if (!force && window.accessibilityPolicySnapshot.matches(retainedPaths)) return + window.accessibilityPolicySnapshot = AccessibilityPolicySnapshot(retainedPaths) + accessibilityPolicyObservers.toList().forEach { + it.onPolicyChanged(root, retainedPresentations) + } } } diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt new file mode 100644 index 0000000..554c22b --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt @@ -0,0 +1,108 @@ +package com.swmansion.reactnativebottomsheet + +import android.app.Activity +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot +import java.util.Collections +import java.util.IdentityHashMap +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class BottomSheetViewPortalAccessibilityTest { + @Before + fun useLocalReactNativeFeatureFlags() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @Test + fun `Active portal excludes background while sheet content and real Dismiss remain reachable`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val sheet = BottomSheetView(activity.get()) + try { + val root = TestReactRoot(activity.get()) + val background = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Application control" + } + val portalWrapper = FrameLayout(activity.get()) + val sheetContent = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Sheet content" + } + sheet.apply { + animateIn = false + modal = true + setDetents( + listOf( + mapOf("value" to 0.0, "kind" to "points", "programmatic" to false), + mapOf("value" to 300.0, "kind" to "points", "programmatic" to false), + ) + ) + setIndex(1) + addSheetChild(sheetContent, 0) + } + portalWrapper.addView(sheet, matchParent()) + root.addView(background, matchParent()) + root.addView(portalWrapper, matchParent()) + activity.get().setContentView(root) + layout(root) + val host = sheet.getChildAt(0) as ViewGroup + val dismiss = host.getChildAt(0) + + val activeTree = accessibleTree(root) + assertFalse(activeTree.contains(background)) + assertTrue(activeTree.contains(sheetContent)) + assertTrue(activeTree.contains(dismiss)) + + sheet.modal = false + + assertTrue(accessibleTree(root).contains(background)) + } finally { + sheet.destroy() + activity.close() + } + } + + private fun accessibleTree(root: ViewGroup): Set { + val result = Collections.newSetFromMap(IdentityHashMap()) + fun visit(parent: ViewGroup) { + val children = arrayListOf() + parent.addChildrenForAccessibility(children) + children.forEach { child -> + if (result.add(child) && child is ViewGroup) visit(child) + } + } + visit(root) + return result + } + + private fun layout(view: View) { + view.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + view.layout(0, 0, 1080, 1920) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun matchParent() = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt new file mode 100644 index 0000000..1dff66d --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt @@ -0,0 +1,145 @@ +package com.swmansion.reactnativebottomsheet.accessibility + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.reactnativebottomsheet.presentation.PortalPresentationCoordinator +import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot +import java.lang.ref.WeakReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PortalAccessibilityIsolationCoordinatorTest { + @Test + fun `shared presentation policy isolates only the owner React root and restores on release`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lease = PortalAccessibilityIsolationCoordinator.acquire() + try { + val window = FrameLayout(activity.get()) + val root = TestReactRoot(activity.get()) + val background = View(activity.get()).also(root::addView) + val owner = View(activity.get()).also(root::addView) + val otherRoot = TestReactRoot(activity.get()) + val otherRootContent = View(activity.get()).also(otherRoot::addView) + window.addView(root) + window.addView(otherRoot) + activity.get().setContentView(window) + + val registration = requireNotNull(PortalPresentationCoordinator.register(owner, true) {}) + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + background.importantForAccessibility, + ) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, owner.importantForAccessibility) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, + otherRootContent.importantForAccessibility, + ) + + registration.remove() + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, background.importantForAccessibility) + } finally { + lease.release() + activity.close() + } + } + + @Test + fun `unique Top hides the lower portal while unknown Top retains every Active portal`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lease = PortalAccessibilityIsolationCoordinator.acquire() + try { + val window = FrameLayout(activity.get()) + val orderedRoot = TestReactRoot(activity.get()) + val orderedParent = FrameLayout(activity.get()).also(orderedRoot::addView) + val background = View(activity.get()).also(orderedParent::addView) + val lower = FrameLayout(activity.get()).also(orderedParent::addView) + val upper = FrameLayout(activity.get()).also(orderedParent::addView) + window.addView(orderedRoot) + activity.get().setContentView(window) + val lowerRegistration = requireNotNull(PortalPresentationCoordinator.register(lower, true) {}) + val upperRegistration = requireNotNull(PortalPresentationCoordinator.register(upper, true) {}) + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + background.importantForAccessibility, + ) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + lower.importantForAccessibility, + ) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, upper.importantForAccessibility) + + upperRegistration.remove() + lowerRegistration.remove() + val unknownRoot = TestReactRoot(activity.get()) + val unknownBackground = View(activity.get()).also(unknownRoot::addView) + val first = View(activity.get()).also(unknownRoot::addView) + val second = View(activity.get()).also(unknownRoot::addView) + window.addView(unknownRoot) + val firstRegistration = requireNotNull(PortalPresentationCoordinator.register(first, true) {}) + val secondRegistration = + requireNotNull(PortalPresentationCoordinator.register(second, true) {}) + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + unknownBackground.importantForAccessibility, + ) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, first.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, second.importantForAccessibility) + + secondRegistration.remove() + firstRegistration.remove() + } finally { + lease.release() + activity.close() + } + } + + @Test + fun `isolation state does not retain a detached React root`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lease = PortalAccessibilityIsolationCoordinator.acquire() + val (registration, root) = registerAndDetachRoot(activity.get()) + try { + repeat(20) { + if (root.get() != null) { + System.gc() + System.runFinalization() + } + } + + assertNull("the isolation coordinator must not retain the React root", root.get()) + } finally { + registration.remove() + lease.release() + activity.close() + } + } + + private fun registerAndDetachRoot( + activity: Activity + ): Pair> { + val window = FrameLayout(activity) + val root = TestReactRoot(activity) + val background = View(activity).also(root::addView) + val owner = View(activity).also(root::addView) + window.addView(root) + activity.setContentView(window) + val registration = requireNotNull(PortalPresentationCoordinator.register(owner, true) {}) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + background.importantForAccessibility, + ) + window.removeView(root) + return registration to WeakReference(root) + } +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconcilerTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconcilerTest.kt new file mode 100644 index 0000000..cc2ff2c --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityReconcilerTest.kt @@ -0,0 +1,328 @@ +package com.swmansion.reactnativebottomsheet.accessibility + +import android.app.Activity +import android.content.Context +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.ref.WeakReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PortalAccessibilityReconcilerTest { + @Test + fun `one retained owner masks every side branch without masking the owner subtree`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val backgroundBefore = View(activity.get()) + val retainedBranch = FrameLayout(activity.get()) + val nestedBackground = View(activity.get()) + val owner = FrameLayout(activity.get()) + val ownerContent = View(activity.get()) + val backgroundAfter = View(activity.get()) + owner.addView(ownerContent) + retainedBranch.addView(nestedBackground) + retainedBranch.addView(owner) + root.addView(backgroundBefore) + root.addView(retainedBranch) + root.addView(backgroundAfter) + activity.get().setContentView(root) + + PortalAccessibilityReconciler(root).reconcile(listOf(owner)) + + listOf(backgroundBefore, nestedBackground, backgroundAfter).forEach { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + it.importantForAccessibility, + ) + } + listOf(root, retainedBranch, owner, ownerContent).forEach { + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, it.importantForAccessibility) + } + } finally { + activity.close() + } + } + + @Test + fun `final release restores every exact accessibility importance baseline`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val baselines = + listOf( + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, + View.IMPORTANT_FOR_ACCESSIBILITY_YES, + View.IMPORTANT_FOR_ACCESSIBILITY_NO, + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + ) + val background = baselines.map { baseline -> + View(activity.get()).apply { + importantForAccessibility = baseline + root.addView(this) + } + } + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + + reconciler.reconcile(listOf(owner)) + reconciler.reconcile(emptyList()) + + assertEquals(baselines, background.map(View::getImportantForAccessibility)) + } finally { + activity.close() + } + } + + @Test + fun `distinguishable application write is covered again and becomes the restore candidate`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val background = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + root.addView(this) + } + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(owner)) + + background.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + reconciler.reconcile(listOf(owner)) + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + background.importantForAccessibility, + ) + reconciler.reconcile(emptyList()) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, background.importantForAccessibility) + } finally { + activity.close() + } + } + + @Test + fun `multiple retained paths preserve both owners and mask their remaining side branches`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val background = View(activity.get()).also(root::addView) + val firstBranch = FrameLayout(activity.get()).also(root::addView) + val firstSide = View(activity.get()).also(firstBranch::addView) + val firstOwner = View(activity.get()).also(firstBranch::addView) + val secondBranch = FrameLayout(activity.get()).also(root::addView) + val secondOwner = View(activity.get()).also(secondBranch::addView) + val secondSide = View(activity.get()).also(secondBranch::addView) + activity.get().setContentView(root) + + PortalAccessibilityReconciler(root).reconcile(listOf(firstOwner, secondOwner)) + + listOf(background, firstSide, secondSide).forEach { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + it.importantForAccessibility, + ) + } + listOf(firstBranch, firstOwner, secondBranch, secondOwner).forEach { + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, it.importantForAccessibility) + } + } finally { + activity.close() + } + } + + @Test + fun `invalid retained path releases this root without mutating the other root`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val window = FrameLayout(activity.get()) + val root = FrameLayout(activity.get()) + val background = View(activity.get()).also(root::addView) + val owner = View(activity.get()).also(root::addView) + val otherRoot = FrameLayout(activity.get()) + val otherOwner = View(activity.get()).also(otherRoot::addView) + window.addView(root) + window.addView(otherRoot) + activity.get().setContentView(window) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(owner)) + + reconciler.reconcile(listOf(otherOwner)) + + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, background.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, otherOwner.importantForAccessibility) + } finally { + activity.close() + } + } + + @Test + fun `release preserves an application write made after the coordinator mask`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val background = View(activity.get()).also(root::addView) + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(owner)) + + background.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + reconciler.reconcile(emptyList()) + + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, background.importantForAccessibility) + } finally { + activity.close() + } + } + + @Test + fun `same-value application write remains indistinguishable and restores the last candidate`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val background = View(activity.get()).also(root::addView) + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(owner)) + + background.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + reconciler.reconcile(emptyList()) + + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, background.importantForAccessibility) + } finally { + activity.close() + } + } + + @Test + fun `dynamic insertion removal and repeated reconciliation use the current hierarchy`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(owner)) + val added = CountingImportanceView(activity.get()).also(root::addView) + + reconciler.reconcile(listOf(owner)) + reconciler.reconcile(listOf(owner)) + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + added.importantForAccessibility, + ) + assertEquals(1, added.writeCount) + root.removeView(added) + reconciler.reconcile(listOf(owner)) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, added.importantForAccessibility) + } finally { + activity.close() + } + } + + @Test + fun `owner transfer masks the new side branch before restoring the old one`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val background = View(activity.get()).also(root::addView) + val firstBranch = FrameLayout(activity.get()).also(root::addView) + val firstOwner = View(activity.get()).also(firstBranch::addView) + var maskedAtRestore = false + val secondBranch = + RestoreObservingLayout(activity.get()) { + maskedAtRestore = + firstBranch.importantForAccessibility == + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS && + background.importantForAccessibility == + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + .also(root::addView) + val secondOwner = View(activity.get()).also(secondBranch::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(firstOwner)) + + reconciler.reconcile(listOf(secondOwner)) + + assertEquals(true, maskedAtRestore) + } finally { + activity.close() + } + } + + @Test + fun `removed masked branches are restored and not retained by the ledger`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = FrameLayout(activity.get()) + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + val removed = maskAndRemoveBranch(activity.get(), root, owner, reconciler) + + repeat(20) { + if (removed.get() != null) { + System.gc() + System.runFinalization() + } + } + + assertNull(removed.get()) + } finally { + activity.close() + } + } + + private fun maskAndRemoveBranch( + context: Context, + root: FrameLayout, + owner: View, + reconciler: PortalAccessibilityReconciler, + ): WeakReference { + val branch = View(context) + root.addView(branch) + reconciler.reconcile(listOf(owner)) + root.removeView(branch) + reconciler.reconcile(listOf(owner)) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, branch.importantForAccessibility) + return WeakReference(branch) + } + + private class CountingImportanceView(context: Context) : View(context) { + var writeCount = 0 + + override fun setImportantForAccessibility(mode: Int) { + super.setImportantForAccessibility(mode) + writeCount++ + } + } + + private class RestoreObservingLayout( + context: Context, + private val onRestore: () -> Unit, + ) : FrameLayout(context) { + override fun setImportantForAccessibility(mode: Int) { + if ( + importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS && + mode != View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + ) { + onRestore() + } + super.setImportantForAccessibility(mode) + } + } +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapterTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapterTest.kt new file mode 100644 index 0000000..7949a54 --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/ReactNativeMountReconciliationAdapterTest.kt @@ -0,0 +1,144 @@ +@file:OptIn(com.facebook.react.common.annotations.UnstableReactNativeAPI::class) + +package com.swmansion.reactnativebottomsheet.accessibility + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.UIManagerListener +import java.lang.reflect.Proxy +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class ReactNativeMountReconciliationAdapterTest { + @Test + fun `Fabric mount callbacks coalesce and one listener lives until the final observation ends`() { + val posted = mutableListOf() + val boundary = FakeMountCallbacks(isFabric = true) + val adapter = ReactNativeMountReconciliationAdapter(postToUiThread = { posted.add(it) }) + var firstCount = 0 + var secondCount = 0 + val first = adapter.observe(boundary) { firstCount++ } + val second = adapter.observe(boundary) { secondCount++ } + + boundary.listener?.didMountItems(FakeUIManager) + boundary.listener?.didMountItems(FakeUIManager) + assertEquals(1, posted.size) + posted.removeAt(0).run() + assertEquals(1, firstCount) + assertEquals(1, secondCount) + assertEquals(1, boundary.addCount) + + first.remove() + assertEquals(0, boundary.removeCount) + boundary.listener?.didMountItems(FakeUIManager) + second.remove() + posted.removeAt(0).run() + assertEquals(1, firstCount) + assertEquals(1, secondCount) + assertEquals(1, boundary.removeCount) + } + + @Test + fun `Paper queues a post-mount block for every batch before coalescing reconciliation`() { + val posted = mutableListOf() + val boundary = FakeMountCallbacks(isFabric = false) + val adapter = ReactNativeMountReconciliationAdapter(postToUiThread = { posted.add(it) }) + var reconciliationCount = 0 + val observation = adapter.observe(boundary) { reconciliationCount++ } + + boundary.listener?.willDispatchViewUpdates(FakeUIManager) + boundary.listener?.willDispatchViewUpdates(FakeUIManager) + assertEquals(2, boundary.paperBlocks.size) + assertEquals(0, posted.size) + boundary.paperBlocks.removeAt(0).invoke() + assertEquals(1, posted.size) + boundary.paperBlocks.removeAt(0).invoke() + assertEquals(1, posted.size) + posted.removeAt(0).run() + assertEquals(1, reconciliationCount) + + observation.remove() + } + + @Test + fun `post-mount reconciliation covers inserted reordered and rewritten side branches`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val posted = mutableListOf() + val boundary = FakeMountCallbacks(isFabric = true) + val adapter = ReactNativeMountReconciliationAdapter(postToUiThread = { posted.add(it) }) + val root = FrameLayout(activity.get()) + val existing = View(activity.get()).also(root::addView) + val owner = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val reconciler = PortalAccessibilityReconciler(root) + reconciler.reconcile(listOf(owner)) + val observation = adapter.observe(boundary) { reconciler.reconcile(listOf(owner)) } + val inserted = View(activity.get()).also(root::addView) + existing.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + root.removeView(owner) + root.addView(owner, 0) + + boundary.listener?.didMountItems(FakeUIManager) + posted.removeAt(0).run() + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + inserted.importantForAccessibility, + ) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + existing.importantForAccessibility, + ) + root.removeView(inserted) + boundary.listener?.didMountItems(FakeUIManager) + posted.removeAt(0).run() + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, inserted.importantForAccessibility) + observation.remove() + reconciler.reconcile(emptyList()) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, existing.importantForAccessibility) + } finally { + activity.close() + } + } + + private class FakeMountCallbacks(override val isFabric: Boolean) : ReactNativeMountCallbacks { + override val identity = Any() + var listener: UIManagerListener? = null + var addCount = 0 + var removeCount = 0 + val paperBlocks = mutableListOf<() -> Unit>() + + override fun addListener(listener: UIManagerListener) { + this.listener = listener + addCount++ + } + + override fun removeListener(listener: UIManagerListener) { + if (this.listener === listener) this.listener = null + removeCount++ + } + + override fun enqueueAfterPaperMount(block: () -> Unit) { + paperBlocks.add(block) + } + } + + private companion object { + val FakeUIManager = + Proxy.newProxyInstance( + UIManager::class.java.classLoader, + arrayOf(UIManager::class.java), + ) { _, _, _ -> + null + } as UIManager + } +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationAccessibilityPolicyTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationAccessibilityPolicyTest.kt new file mode 100644 index 0000000..d56ac07 --- /dev/null +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationAccessibilityPolicyTest.kt @@ -0,0 +1,86 @@ +package com.swmansion.reactnativebottomsheet.presentation + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PortalPresentationAccessibilityPolicyTest { + @Test + fun `accessibility policy retains only unique Top and all Active anchors when Top is unknown`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val window = FrameLayout(activity.get()) + val orderedRoot = TestReactRoot(activity.get()) + val orderedParent = FrameLayout(activity.get()).also(orderedRoot::addView) + val lower = View(activity.get()).also(orderedParent::addView) + val upper = View(activity.get()).also(orderedParent::addView) + val unknownRoot = TestReactRoot(activity.get()) + val unknownFirst = View(activity.get()).also(unknownRoot::addView) + val unknownSecond = View(activity.get()).also(unknownRoot::addView) + window.addView(orderedRoot) + window.addView(unknownRoot) + activity.get().setContentView(window) + val retained = mutableMapOf>() + val observation = + PortalPresentationCoordinator.observeAccessibilityPolicy { windowRoot, policy -> + retained[windowRoot] = policy.map { it.anchor }.toSet() + } + val lowerRegistration = requireNotNull(PortalPresentationCoordinator.register(lower, true) {}) + val upperRegistration = requireNotNull(PortalPresentationCoordinator.register(upper, true) {}) + + assertEquals(setOf(upper), retained[lower.rootView]) + + lowerRegistration.remove() + upperRegistration.remove() + val firstRegistration = + requireNotNull(PortalPresentationCoordinator.register(unknownFirst, true) {}) + val secondRegistration = + requireNotNull(PortalPresentationCoordinator.register(unknownSecond, true) {}) + + assertEquals(setOf(unknownFirst, unknownSecond), retained[unknownFirst.rootView]) + + secondRegistration.remove() + firstRegistration.remove() + observation.remove() + } finally { + activity.close() + } + } + + @Test + fun `inactive upper presentation is excluded until it becomes Active`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + try { + val root = TestReactRoot(activity.get()) + val parent = FrameLayout(activity.get()).also(root::addView) + val lower = View(activity.get()).also(parent::addView) + val upper = View(activity.get()).also(parent::addView) + activity.get().setContentView(root) + var retained = emptySet() + val observation = PortalPresentationCoordinator.observeAccessibilityPolicy { _, policy -> + retained = policy.map { it.anchor }.toSet() + } + val lowerRegistration = requireNotNull(PortalPresentationCoordinator.register(lower, true) {}) + val upperRegistration = + requireNotNull(PortalPresentationCoordinator.register(upper, false) {}) + assertEquals(setOf(lower), retained) + + upperRegistration.update(true) + assertEquals(setOf(upper), retained) + + upperRegistration.remove() + lowerRegistration.remove() + observation.remove() + } finally { + activity.close() + } + } +} From ee505e884af7597e45901135863a6ca5ea61b134 Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Sat, 19 Sep 2026 12:31:10 +0200 Subject: [PATCH 06/27] fix(android): preserve portal accessibility lifecycle --- ...ortalModalAccessibilityInstrumentedTest.kt | 142 ++++++++++++++++++ .../reactnativebottomsheet/BottomSheetView.kt | 8 + .../BottomSheetViewCloseRequestTest.kt | 30 ++++ .../BottomSheetViewPortalAccessibilityTest.kt | 96 ++++++++++++ ...alAccessibilityIsolationCoordinatorTest.kt | 74 +++++++++ .../PortalPresentationControllerTest.kt | 121 ++++++++++++--- 6 files changed, 448 insertions(+), 23 deletions(-) create mode 100644 android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt diff --git a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt new file mode 100644 index 0000000..9daba90 --- /dev/null +++ b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt @@ -0,0 +1,142 @@ +package com.swmansion.reactnativebottomsheet + +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.RootView +import java.util.Collections +import java.util.IdentityHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PortalModalAccessibilityInstrumentedTest { + @Before + fun useLocalReactNativeFeatureFlags() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @Test + fun portalExcludesApplicationThroughClosingAndRestoresItOnlyAfterSettle() { + val closeSettled = CountDownLatch(1) + lateinit var root: InstrumentedReactRoot + lateinit var background: View + lateinit var sheetContent: View + lateinit var dismiss: View + lateinit var sheet: BottomSheetView + var sheetCreated = false + + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + try { + scenario.onActivity { activity -> + root = InstrumentedReactRoot(activity) + background = + View(activity).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Application control" + } + sheetContent = + View(activity).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Sheet content" + } + sheet = + BottomSheetView(activity).apply { + listener = + object : BottomSheetViewListener { + override fun onIndexChange(index: Int) = Unit + + override fun onSettle(index: Int) { + if (index == 0) closeSettled.countDown() + } + + override fun onPositionChange(position: Double, index: Double) = Unit + + override fun onCloseRequest() = Unit + } + animateIn = false + modal = true + setDetents( + listOf( + mapOf("value" to 0.0, "kind" to "points", "programmatic" to false), + mapOf("value" to 300.0, "kind" to "points", "programmatic" to false), + ) + ) + setIndex(1) + addSheetChild(sheetContent, 0) + } + sheetCreated = true + val portalWrapper = FrameLayout(activity) + portalWrapper.addView(sheet, matchParent()) + root.addView(background, matchParent()) + root.addView(portalWrapper, matchParent()) + activity.setContentView(root) + dismiss = (sheet.getChildAt(0) as ViewGroup).getChildAt(0) + } + + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.waitForIdleSync() + scenario.onActivity { + val activeTree = accessibleTree(root) + assertFalse(activeTree.contains(background)) + assertTrue(activeTree.contains(sheetContent)) + assertTrue(activeTree.contains(dismiss)) + + sheet.setIndex(0) + val closingTree = accessibleTree(root) + assertFalse(closingTree.contains(background)) + assertTrue(closingTree.contains(sheetContent)) + } + + assertTrue("close animation did not settle", closeSettled.await(5, TimeUnit.SECONDS)) + instrumentation.waitForIdleSync() + scenario.onActivity { + assertTrue(accessibleTree(root).contains(background)) + } + } finally { + scenario.onActivity { + if (sheetCreated) sheet.destroy() + } + } + } + } + + private fun accessibleTree(root: ViewGroup): Set { + val result = Collections.newSetFromMap(IdentityHashMap()) + fun visit(parent: ViewGroup) { + val children = arrayListOf() + parent.addChildrenForAccessibility(children) + children.forEach { child -> + if (result.add(child) && child is ViewGroup) visit(child) + } + } + visit(root) + return result + } + + private fun matchParent() = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) +} + +private class InstrumentedReactRoot(context: Context) : FrameLayout(context), RootView { + override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) = Unit + + override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) = Unit + + override fun handleException(t: Throwable) = throw t +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt index 9eac76b..ee4a0f6 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt @@ -62,6 +62,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven private var hasCloseRequestHandler = false private var isViewAttached = false private var isReactHostResumed = themedReactContext?.lifecycleState == LifecycleState.RESUMED + private var destroyed = false private val portalCloseRequestController = PortalCloseRequestController( view = this, @@ -181,6 +182,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven } fun setNativeOverlay(value: Boolean) { + if (destroyed) return if (value == nativeOverlay) return nativeOverlay = value if (value) { @@ -197,6 +199,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven override fun onAttachedToWindow() { super.onAttachedToWindow() + if (destroyed) return isViewAttached = true overlayDialog?.let { dialog -> overlayCloseRequestController.bind(dialog) @@ -447,6 +450,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven // MARK: - Activity lifecycle override fun onHostResume() { + if (destroyed) return isReactHostResumed = true // Restore the overlay if it was torn down while the activity was gone but the // sheet should still be presented above it. @@ -458,11 +462,13 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven } override fun onHostPause() { + if (destroyed) return isReactHostResumed = false refreshPresentationRouting() } override fun onHostDestroy() { + if (destroyed) return isReactHostResumed = false portalPresentationController.clear() portalCloseRequestController.clear() @@ -478,6 +484,8 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven // MARK: - Cleanup fun destroy() { + if (destroyed) return + destroyed = true isViewAttached = false isReactHostResumed = false hasCloseRequestHandler = false diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt index ac9cc77..263cf04 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewCloseRequestTest.kt @@ -33,6 +33,8 @@ import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.EventDispatcherListener import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -484,6 +486,34 @@ class BottomSheetViewCloseRequestTest { } } + @Test + fun `destroy is idempotent and a late host resume cannot recreate the overlay`() { + withActivity { activity -> + val reactContext = BridgeReactContext(activity.applicationContext) + reactContext.onHostResume(activity) + val themedContext = ThemedReactContext(reactContext, activity, "test", 1) + val sheet = configuredOpenSheet(themedContext, CountingBottomSheetListener()) + sheet.eventDispatcher = NoOpEventDispatcher + activity.setReactContentView(sheet) + layoutPortal(sheet) + sheet.onHostResume() + sheet.setNativeOverlay(true) + shadowOf(Looper.getMainLooper()).idle() + val dialog = requireNotNull(ShadowDialog.getLatestDialog()) as ComponentDialog + assertTrue(dialog.isShowing) + + sheet.destroy() + sheet.destroy() + assertFalse(dialog.isShowing) + sheet.onHostResume() + shadowOf(Looper.getMainLooper()).idle() + + assertSame(dialog, ShadowDialog.getLatestDialog()) + assertFalse(dialog.isShowing) + reactContext.onHostDestroy() + } + } + private fun configuredOpenSheet( context: Context, listener: CountingBottomSheetListener, diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt index 554c22b..b12a107 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt @@ -1,3 +1,6 @@ +// BridgeReactContext is required by the legacy-architecture test fixture. +@file:Suppress("DEPRECATION") + package com.swmansion.reactnativebottomsheet import android.app.Activity @@ -5,8 +8,15 @@ import android.os.Looper import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.activity.ComponentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.events.BatchEventDispatchedListener +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.events.EventDispatcherListener import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import java.util.Collections import java.util.IdentityHashMap @@ -69,6 +79,17 @@ class BottomSheetViewPortalAccessibilityTest { assertTrue(activeTree.contains(sheetContent)) assertTrue(activeTree.contains(dismiss)) + sheet.onHostDestroy() + assertTrue(accessibleTree(root).contains(background)) + sheet.onHostResume() + assertFalse(accessibleTree(root).contains(background)) + + portalWrapper.removeView(sheet) + assertTrue(accessibleTree(root).contains(background)) + portalWrapper.addView(sheet, matchParent()) + layout(root) + assertFalse(accessibleTree(root).contains(background)) + sheet.modal = false assertTrue(accessibleTree(root).contains(background)) @@ -78,6 +99,65 @@ class BottomSheetViewPortalAccessibilityTest { } } + @Test + fun `programmatic-only portal withdraws for native overlay and isolates again after inline attach`() { + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val reactContext = BridgeReactContext(activity.get().applicationContext) + reactContext.onHostResume(activity.get()) + val themedContext = ThemedReactContext(reactContext, activity.get(), "test", 1) + val sheet = BottomSheetView(themedContext) + try { + val root = TestReactRoot(activity.get()) + val background = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Application control" + } + val sheetContent = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Sheet content" + } + sheet.apply { + eventDispatcher = NoOpPortalAccessibilityEventDispatcher + animateIn = false + modal = true + setHasCloseRequestHandler(false) + setDetents( + listOf( + mapOf("value" to 0.0, "kind" to "points", "programmatic" to true), + mapOf("value" to 300.0, "kind" to "points", "programmatic" to false), + ) + ) + setIndex(1) + addSheetChild(sheetContent, 0) + } + root.addView(background, matchParent()) + root.addView(sheet, matchParent()) + activity.get().setContentView(root) + layout(root) + sheet.onHostResume() + val dismiss = (sheet.getChildAt(0) as ViewGroup).getChildAt(0) + + assertFalse(accessibleTree(root).contains(background)) + assertTrue(accessibleTree(root).contains(sheetContent)) + assertFalse(accessibleTree(root).contains(dismiss)) + + sheet.setNativeOverlay(true) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(accessibleTree(root).contains(background)) + + sheet.setNativeOverlay(false) + layout(root) + assertFalse(accessibleTree(root).contains(background)) + assertTrue(accessibleTree(root).contains(sheetContent)) + } finally { + sheet.destroy() + reactContext.onHostDestroy() + activity.close() + } + } + private fun accessibleTree(root: ViewGroup): Set { val result = Collections.newSetFromMap(IdentityHashMap()) fun visit(parent: ViewGroup) { @@ -106,3 +186,19 @@ class BottomSheetViewPortalAccessibilityTest { ViewGroup.LayoutParams.MATCH_PARENT, ) } + +private object NoOpPortalAccessibilityEventDispatcher : EventDispatcher { + override fun dispatchEvent(event: Event<*>) = Unit + + override fun dispatchAllEvents() = Unit + + override fun addListener(listener: EventDispatcherListener) = Unit + + override fun removeListener(listener: EventDispatcherListener) = Unit + + override fun addBatchEventDispatchedListener(listener: BatchEventDispatchedListener) = Unit + + override fun removeBatchEventDispatchedListener(listener: BatchEventDispatchedListener) = Unit + + @Suppress("OVERRIDE_DEPRECATION") override fun onCatalystInstanceDestroyed() = Unit +} diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt index 1dff66d..d60f69f 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/accessibility/PortalAccessibilityIsolationCoordinatorTest.kt @@ -9,6 +9,7 @@ import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import java.lang.ref.WeakReference import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -17,6 +18,64 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(sdk = [35]) class PortalAccessibilityIsolationCoordinatorTest { + @Test + fun `nested Top stays isolated through closing and transfers directly on settle`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lease = PortalAccessibilityIsolationCoordinator.acquire() + try { + val root = TestReactRoot(activity.get()) + val background = View(activity.get()).also(root::addView) + val lowerPortal = FrameLayout(activity.get()).also(root::addView) + var transferredWithoutBackgroundRestore = false + val lowerContent = + RestoreObservingView(activity.get()) { + transferredWithoutBackgroundRestore = + background.importantForAccessibility == + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + .also(lowerPortal::addView) + val nestedProvider = FrameLayout(activity.get()).also(lowerPortal::addView) + val upperPortal = View(activity.get()).also(nestedProvider::addView) + activity.get().setContentView(root) + val lowerRegistration = + requireNotNull(PortalPresentationCoordinator.register(lowerPortal, true) {}) + val upperRegistration = + requireNotNull(PortalPresentationCoordinator.register(upperPortal, true) {}) + + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + background.importantForAccessibility, + ) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + lowerContent.importantForAccessibility, + ) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, upperPortal.importantForAccessibility) + + // A visible closing Top remains Active until its host reports settle. + upperRegistration.update(true) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + lowerContent.importantForAccessibility, + ) + upperRegistration.update(false) + + assertTrue(transferredWithoutBackgroundRestore) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, lowerContent.importantForAccessibility) + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + background.importantForAccessibility, + ) + + upperRegistration.remove() + lowerRegistration.remove() + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, background.importantForAccessibility) + } finally { + lease.release() + activity.close() + } + } + @Test fun `shared presentation policy isolates only the owner React root and restores on release`() { val activity = Robolectric.buildActivity(Activity::class.java).setup() @@ -142,4 +201,19 @@ class PortalAccessibilityIsolationCoordinatorTest { window.removeView(root) return registration to WeakReference(root) } + + private class RestoreObservingView( + activity: Activity, + private val onRestore: () -> Unit, + ) : View(activity) { + override fun setImportantForAccessibility(mode: Int) { + if ( + importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS && + mode != View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + ) { + onRestore() + } + super.setImportantForAccessibility(mode) + } + } } diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt index c81ca10..3b724af 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/presentation/PortalPresentationControllerTest.kt @@ -6,9 +6,9 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.reactnativebottomsheet.accessibility.PortalAccessibilityIsolationCoordinator import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -20,48 +20,94 @@ import org.robolectric.annotation.Config @Config(sdk = [35]) class PortalPresentationControllerTest { @Test - fun `hierarchy sync refreshes the path and withdraws old root before joining another`() { + fun `clear restores isolation cancels pending sync and dispose makes later updates inert`() { val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lease = PortalAccessibilityIsolationCoordinator.acquire() try { - val container = FrameLayout(activity.get()) + val root = TestReactRoot(activity.get()) + val background = View(activity.get()).also(root::addView) + val anchor = View(activity.get()).also(root::addView) + activity.get().setContentView(root) + val controller = PortalPresentationController(anchor) { _, _ -> } + controller.update(isPortal = true, isActive = true) + assertMasked(background) + + controller.scheduleHierarchySync() + controller.clear() + assertRestored(background) + shadowOf(Looper.getMainLooper()).idle() + assertRestored(background) + + controller.update(isPortal = true, isActive = true) + assertMasked(background) + controller.dispose() + controller.dispose() + controller.update(isPortal = true, isActive = true) + controller.scheduleHierarchySync() + shadowOf(Looper.getMainLooper()).idle() + assertRestored(background) + } finally { + lease.release() + activity.close() + } + } + + @Test + fun `same-root reparent masks the new path before restore and migration cleans the old root`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lease = PortalAccessibilityIsolationCoordinator.acquire() + try { + val window = FrameLayout(activity.get()) val firstRoot = TestReactRoot(activity.get()) + val firstBackground = View(activity.get()).also(firstRoot::addView) + val oldBranch = FrameLayout(activity.get()).also(firstRoot::addView) + var newPathRestoredSafely = false + val newBranch = + RestoreObservingLayout(activity.get()) { + newPathRestoredSafely = firstBackground.isMasked() && oldBranch.isMasked() + } + .also(firstRoot::addView) + val anchor = View(activity.get()).also(oldBranch::addView) val secondRoot = TestReactRoot(activity.get()) - val branch = FrameLayout(activity.get()) - val anchor = View(activity.get()) - firstRoot.addView(anchor) - firstRoot.addView(branch) - container.addView(firstRoot) - container.addView(secondRoot) - activity.get().setContentView(container) - var current: PortalPresentationContext? = null + val secondBackground = View(activity.get()).also(secondRoot::addView) + val secondBranch = FrameLayout(activity.get()).also(secondRoot::addView) + window.addView(firstRoot) + window.addView(secondRoot) + activity.get().setContentView(window) val assignments = mutableListOf>() val controller = PortalPresentationController(anchor) { context, assignment -> - current = context assignments.add(context?.reactRoot to assignment) } controller.update(isPortal = true, isActive = true) - firstRoot.removeView(anchor) - branch.addView(anchor) + + oldBranch.removeView(anchor) + newBranch.addView(anchor) controller.syncHierarchy() - assertEquals(listOf(firstRoot, branch, anchor), current?.path) + + assertTrue(newPathRestoredSafely) + assertMasked(firstBackground) + assertMasked(oldBranch) + assertRestored(newBranch) assignments.clear() - branch.removeView(anchor) - secondRoot.addView(anchor) + newBranch.removeView(anchor) + secondBranch.addView(anchor) controller.scheduleHierarchySync() shadowOf(Looper.getMainLooper()).idle() - assertSame(secondRoot, current?.reactRoot) + + assertRestored(firstBackground) + assertRestored(oldBranch) + assertRestored(newBranch) + assertMasked(secondBackground) + assertRestored(secondBranch) val oldRelease = assignments.indexOf(firstRoot to PortalPresentationAssignment.NONE) val newClaim = assignments.indexOf(secondRoot to PortalPresentationAssignment.TOP) assertTrue(oldRelease >= 0 && newClaim > oldRelease) - - controller.scheduleHierarchySync() - controller.clear() - shadowOf(Looper.getMainLooper()).idle() - assertNull(current) controller.dispose() + assertRestored(secondBackground) } finally { + lease.release() activity.close() } } @@ -100,4 +146,33 @@ class PortalPresentationControllerTest { activity.close() } } + + private fun assertMasked(view: View) { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + view.importantForAccessibility, + ) + } + + private fun assertRestored(view: View) { + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, view.importantForAccessibility) + } + + private fun View.isMasked(): Boolean = + importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + + private class RestoreObservingLayout( + context: android.content.Context, + private val onRestore: () -> Unit, + ) : FrameLayout(context) { + override fun setImportantForAccessibility(mode: Int) { + if ( + importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS && + mode != View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + ) { + onRestore() + } + super.setImportantForAccessibility(mode) + } + } } From 7cfb032107599654c498e89a5b1a1b3c0268deb0 Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Sat, 19 Sep 2026 12:44:28 +0200 Subject: [PATCH 07/27] test(android): mount portal fixtures in React roots --- .../CloseRequestAnimationInstrumentedTest.kt | 2 +- .../EscapeRoutingInstrumentedTest.kt | 6 ++-- .../InstrumentedReactRoot.kt | 31 +++++++++++++++++++ ...ortalModalAccessibilityInstrumentedTest.kt | 11 ------- 4 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 android/src/androidTest/java/com/swmansion/reactnativebottomsheet/InstrumentedReactRoot.kt diff --git a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/CloseRequestAnimationInstrumentedTest.kt b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/CloseRequestAnimationInstrumentedTest.kt index 38893fd..614955d 100644 --- a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/CloseRequestAnimationInstrumentedTest.kt +++ b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/CloseRequestAnimationInstrumentedTest.kt @@ -65,7 +65,7 @@ class CloseRequestAnimationInstrumentedTest { ) setIndex(1) } - activity.setContentView(sheet) + activity.setInstrumentedReactContentView(sheet) } val instrumentation = InstrumentationRegistry.getInstrumentation() diff --git a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/EscapeRoutingInstrumentedTest.kt b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/EscapeRoutingInstrumentedTest.kt index 89f5601..a343fd7 100644 --- a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/EscapeRoutingInstrumentedTest.kt +++ b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/EscapeRoutingInstrumentedTest.kt @@ -49,7 +49,7 @@ class EscapeRoutingInstrumentedTest { val child = EscapeRecordingView(activity, childEventCount, consumesEscape = true) child.isFocusableInTouchMode = true sheet.addView(child, ViewGroup.LayoutParams(1, 1)) - activity.setContentView(sheet) + activity.setInstrumentedReactContentView(sheet) assertTrue(child.requestFocus()) } @@ -82,7 +82,7 @@ class EscapeRoutingInstrumentedTest { ), ) root.addView(sibling, FrameLayout.LayoutParams(100, 100)) - activity.setContentView(root) + activity.setInstrumentedReactContentView(root) assertTrue(sibling.requestFocus()) } @@ -120,7 +120,7 @@ class EscapeRoutingInstrumentedTest { FrameLayout.LayoutParams.MATCH_PARENT, ), ) - activity.setContentView(root) + activity.setInstrumentedReactContentView(root) assertTrue(lowerChild.requestFocus()) upperContent = MutableContentHeightView(activity) diff --git a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/InstrumentedReactRoot.kt b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/InstrumentedReactRoot.kt new file mode 100644 index 0000000..0e0ac24 --- /dev/null +++ b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/InstrumentedReactRoot.kt @@ -0,0 +1,31 @@ +package com.swmansion.reactnativebottomsheet + +import android.app.Activity +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.facebook.react.uimanager.RootView + +internal class InstrumentedReactRoot(context: Context) : FrameLayout(context), RootView { + override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) = Unit + + override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) = Unit + + override fun handleException(t: Throwable) = throw t +} + +internal fun Activity.setInstrumentedReactContentView(content: View) { + setContentView( + InstrumentedReactRoot(this).apply { + addView( + content, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + } + ) +} diff --git a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt index 9daba90..ee19ffb 100644 --- a/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt +++ b/android/src/androidTest/java/com/swmansion/reactnativebottomsheet/PortalModalAccessibilityInstrumentedTest.kt @@ -1,7 +1,5 @@ package com.swmansion.reactnativebottomsheet -import android.content.Context -import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -10,7 +8,6 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests -import com.facebook.react.uimanager.RootView import java.util.Collections import java.util.IdentityHashMap import java.util.concurrent.CountDownLatch @@ -132,11 +129,3 @@ class PortalModalAccessibilityInstrumentedTest { ViewGroup.LayoutParams.MATCH_PARENT, ) } - -private class InstrumentedReactRoot(context: Context) : FrameLayout(context), RootView { - override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) = Unit - - override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) = Unit - - override fun handleException(t: Throwable) = throw t -} From a998561ec48fed23181b2c778dc36c1cb8efbc11 Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Sat, 19 Sep 2026 19:33:55 +0200 Subject: [PATCH 08/27] fix(android): make stacked portal order provable Mount portal wrappers under one non-flattened native host so Android can derive a unique visual Top even when the provider is nested below a custom ViewGroup. This prevents the conservative unknown-order fallback from leaving lower active portals reachable to TalkBack. Add provider-topology and real-view accessibility regressions, and run the JS coverage in CI. --- .github/workflows/check.yml | 1 + .../BottomSheetViewPortalAccessibilityTest.kt | 70 +++++++++++++++++++ package.json | 1 + src/BottomSheetProvider.tsx | 9 +-- src/PortalHost.tsx | 28 ++++++++ src/__tests__/BottomSheetProvider.test.tsx | 50 +++++++++++++ tsconfig.build.json | 5 +- 7 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/PortalHost.tsx create mode 100644 src/__tests__/BottomSheetProvider.test.tsx diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a98b494..6cbd4cb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,6 +23,7 @@ jobs: - run: bun install --frozen-lockfile - run: bun run lint - run: bun run typecheck + - run: bun run test:js - run: bun run prepare - uses: actions/setup-java@v5 with: diff --git a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt index b12a107..2d6511b 100644 --- a/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt +++ b/android/src/test/java/com/swmansion/reactnativebottomsheet/BottomSheetViewPortalAccessibilityTest.kt @@ -17,6 +17,7 @@ import com.facebook.react.uimanager.events.BatchEventDispatchedListener import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.EventDispatcherListener +import com.facebook.react.views.view.ReactViewGroup import com.swmansion.reactnativebottomsheet.presentation.TestReactRoot import java.util.Collections import java.util.IdentityHashMap @@ -158,6 +159,75 @@ class BottomSheetViewPortalAccessibilityTest { } } + @Test + fun `opening an upper sibling portal removes the lower portal from the accessibility tree`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup() + val lowerSheet = BottomSheetView(activity.get()) + val upperSheet = BottomSheetView(activity.get()) + try { + val root = TestReactRoot(activity.get()) + val portalHost = ReactViewGroup(activity.get()) + val background = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Application control" + } + val lowerWrapper = ReactViewGroup(activity.get()) + val upperWrapper = ReactViewGroup(activity.get()) + val lowerContent = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Lower focus target" + } + val upperContent = + View(activity.get()).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Upper focus target" + } + lowerSheet.configurePortal(index = 1, content = lowerContent) + upperSheet.configurePortal(index = 0, content = upperContent) + lowerWrapper.addView(lowerSheet, matchParent()) + upperWrapper.addView(upperSheet, matchParent()) + portalHost.addView(lowerWrapper, matchParent()) + portalHost.addView(upperWrapper, matchParent()) + root.addView(background, matchParent()) + root.addView(portalHost, matchParent()) + activity.get().setContentView(root) + layout(root) + layout(lowerSheet) + layout(upperSheet) + + val lowerTree = accessibleTree(root) + assertFalse(lowerTree.contains(background)) + assertTrue(lowerTree.contains(lowerContent)) + + upperSheet.setIndex(1) + shadowOf(Looper.getMainLooper()).idle() + + val upperTree = accessibleTree(root) + assertFalse(upperTree.contains(background)) + assertFalse(upperTree.contains(lowerContent)) + assertTrue(upperTree.contains(upperContent)) + } finally { + upperSheet.destroy() + lowerSheet.destroy() + activity.close() + } + } + + private fun BottomSheetView.configurePortal(index: Int, content: View) { + animateIn = false + modal = true + setDetents( + listOf( + mapOf("value" to 0.0, "kind" to "points", "programmatic" to false), + mapOf("value" to 300.0, "kind" to "points", "programmatic" to false), + ) + ) + setIndex(index) + addSheetChild(content, 0) + } + private fun accessibleTree(root: ViewGroup): Set { val result = Collections.newSetFromMap(IdentityHashMap()) fun visit(parent: ViewGroup) { diff --git a/package.json b/package.json index ddbab70..59a7819 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "example": "bun run --filter @swmansion/react-native-bottom-sheet-example", "clean": "del-cli lib", "prepare": "lefthook install && bob build", + "test:js": "bun test src", "test:android": "bun run test:android:prepare && bun run test:android:unit", "test:android:prepare": "bun run --cwd example expo prebuild --platform android --no-install", "test:android:instrumented:assemble": "./example/android/gradlew --project-dir example/android :swmansion_react-native-bottom-sheet:assembleDebugAndroidTest", diff --git a/src/BottomSheetProvider.tsx b/src/BottomSheetProvider.tsx index 570a114..f3fc74b 100644 --- a/src/BottomSheetProvider.tsx +++ b/src/BottomSheetProvider.tsx @@ -7,9 +7,8 @@ import { useSyncExternalStore, } from 'react'; import type { ReactNode } from 'react'; -import { StyleSheet, View } from 'react-native'; -type PortalSnapshot = Array<[string, ReactNode]>; +import { renderPortalHost, type PortalSnapshot } from './PortalHost'; interface PortalContextType { addPortal: (key: string, element: ReactNode) => void; @@ -28,11 +27,7 @@ const PortalHost = () => { context.getSnapshot ); - return portals.map(([key, element]) => ( - - {element} - - )); + return renderPortalHost(portals); }; /** Provides the portal host required for modal bottom sheets. */ diff --git a/src/PortalHost.tsx b/src/PortalHost.tsx new file mode 100644 index 0000000..473d919 --- /dev/null +++ b/src/PortalHost.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; + +export type PortalSnapshot = Array<[string, ReactNode]>; + +export const renderPortalHost = (portals: PortalSnapshot) => { + if (portals.length === 0) return null; + + return ( + // Keep portal wrappers under one non-flattened native parent. Android uses + // their native sibling order to select the unique accessible Top portal. + + {portals.map(([key, element]) => ( + + {element} + + ))} + + ); +}; diff --git a/src/__tests__/BottomSheetProvider.test.tsx b/src/__tests__/BottomSheetProvider.test.tsx new file mode 100644 index 0000000..06e2061 --- /dev/null +++ b/src/__tests__/BottomSheetProvider.test.tsx @@ -0,0 +1,50 @@ +import { Children, isValidElement, type ReactNode } from 'react'; +import { expect, mock, test } from 'bun:test'; + +const nativeView = 'NativeView'; +const absoluteFill = Object.freeze({ position: 'absolute' }); + +mock.module('react-native', () => ({ + StyleSheet: { absoluteFill }, + View: nativeView, +})); + +const { renderPortalHost } = await import('../PortalHost'); + +test('zero portals render no native host', () => { + expect(renderPortalHost([])).toBeNull(); +}); + +test('multiple portals share one non-flattened native host in presentation order', () => { + const host = renderPortalHost([ + ['lower', 'Lower portal'], + ['upper', 'Upper portal'], + ]); + + expect(isValidElement(host)).toBe(true); + if ( + !isValidElement<{ + collapsable: boolean; + children: ReactNode; + pointerEvents: string; + style: object; + }>(host) + ) { + throw new Error('Expected one native portal host.'); + } + expect(host.type).toBe(nativeView); + expect(host.props.collapsable).toBe(false); + expect(host.props.pointerEvents).toBe('box-none'); + expect(host.props.style).toBe(absoluteFill); + + const wrappers = Children.toArray(host.props.children); + expect(wrappers).toHaveLength(2); + expect( + wrappers.map((wrapper) => { + if (!isValidElement<{ children: ReactNode }>(wrapper)) { + throw new Error('Expected a native portal wrapper.'); + } + return wrapper.props.children; + }) + ).toEqual(['Lower portal', 'Upper portal']); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 1554584..01d7a52 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1 +1,4 @@ -{ "extends": "./tsconfig", "exclude": ["docs", "example", "lib"] } +{ + "extends": "./tsconfig", + "exclude": ["docs", "example", "lib", "src/__tests__"] +} From 693617ad759390ad477b27ac10e16ac598470af0 Mon Sep 17 00:00:00 2001 From: DanyKrk Date: Sat, 19 Sep 2026 19:34:53 +0200 Subject: [PATCH 09/27] feat(example): add stacked portal accessibility demo Keep a two-portal screen with distinct lower and upper focus targets plus open, closing, and settle logs. This gives maintainers a repeatable TalkBack path for verifying that the visual Top hides the lower portal until ownership transfers after settle. --- example/app/_layout.tsx | 7 + example/app/portal-accessibility-stack.tsx | 1 + example/src/demoCases.tsx | 6 + .../demos/PortalAccessibilityStackScreen.tsx | 233 ++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 example/app/portal-accessibility-stack.tsx create mode 100644 example/src/demos/PortalAccessibilityStackScreen.tsx diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index b67201d..21293f9 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -61,6 +61,13 @@ export default function RootLayout() { headerShown: false, }} /> + ( + undefined} + style={styles.focusTarget} + > + {label} + +); + +export const PortalAccessibilityStackScreen = () => { + const [lowerIndex, setLowerIndex] = useState(0); + const [upperIndex, setUpperIndex] = useState(0); + const [events, setEvents] = useState([]); + const nextEventId = useRef(0); + const pendingSettle = useRef>({ + lower: false, + upper: false, + }); + const bottomPadding = useSheetBottomPadding(); + + const logEvent = (portal: PortalName, phase: PortalPhase, index: number) => { + const message = `[two-portals] ${portal} ${phase} index=${index}`; + console.log(message); + setEvents((current) => + [{ id: nextEventId.current++, portal, phase, index }, ...current].slice( + 0, + 8 + ) + ); + }; + + const beginTransition = ( + portal: PortalName, + phase: Exclude, + index: number, + setIndex: (index: number) => void + ) => { + pendingSettle.current[portal] = true; + logEvent(portal, phase, index); + setIndex(index); + }; + + const openLower = () => beginTransition('lower', 'open', 1, setLowerIndex); + + const closeLower = () => { + if (lowerIndex === 0) return; + beginTransition('lower', 'closing', 0, setLowerIndex); + }; + + const openUpper = () => beginTransition('upper', 'open', 1, setUpperIndex); + + const closeUpper = () => { + if (upperIndex === 0) return; + beginTransition('upper', 'closing', 0, setUpperIndex); + }; + + const handleIndexChange = ( + portal: PortalName, + nextIndex: number, + setIndex: (index: number) => void + ) => { + beginTransition( + portal, + nextIndex === 0 ? 'closing' : 'open', + nextIndex, + setIndex + ); + }; + + const handleSettle = (portal: PortalName, index: number) => { + if (!pendingSettle.current[portal]) return; + pendingSettle.current[portal] = false; + logEvent(portal, 'settle', index); + }; + + return ( + + + handleIndexChange('lower', nextIndex, setLowerIndex) + } + onSettle={(index) => handleSettle('lower', index)} + onCloseRequest={closeLower} + scrimColor={MODAL_SCRIM_COLOR} + surface={} + > + + + +