From a9b2ac472a361454f6ef0ae43e439683f43bd7f5 Mon Sep 17 00:00:00 2001 From: Nicola Corti Date: Fri, 13 Nov 2026 05:40:51 -0800 Subject: [PATCH] Send accessibility hover events from ReactViewGroup when pointer events are enabled (#59992) Summary: When `ReactFeatureFlags.dispatchPointerEvents` is enabled, `ReactViewGroup.onHoverEvent` returned `canBeTouchTarget(pointerEvents)` without calling `View.onHoverEvent`, so React Native views never sent `TYPE_VIEW_HOVER_ENTER`/`EXIT` accessibility events. Hover-driven accessibility services (for example hover text-to-speech) were silent over the body of a pressable view, while its plain text and image children, which don't override `onHoverEvent`, still spoke. Just calling `super.onHoverEvent` is not enough: a pressable often contains a full-size `pointerEvents="none"` overlay child, and `View.onHoverEvent` skips accessibility hover while any child is hovered. This change makes `ReactViewGroup` send hover enter/exit itself, from its nearest important-for-accessibility ancestor, when it can be a touch target. It stays silent while a child that can take the hover is under the pointer. `pointerEvents="none"` children are ignored and `box-none` children are checked one level deeper. The view remembers which view received HOVER_ENTER and sends the matching HOVER_EXIT to that same view, so enter/exit stay paired even if no ancestor was important for accessibility at enter time or the important ancestor changes mid-hover. A pending HOVER_EXIT is also sent when the view is recycled or detached from the window mid-hover. Behavior with pointer events disabled is unchanged. Note for reviewers: a plain hovered container now emits hover enter/exit the way stock Android does with the flag off. Changelog: [Android][Fixed] - Send accessibility hover events from `ReactViewGroup` when pointer events dispatch is enabled Differential Revision: D131164392 --- .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/views/view/ReactViewGroup.kt | 85 +++++++- .../react/views/view/ReactViewGroupTest.kt | 205 ++++++++++++++++++ 3 files changed, 290 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 9f62f0c9b496..2291352ff0a6 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6467,6 +6467,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro public fun invalidateElevatedDescendantCache ()Z protected fun onAttachedToWindow ()V protected fun onConfigurationChanged (Landroid/content/res/Configuration;)V + protected fun onDetachedFromWindow ()V public fun onHoverEvent (Landroid/view/MotionEvent;)Z public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z protected fun onLayout (ZIIII)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 437baa1f2591..9f78deac9ce1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -15,6 +15,7 @@ import android.content.Context import android.content.res.Configuration import android.graphics.BlendMode import android.graphics.Canvas +import android.graphics.Matrix import android.graphics.Paint import android.graphics.Rect import android.graphics.drawable.Drawable @@ -23,6 +24,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewStructure +import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager import com.facebook.common.logging.FLog import com.facebook.react.R @@ -159,6 +161,8 @@ public open class ReactViewGroup public constructor(context: Context?) : AccessibilityManager.AccessibilityStateChangeListener? = null private var focusOnAttach = false + // Receives the HOVER_EXIT that pairs with the last accessibility HOVER_ENTER. + private var accessibilityHoverSource: View? = null internal var nativeBackgroundMap: ReadableMap? = null internal var nativeForegroundMap: ReadableMap? = null @@ -200,6 +204,7 @@ public open class ReactViewGroup public constructor(context: Context?) : backfaceOpacity = 1f backfaceVisible = true childrenRemovedWhileTransitioning = null + accessibilityHoverSource = null nativeBackgroundMap = null nativeForegroundMap = null } @@ -216,6 +221,8 @@ public open class ReactViewGroup public constructor(context: Context?) : } } + sendPendingAccessibilityHoverExit() + // Set default field values initView() overflowInset.setEmpty() @@ -312,11 +319,82 @@ public open class ReactViewGroup public constructor(context: Context?) : @Suppress("DEPRECATION") if (ReactFeatureFlags.dispatchPointerEvents) { // Match the logic from onTouchEvent if pointer events are enabled - return canBeTouchTarget(pointerEvents) + val canBeTarget = canBeTouchTarget(pointerEvents) + if (canBeTarget || accessibilityHoverSource != null) { + updateAccessibilityHoverState(event) + } + return canBeTarget } return super.onHoverEvent(event) } + /** + * Sends the accessibility hover events that [View.onHoverEvent] would send, which hover-based + * screen readers rely on. [View.onHoverEvent] can't be used here because it skips them while any + * child is under the pointer, including children that pointer events exclude from hit testing. + */ + private fun updateAccessibilityHoverState(event: MotionEvent) { + val x = event.x + val y = event.y + val isHovered = + event.actionMasked != MotionEvent.ACTION_HOVER_EXIT && + x >= 0 && + y >= 0 && + x < width && + y < height && + !hasHoverTargetChildAt(this, x, y) + if (accessibilityHoverSource != null) { + if (!isHovered) { + sendPendingAccessibilityHoverExit() + } + return + } + if (!isHovered) { + return + } + var source: View? = this + while (source != null && !source.isImportantForAccessibility) { + source = source.parent as? View + } + if (source != null) { + accessibilityHoverSource = source + source.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) + } + } + + private fun sendPendingAccessibilityHoverExit() { + val hoverSource = accessibilityHoverSource ?: return + accessibilityHoverSource = null + hoverSource.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT) + } + + private fun hasHoverTargetChildAt(parent: ViewGroup, x: Float, y: Float): Boolean { + for (i in parent.childCount - 1 downTo 0) { + val child = parent.getChildAt(i) + if (child.visibility != VISIBLE) { + continue + } + val point = floatArrayOf(x + parent.scrollX - child.left, y + parent.scrollY - child.top) + if (!child.matrix.isIdentity) { + val inverse = Matrix() + child.matrix.invert(inverse) + inverse.mapPoints(point) + } + if (point[0] < 0 || point[1] < 0 || point[0] >= child.width || point[1] >= child.height) { + continue + } + when ((child as? ReactPointerEventsView)?.pointerEvents ?: PointerEvents.AUTO) { + PointerEvents.NONE -> continue + PointerEvents.BOX_NONE -> + if (child is ViewGroup && hasHoverTargetChildAt(child, point[0], point[1])) { + return true + } + else -> return true + } + } + return false + } + override fun dispatchGenericMotionEvent(ev: MotionEvent): Boolean { // We do not dispatch the motion event if its children are not supposed to receive it if (!canChildrenBeTouchTarget(pointerEvents)) { @@ -675,6 +753,11 @@ public open class ReactViewGroup public constructor(context: Context?) : } } + override fun onDetachedFromWindow() { + sendPendingAccessibilityHoverExit() + super.onDetachedFromWindow() + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) if (nativeBackgroundMap != null) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt index 96bb5a9eec8f..34d6dab17d21 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt @@ -12,13 +12,18 @@ import android.content.Context import android.graphics.BlendMode import android.graphics.Canvas import android.graphics.Paint +import android.view.InputDevice +import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent import android.widget.FrameLayout import com.facebook.react.R import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests import com.facebook.react.uimanager.HasElevatedDescendantCache +import com.facebook.react.uimanager.PointerEvents import org.assertj.core.api.Assertions.assertThat +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -36,6 +41,177 @@ class ReactViewGroupTest { context = Robolectric.buildActivity(Activity::class.java).create().get() } + @After + fun tearDown() { + @Suppress("DEPRECATION") + com.facebook.react.config.ReactFeatureFlags.dispatchPointerEvents = false + } + + @Test + fun `hover with pointer events - sends accessibility hover enter and exit`() { + enablePointerEvents() + val rvg = hoverableView() + + assertThat(rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_ENTER, 10f))).isTrue() + assertThat(rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 20f))).isTrue() + assertThat(rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_EXIT, 20f))).isTrue() + + assertThat(rvg.sentAccessibilityEvents) + .containsExactly( + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + ) + } + + @Test + fun `hover with pointer events - sends hover exit when the pointer moves out of the view`() { + enablePointerEvents() + val rvg = hoverableView() + + rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)) + rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 150f)) + + assertThat(rvg.sentAccessibilityEvents) + .containsExactly( + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + ) + } + + @Test + fun `hover with pointer events - a hovered child attributes the hover to its accessible parent`() { + enablePointerEvents() + val parent = hoverableView() + val overlay = ReactViewGroup(parent.context) + parent.addView(overlay) + overlay.layout(0, 0, 100, 100) + + assertThat(overlay.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_ENTER, 10f))).isTrue() + + assertThat(parent.sentAccessibilityEvents) + .containsExactly(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) + } + + @Test + fun `hover with pointer events - a child with pointerEvents none does not block the parent hover`() { + enablePointerEvents() + val parent = hoverableView() + val overlay = ReactViewGroup(parent.context).apply { pointerEvents = PointerEvents.NONE } + parent.addView(overlay) + overlay.layout(0, 0, 100, 100) + + assertThat( + parent.dispatchGenericMotionEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)), + ) + .isTrue() + + assertThat(parent.sentAccessibilityEvents) + .containsExactly(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) + } + + @Test + fun `hover with pointer events - a child under the pointer keeps the parent hover silent`() { + enablePointerEvents() + val parent = hoverableView() + val label = View(parent.context) + parent.addView(label) + label.layout(0, 0, 50, 50) + + parent.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)) + assertThat(parent.sentAccessibilityEvents).isEmpty() + + parent.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 80f)) + assertThat(parent.sentAccessibilityEvents) + .containsExactly(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) + } + + @Test + fun `hover with pointer events - a view that cannot be a touch target sends no hover events`() { + enablePointerEvents() + val rvg = hoverableView() + rvg.pointerEvents = PointerEvents.BOX_NONE + + assertThat(rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_ENTER, 10f))).isFalse() + + assertThat(rvg.sentAccessibilityEvents).isEmpty() + } + + @Test + fun `hover with pointer events - a view with no accessible ancestor sends enter once it becomes accessible`() { + enablePointerEvents() + val rvg = AccessibilityEventRecordingViewGroup(context) + rvg.layout(0, 0, 100, 100) + + rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)) + assertThat(rvg.sentAccessibilityEvents).isEmpty() + + rvg.contentDescription = "Button" + rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 20f)) + rvg.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_EXIT, 20f)) + + assertThat(rvg.sentAccessibilityEvents) + .containsExactly( + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + ) + } + + @Test + fun `hover with pointer events - hover exit goes to the view that got hover enter`() { + enablePointerEvents() + val parent = hoverableView() + val overlay = AccessibilityEventRecordingViewGroup(parent.context) + parent.addView(overlay) + overlay.layout(0, 0, 100, 100) + + overlay.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)) + overlay.contentDescription = "Overlay" + overlay.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_EXIT, 10f)) + + assertThat(parent.sentAccessibilityEvents) + .containsExactly( + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + ) + assertThat(overlay.sentAccessibilityEvents).isEmpty() + } + + @Test + fun `hover with pointer events - recycling a hovered view sends the pending hover exit`() { + enablePointerEvents() + val parent = hoverableView() + val overlay = ReactViewGroup(parent.context) + parent.addView(overlay) + overlay.layout(0, 0, 100, 100) + + overlay.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)) + overlay.recycleView() + + assertThat(parent.sentAccessibilityEvents) + .containsExactly( + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + ) + } + + @Test + fun `hover with pointer events - detaching a hovered view sends the pending hover exit`() { + enablePointerEvents() + val parent = hoverableView() + val overlay = ReactViewGroup(parent.context) + parent.addView(overlay) + overlay.layout(0, 0, 100, 100) + + overlay.onHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_MOVE, 10f)) + parent.removeView(overlay) + + assertThat(parent.sentAccessibilityEvents) + .containsExactly( + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + ) + } + @Test fun `View clipping - ensure allChildren properly resizes when adding views in sequence`() { val rvg = ReactViewGroup(context) @@ -278,6 +454,35 @@ class ReactViewGroupTest { } private fun elevatedChild(): View = View(context).apply { elevation = 10f } + + private fun enablePointerEvents() { + @Suppress("DEPRECATION") + com.facebook.react.config.ReactFeatureFlags.dispatchPointerEvents = true + } + + // Accessibility hover events are only sent from views attached to a window. + private fun hoverableView(): AccessibilityEventRecordingViewGroup { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val view = + AccessibilityEventRecordingViewGroup(activity).apply { contentDescription = "Button" } + activity.setContentView(view) + view.layout(0, 0, 100, 100) + return view + } + + private fun hoverEvent(action: Int, position: Float): MotionEvent = + MotionEvent.obtain(0, 0, action, position, position, 0).apply { + source = InputDevice.SOURCE_MOUSE + } +} + +private class AccessibilityEventRecordingViewGroup(context: Context) : ReactViewGroup(context) { + val sentAccessibilityEvents = mutableListOf() + + override fun sendAccessibilityEvent(eventType: Int) { + sentAccessibilityEvents.add(eventType) + super.sendAccessibilityEvent(eventType) + } } class TestView(context: Context, yPos: Int) : View(context) {