diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt index 6cc469efb630..e92ee0da8695 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt @@ -252,7 +252,8 @@ public object TouchTargetHelper { * Checks whether a touch at {@code x} and {@code y} are within the bounds of the View. Both * {@code x} and {@code y} must be relative to the top-left corner of the view. */ - private fun isTouchPointInView(x: Float, y: Float, view: View): Boolean { + @JvmStatic + public fun isTouchPointInView(x: Float, y: Float, view: View): Boolean { val hitSlopRect = (view as? ReactHitSlopView)?.hitSlopRect if (hitSlopRect != null) { if ( 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 c47f67ce0361..c5e40670cff7 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 @@ -57,6 +57,7 @@ import com.facebook.react.uimanager.ReactClippingViewGroupHelper.calculateClippi import com.facebook.react.uimanager.ReactOverflowViewWithInset import com.facebook.react.uimanager.ReactPointerEventsView import com.facebook.react.uimanager.ReactZIndexedViewGroup +import com.facebook.react.uimanager.TouchTargetHelper import com.facebook.react.uimanager.ViewGroupDrawingOrderHelper import com.facebook.react.uimanager.common.UIManagerType import com.facebook.react.uimanager.common.ViewUtil.getUIManagerType @@ -153,6 +154,10 @@ public open class ReactViewGroup public constructor(context: Context?) : private var accessibilityStateChangeListener: AccessibilityManager.AccessibilityStateChangeListener? = null + private var touchExplorationStateChangeListener: + AccessibilityManager.TouchExplorationStateChangeListener? = + null + private var isTouchExplorationEnabled = false init { initView() @@ -181,6 +186,8 @@ public open class ReactViewGroup public constructor(context: Context?) : backfaceOpacity = 1f backfaceVisible = true childrenRemovedWhileTransitioning = null + touchExplorationStateChangeListener = null + isTouchExplorationEnabled = false } internal open fun recycleView() { @@ -305,6 +312,55 @@ public open class ReactViewGroup public constructor(context: Context?) : return false } + // For accessibility services (TalkBack), check if hover is within any child's hitSlop area. + // Only apply this logic when accessibility services are enabled to avoid interfering with + // other input methods (VR, mouse, stylus, etc.) + // Use cached value to avoid expensive Binder call per frame + if (isTouchExplorationEnabled && + ev.isFromSource(android.view.InputDevice.SOURCE_CLASS_POINTER) && + (ev.action == MotionEvent.ACTION_HOVER_ENTER || ev.action == MotionEvent.ACTION_HOVER_MOVE)) { + val x = ev.x + val y = ev.y + + // Check each child in reverse order (front-to-back, matching touch behavior) + for (i in childCount - 1 downTo 0) { + val child = getChildAt(i) + if (child == null || child.visibility != VISIBLE) { + continue + } + + // Check if child has hitSlop + if (child is ReactHitSlopView) { + val hitSlopRect = child.hitSlopRect + if (hitSlopRect != null) { + // Calculate child-relative coordinates + val childX = x - child.left + val childY = y - child.top + + // Use TouchTargetHelper to check if within hitSlop-extended bounds + if (TouchTargetHelper.isTouchPointInView(childX, childY, child)) { + // For TalkBack accessibility, request focus on the child + if (ev.action == MotionEvent.ACTION_HOVER_ENTER) { + child.performAccessibilityAction( + android.view.accessibility.AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, + null + ) + return true + } + // Transform event coordinates to child's coordinate system + ev.offsetLocation(-child.left.toFloat(), -child.top.toFloat()) + val handled = child.dispatchGenericMotionEvent(ev) + // Restore original coordinates + ev.offsetLocation(child.left.toFloat(), child.top.toFloat()) + if (handled) { + return true + } + } + } + } + } + } + return super.dispatchGenericMotionEvent(ev) } @@ -572,6 +628,35 @@ public open class ReactViewGroup public constructor(context: Context?) : if (_removeClippedSubviews) { updateClippingRect() } + + // Initialize touch exploration state and register listener + val accessibilityManager = + context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + if (accessibilityManager != null) { + // Query current state once and cache it + isTouchExplorationEnabled = accessibilityManager.isTouchExplorationEnabled + + // Register listener for future changes + if (touchExplorationStateChangeListener == null) { + val listener = + AccessibilityManager.TouchExplorationStateChangeListener { enabled -> + isTouchExplorationEnabled = enabled + } + touchExplorationStateChangeListener = listener + accessibilityManager.addTouchExplorationStateChangeListener(listener) + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + // Unregister touch exploration listener to avoid memory leaks + val accessibilityManager = + context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + touchExplorationStateChangeListener?.let { + accessibilityManager?.removeTouchExplorationStateChangeListener(it) + } } private fun customDrawOrderDisabled(): Boolean {