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
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down