Skip to content

Commit 563abd4

Browse files
Send accessibility hover events from ReactViewGroup when pointer events are dispatched
With ReactFeatureFlags.dispatchPointerEvents on, ReactViewGroup.onHoverEvent returns its touch-target answer without calling View.onHoverEvent, which is where Android sends TYPE_VIEW_HOVER_ENTER and TYPE_VIEW_HOVER_EXIT. TalkBack's explore-by-touch turns a finger into hover events and focuses the view that sends them, so a React Native view under the finger stops announcing itself. ReactViewGroup now runs the View implementation for its side effects and keeps returning the touch-target answer #46445 introduced.
1 parent 55c951c commit 563abd4

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@ public open class ReactViewGroup public constructor(context: Context?) :
310310
override fun onHoverEvent(event: MotionEvent): Boolean {
311311
@Suppress("DEPRECATION")
312312
if (ReactFeatureFlags.dispatchPointerEvents) {
313+
// View's own handling sends the accessibility hover events explore-by-touch relies on
314+
super.onHoverEvent(event)
313315
// Match the logic from onTouchEvent if pointer events are enabled
314316
return canBeTouchTarget(pointerEvents)
315317
}

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,24 @@ package com.facebook.react.views.view
99

1010
import android.app.Activity
1111
import android.content.Context
12+
import android.view.MotionEvent
1213
import android.view.View
1314
import android.view.ViewGroup
15+
import android.view.accessibility.AccessibilityEvent
1416
import android.widget.FrameLayout
1517
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
1618
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
1719
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
1820
import com.facebook.react.uimanager.HasElevatedDescendantCache
21+
import com.facebook.react.uimanager.PointerEvents
1922
import org.assertj.core.api.Assertions.assertThat
23+
import org.junit.After
2024
import org.junit.Before
2125
import org.junit.Test
2226
import org.junit.runner.RunWith
2327
import org.robolectric.Robolectric
2428
import org.robolectric.RobolectricTestRunner
29+
import org.robolectric.shadows.ShadowLooper
2530

2631
@RunWith(RobolectricTestRunner::class)
2732
class ReactViewGroupTest {
@@ -34,6 +39,32 @@ class ReactViewGroupTest {
3439
context = Robolectric.buildActivity(Activity::class.java).create().get()
3540
}
3641

42+
@After
43+
fun tearDown() {
44+
setDispatchPointerEvents(false)
45+
}
46+
47+
@Test
48+
fun `hover with pointer events dispatched still sends the accessibility hover event`() {
49+
setDispatchPointerEvents(true)
50+
val rvg = attachForHover()
51+
val sentEventTypes = recordSentAccessibilityEvents(rvg)
52+
53+
rvg.onHoverEvent(obtainHoverEvent(MotionEvent.ACTION_HOVER_ENTER))
54+
55+
assertThat(sentEventTypes).contains(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER)
56+
}
57+
58+
@Test
59+
fun `hover with pointer events dispatched still answers whether the view is a touch target`() {
60+
setDispatchPointerEvents(true)
61+
val rvg = attachForHover()
62+
63+
assertThat(rvg.onHoverEvent(obtainHoverEvent(MotionEvent.ACTION_HOVER_MOVE))).isTrue()
64+
rvg.pointerEvents = PointerEvents.NONE
65+
assertThat(rvg.onHoverEvent(obtainHoverEvent(MotionEvent.ACTION_HOVER_MOVE))).isFalse()
66+
}
67+
3768
@Test
3869
fun `View clipping - ensure allChildren properly resizes when adding views in sequence`() {
3970
val rvg = ReactViewGroup(context)
@@ -280,6 +311,37 @@ class ReactViewGroupTest {
280311
}
281312

282313
private fun elevatedChild(): View = View(context).apply { elevation = 10f }
314+
315+
@Suppress("DEPRECATION")
316+
private fun setDispatchPointerEvents(enabled: Boolean) {
317+
com.facebook.react.config.ReactFeatureFlags.dispatchPointerEvents = enabled
318+
}
319+
320+
// An accessibility hover event is sent only from a view attached to a window.
321+
private fun attachForHover(): ReactViewGroup {
322+
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
323+
val rvg = ReactViewGroup(activity)
324+
rvg.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
325+
activity.setContentView(rvg)
326+
ShadowLooper.idleMainLooper()
327+
rvg.layout(0, 0, 100, 100)
328+
assertThat(rvg.isAttachedToWindow).isTrue()
329+
return rvg
330+
}
331+
332+
private fun recordSentAccessibilityEvents(view: View): List<Int> {
333+
val sentEventTypes = mutableListOf<Int>()
334+
view.accessibilityDelegate =
335+
object : View.AccessibilityDelegate() {
336+
override fun sendAccessibilityEvent(host: View, eventType: Int) {
337+
sentEventTypes.add(eventType)
338+
}
339+
}
340+
return sentEventTypes
341+
}
342+
343+
private fun obtainHoverEvent(action: Int): MotionEvent =
344+
MotionEvent.obtain(0L, 0L, action, 50f, 50f, 0)
283345
}
284346

285347
class TestView(context: Context, yPos: Int) : View(context) {

0 commit comments

Comments
 (0)