Skip to content

Commit a783e6a

Browse files
fix: reuse isTouchPointInView, listen to a11y events instead of polling
fix: clean up unncessary bounds checking fix: use listener instead
1 parent 3550da1 commit a783e6a

2 files changed

Lines changed: 57 additions & 31 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ public object TouchTargetHelper {
252252
* Checks whether a touch at {@code x} and {@code y} are within the bounds of the View. Both
253253
* {@code x} and {@code y} must be relative to the top-left corner of the view.
254254
*/
255-
private fun isTouchPointInView(x: Float, y: Float, view: View): Boolean {
255+
@JvmStatic
256+
public fun isTouchPointInView(x: Float, y: Float, view: View): Boolean {
256257
val hitSlopRect = (view as? ReactHitSlopView)?.hitSlopRect
257258
if (hitSlopRect != null) {
258259
if (

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

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import com.facebook.react.uimanager.ReactClippingViewGroupHelper.calculateClippi
5757
import com.facebook.react.uimanager.ReactOverflowViewWithInset
5858
import com.facebook.react.uimanager.ReactPointerEventsView
5959
import com.facebook.react.uimanager.ReactZIndexedViewGroup
60+
import com.facebook.react.uimanager.TouchTargetHelper
6061
import com.facebook.react.uimanager.ViewGroupDrawingOrderHelper
6162
import com.facebook.react.uimanager.common.UIManagerType
6263
import com.facebook.react.uimanager.common.ViewUtil.getUIManagerType
@@ -153,6 +154,10 @@ public open class ReactViewGroup public constructor(context: Context?) :
153154
private var accessibilityStateChangeListener:
154155
AccessibilityManager.AccessibilityStateChangeListener? =
155156
null
157+
private var touchExplorationStateChangeListener:
158+
AccessibilityManager.TouchExplorationStateChangeListener? =
159+
null
160+
private var isTouchExplorationEnabled = false
156161

157162
init {
158163
initView()
@@ -181,6 +186,8 @@ public open class ReactViewGroup public constructor(context: Context?) :
181186
backfaceOpacity = 1f
182187
backfaceVisible = true
183188
childrenRemovedWhileTransitioning = null
189+
touchExplorationStateChangeListener = null
190+
isTouchExplorationEnabled = false
184191
}
185192

186193
internal open fun recycleView() {
@@ -308,8 +315,8 @@ public open class ReactViewGroup public constructor(context: Context?) :
308315
// For accessibility services (TalkBack), check if hover is within any child's hitSlop area.
309316
// Only apply this logic when accessibility services are enabled to avoid interfering with
310317
// other input methods (VR, mouse, stylus, etc.)
311-
val accessibilityManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
312-
if (accessibilityManager?.isTouchExplorationEnabled == true &&
318+
// Use cached value to avoid expensive Binder call per frame
319+
if (isTouchExplorationEnabled &&
313320
ev.isFromSource(android.view.InputDevice.SOURCE_CLASS_POINTER) &&
314321
(ev.action == MotionEvent.ACTION_HOVER_ENTER || ev.action == MotionEvent.ACTION_HOVER_MOVE)) {
315322
val x = ev.x
@@ -330,34 +337,23 @@ public open class ReactViewGroup public constructor(context: Context?) :
330337
val childX = x - child.left
331338
val childY = y - child.top
332339

333-
// Check if within hitSlop-extended bounds
334-
if (childX >= -hitSlopRect.left &&
335-
childX < child.width + hitSlopRect.right &&
336-
childY >= -hitSlopRect.top &&
337-
childY < child.height + hitSlopRect.bottom) {
338-
339-
// Only intercept if OUTSIDE normal bounds but WITHIN hitSlop
340-
// This prevents interfering with normal child event handling
341-
val inNormalBounds = childX >= 0 && childX < child.width &&
342-
childY >= 0 && childY < child.height
343-
344-
if (!inNormalBounds) {
345-
// For TalkBack accessibility, request focus on the child
346-
if (ev.action == MotionEvent.ACTION_HOVER_ENTER) {
347-
child.performAccessibilityAction(
348-
android.view.accessibility.AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS,
349-
null
350-
)
351-
return true
352-
}
353-
// Transform event coordinates to child's coordinate system
354-
ev.offsetLocation(-child.left.toFloat(), -child.top.toFloat())
355-
val handled = child.dispatchGenericMotionEvent(ev)
356-
// Restore original coordinates
357-
ev.offsetLocation(child.left.toFloat(), child.top.toFloat())
358-
if (handled) {
359-
return true
360-
}
340+
// Use TouchTargetHelper to check if within hitSlop-extended bounds
341+
if (TouchTargetHelper.isTouchPointInView(childX, childY, child)) {
342+
// For TalkBack accessibility, request focus on the child
343+
if (ev.action == MotionEvent.ACTION_HOVER_ENTER) {
344+
child.performAccessibilityAction(
345+
android.view.accessibility.AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS,
346+
null
347+
)
348+
return true
349+
}
350+
// Transform event coordinates to child's coordinate system
351+
ev.offsetLocation(-child.left.toFloat(), -child.top.toFloat())
352+
val handled = child.dispatchGenericMotionEvent(ev)
353+
// Restore original coordinates
354+
ev.offsetLocation(child.left.toFloat(), child.top.toFloat())
355+
if (handled) {
356+
return true
361357
}
362358
}
363359
}
@@ -632,6 +628,35 @@ public open class ReactViewGroup public constructor(context: Context?) :
632628
if (_removeClippedSubviews) {
633629
updateClippingRect()
634630
}
631+
632+
// Initialize touch exploration state and register listener
633+
val accessibilityManager =
634+
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
635+
if (accessibilityManager != null) {
636+
// Query current state once and cache it
637+
isTouchExplorationEnabled = accessibilityManager.isTouchExplorationEnabled
638+
639+
// Register listener for future changes
640+
if (touchExplorationStateChangeListener == null) {
641+
val listener =
642+
AccessibilityManager.TouchExplorationStateChangeListener { enabled ->
643+
isTouchExplorationEnabled = enabled
644+
}
645+
touchExplorationStateChangeListener = listener
646+
accessibilityManager.addTouchExplorationStateChangeListener(listener)
647+
}
648+
}
649+
}
650+
651+
override fun onDetachedFromWindow() {
652+
super.onDetachedFromWindow()
653+
654+
// Unregister touch exploration listener to avoid memory leaks
655+
val accessibilityManager =
656+
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
657+
touchExplorationStateChangeListener?.let {
658+
accessibilityManager?.removeTouchExplorationStateChangeListener(it)
659+
}
635660
}
636661

637662
private fun customDrawOrderDisabled(): Boolean {

0 commit comments

Comments
 (0)