Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -200,6 +204,7 @@ public open class ReactViewGroup public constructor(context: Context?) :
backfaceOpacity = 1f
backfaceVisible = true
childrenRemovedWhileTransitioning = null
accessibilityHoverSource = null
nativeBackgroundMap = null
nativeForegroundMap = null
}
Expand All @@ -216,6 +221,8 @@ public open class ReactViewGroup public constructor(context: Context?) :
}
}

sendPendingAccessibilityHoverExit()

// Set default field values
initView()
overflowInset.setEmpty()
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<Int>()

override fun sendAccessibilityEvent(eventType: Int) {
sentAccessibilityEvents.add(eventType)
super.sendAccessibilityEvent(eventType)
}
}

class TestView(context: Context, yPos: Int) : View(context) {
Expand Down
Loading