Skip to content

Commit 73c69bd

Browse files
Send Android key events to the tapped view while no view has focus
With `enableKeyEvents` on, a key pressed after a tap that neither types a character nor moves focus reached no JS handler: Android leaves no view focused in touch mode, so it hands the key to no view and `JSKeyDispatcher` has no target. The root view now takes such a key from Android's unhandled-key listener and sends it to the view a browser would hold focused: the nearest focusable React view of the window's last tap, or of the view whose focus entering touch mode took away. A scroll keeps that target; a tap on nothing focusable, or any view taking Android's focus, ends it. Fixes #59537
1 parent dfed2d9 commit 73c69bd

6 files changed

Lines changed: 841 additions & 7 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import com.facebook.react.uimanager.ReactStage;
6767
import com.facebook.react.uimanager.RootView;
6868
import com.facebook.react.uimanager.RootViewUtil;
69+
import com.facebook.react.uimanager.TapFocusTracker;
6970
import com.facebook.react.uimanager.UIManagerHelper;
7071
import com.facebook.react.uimanager.common.UIManagerType;
7172
import com.facebook.react.uimanager.events.EventDispatcher;
@@ -105,6 +106,7 @@ public interface ReactRootViewEventListener {
105106
private @Nullable JSTouchDispatcher mJSTouchDispatcher;
106107
private @Nullable JSPointerDispatcher mJSPointerDispatcher;
107108
private @Nullable JSKeyDispatcher mJSKeyDispatcher;
109+
private @Nullable TapFocusTracker mTapFocusTracker;
108110
private final ReactAndroidHWInputDeviceHelper mAndroidHWInputDeviceHelper =
109111
new ReactAndroidHWInputDeviceHelper();
110112
private boolean mWasMeasured = false;
@@ -259,6 +261,9 @@ public boolean shouldDispatchJSTouchEvent(MotionEvent ev) {
259261

260262
@Override
261263
public boolean onInterceptTouchEvent(MotionEvent ev) {
264+
if (mTapFocusTracker != null) {
265+
mTapFocusTracker.handleMotionEvent(ev);
266+
}
262267
if (shouldDispatchJSTouchEvent(ev)) {
263268
dispatchJSTouchEvent(ev);
264269
}
@@ -268,6 +273,9 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {
268273

269274
@Override
270275
public boolean onTouchEvent(MotionEvent ev) {
276+
if (mTapFocusTracker != null) {
277+
mTapFocusTracker.handleMotionEvent(ev);
278+
}
271279
if (shouldDispatchJSTouchEvent(ev)) {
272280
dispatchJSTouchEvent(ev);
273281
}
@@ -450,6 +458,30 @@ protected void dispatchJSKeyEvent(KeyEvent ev) {
450458
}
451459
}
452460

461+
private void startTrackingKeyEventTargets() {
462+
TapFocusTracker tapFocusTracker = mTapFocusTracker;
463+
if (tapFocusTracker == null) {
464+
if (!ReactNativeFeatureFlags.enableKeyEvents()) {
465+
return;
466+
}
467+
tapFocusTracker = new TapFocusTracker(this);
468+
mTapFocusTracker = tapFocusTracker;
469+
ViewCompat.addOnUnhandledKeyEventListener(this, this::dispatchUnfocusedJSKeyEvent);
470+
}
471+
if (isAttachedToWindow()) {
472+
tapFocusTracker.observeWindow();
473+
}
474+
}
475+
476+
private boolean dispatchUnfocusedJSKeyEvent(View view, KeyEvent keyEvent) {
477+
// A root that is its window's top view gets every key through dispatchKeyEvent, and any root
478+
// gets here a key that a focused view left unhandled, after dispatchKeyEvent sent it.
479+
if (getRootView() != this && getRootView().findFocus() == null) {
480+
dispatchJSKeyEvent(keyEvent);
481+
}
482+
return false;
483+
}
484+
453485
@Override
454486
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
455487
// Override in order to still receive events to onInterceptTouchEvent even when some other
@@ -479,6 +511,7 @@ protected void onAttachedToWindow() {
479511
if (isViewAttachedToReactInstance()) {
480512
removeOnGlobalLayoutListener();
481513
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
514+
startTrackingKeyEventTargets();
482515
}
483516
}
484517

@@ -488,6 +521,9 @@ protected void onDetachedFromWindow() {
488521
if (isViewAttachedToReactInstance()) {
489522
removeOnGlobalLayoutListener();
490523
}
524+
if (mTapFocusTracker != null) {
525+
mTapFocusTracker.stopObservingWindow();
526+
}
491527
}
492528

493529
private void removeOnGlobalLayoutListener() {
@@ -706,7 +742,7 @@ public void onAttachedToReactInstance() {
706742
}
707743

708744
if (ReactNativeFeatureFlags.enableKeyEvents()) {
709-
mJSKeyDispatcher = new JSKeyDispatcher();
745+
mJSKeyDispatcher = new JSKeyDispatcher(this);
710746
}
711747

712748
if (mRootViewEventListener != null) {
@@ -820,6 +856,7 @@ private void attachToReactInstanceManager() {
820856
mIsAttachedToInstance = true;
821857
Assertions.assertNotNull(mReactInstanceManager).attachRootView(this);
822858
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
859+
startTrackingKeyEventTargets();
823860
} finally {
824861
ReactMarker.logMarker(ReactMarkerConstants.ROOT_VIEW_ATTACH_TO_REACT_INSTANCE_MANAGER_END);
825862
Systrace.endSection(TRACE_TAG_REACT);

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceView.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public class ReactSurfaceView(context: Context?, internal val surface: ReactSurf
7474
jsPointerDispatcher = JSPointerDispatcher(this)
7575
}
7676
if (ReactNativeFeatureFlags.enableKeyEvents()) {
77-
jsKeyDispatcher = JSKeyDispatcher()
77+
jsKeyDispatcher = JSKeyDispatcher(this)
7878
}
7979
}
8080

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.facebook.react.uimanager
99

1010
import android.view.KeyEvent as AndroidKeyEvent
1111
import android.view.View
12+
import android.view.ViewGroup
1213
import com.facebook.react.uimanager.events.EventDispatcher
1314
import com.facebook.react.uimanager.events.KeyDownEvent
1415
import com.facebook.react.uimanager.events.KeyUpEvent
@@ -19,17 +20,24 @@ import com.facebook.react.uimanager.events.KeyUpEvent
1920
* phases.
2021
*
2122
* The keydown and keyup events provide a code indicating which key is pressed. The event target is
22-
* derived from the currently focused Android view.
23+
* derived from the currently focused Android view. While no view in the window holds focus, as
24+
* after a touch in touch mode, the target is the view [TapFocusTracker] holds focused for it.
2325
*/
24-
internal class JSKeyDispatcher {
26+
internal class JSKeyDispatcher(private val rootViewGroup: ViewGroup) {
2527
private var focusedViewTag: Int = View.NO_ID
2628

2729
fun handleKeyEvent(
2830
keyEvent: AndroidKeyEvent,
2931
eventDispatcher: EventDispatcher,
3032
surfaceId: Int,
3133
) {
32-
if (focusedViewTag == View.NO_ID) {
34+
val targetTag =
35+
if (rootViewGroup.rootView.findFocus() != null) {
36+
focusedViewTag
37+
} else {
38+
TapFocusTracker.findFocusedView(rootViewGroup)?.id ?: View.NO_ID
39+
}
40+
if (targetTag == View.NO_ID) {
3341
return
3442
}
3543

@@ -38,7 +46,7 @@ internal class JSKeyDispatcher {
3846
eventDispatcher.dispatchEvent(
3947
KeyDownEvent(
4048
surfaceId,
41-
focusedViewTag,
49+
targetTag,
4250
keyEvent,
4351
),
4452
)
@@ -47,7 +55,7 @@ internal class JSKeyDispatcher {
4755
eventDispatcher.dispatchEvent(
4856
KeyUpEvent(
4957
surfaceId,
50-
focusedViewTag,
58+
targetTag,
5159
keyEvent,
5260
),
5361
)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.uimanager
9+
10+
import android.graphics.PointF
11+
import android.view.MotionEvent
12+
import android.view.View
13+
import android.view.ViewConfiguration
14+
import android.view.ViewGroup
15+
import android.view.ViewTreeObserver
16+
import com.facebook.react.R
17+
import java.lang.ref.WeakReference
18+
19+
/**
20+
* Keeps, for the window of a React root, the view a browser would hold focused where Android holds
21+
* none, since Android's touch mode leaves no view focused unless it is focusable in touch mode: the
22+
* nearest focusable React view of the last tap on a React root in the window, or of the view whose
23+
* focus a touch that was no tap took away. A view that takes Android's focus, or held it when
24+
* tapped, holds that focus itself, so the tap's focus ends.
25+
*
26+
* A tap is a touch with one pointer that never moves past the touch slop, so a scroll keeps the
27+
* focus, as it keeps a browser's.
28+
*/
29+
internal class TapFocusTracker(private val rootViewGroup: ViewGroup) :
30+
ViewTreeObserver.OnGlobalFocusChangeListener, ViewTreeObserver.OnTouchModeChangeListener {
31+
private val touchSlop = ViewConfiguration.get(rootViewGroup.context).scaledTouchSlop
32+
private var tapStart: PointF? = null
33+
34+
fun handleMotionEvent(motionEvent: MotionEvent) {
35+
when (motionEvent.actionMasked) {
36+
MotionEvent.ACTION_DOWN -> tapStart = PointF(motionEvent.x, motionEvent.y)
37+
MotionEvent.ACTION_MOVE ->
38+
tapStart = tapStart?.takeIf { start -> isWithinTouchSlop(start, motionEvent) }
39+
MotionEvent.ACTION_UP -> {
40+
if (tapStart != null) {
41+
setFocusedView(findTappedView(motionEvent)?.takeUnless { it.isFocused })
42+
}
43+
tapStart = null
44+
}
45+
else -> tapStart = null
46+
}
47+
}
48+
49+
/** Follows the focus and the touch mode of the root's window, until [stopObservingWindow]. */
50+
fun observeWindow() {
51+
stopObservingWindow()
52+
rootViewGroup.viewTreeObserver.addOnGlobalFocusChangeListener(this)
53+
rootViewGroup.viewTreeObserver.addOnTouchModeChangeListener(this)
54+
}
55+
56+
fun stopObservingWindow() {
57+
rootViewGroup.viewTreeObserver.removeOnGlobalFocusChangeListener(this)
58+
rootViewGroup.viewTreeObserver.removeOnTouchModeChangeListener(this)
59+
}
60+
61+
override fun onGlobalFocusChanged(oldFocus: View?, newFocus: View?) {
62+
if (newFocus != null) {
63+
setFocusedView(null)
64+
}
65+
}
66+
67+
/**
68+
* Android calls this before entering touch mode clears the focus of a view not focusable in it.
69+
*/
70+
override fun onTouchModeChanged(isInTouchMode: Boolean) {
71+
val focusedView = rootViewGroup.findFocus()
72+
if (isInTouchMode && focusedView != null && !focusedView.isFocusableInTouchMode) {
73+
setFocusedView(focusedView.findNearestFocusableReactView())
74+
}
75+
}
76+
77+
private fun isWithinTouchSlop(start: PointF, motionEvent: MotionEvent): Boolean {
78+
val deltaX = motionEvent.x - start.x
79+
val deltaY = motionEvent.y - start.y
80+
return deltaX * deltaX + deltaY * deltaY <= touchSlop * touchSlop
81+
}
82+
83+
private fun findTappedView(motionEvent: MotionEvent): View? =
84+
TouchTargetHelper.findTargetPathAndCoordinatesForTouch(
85+
motionEvent.x,
86+
motionEvent.y,
87+
rootViewGroup,
88+
FloatArray(2),
89+
)
90+
.firstNotNullOfOrNull { target -> target.getView() }
91+
?.findNearestFocusableReactView()
92+
93+
private fun setFocusedView(view: View?) {
94+
rootViewGroup.rootView.setTag(R.id.tap_focused_view, view?.let(::TappedView))
95+
}
96+
97+
private class TappedView(view: View) {
98+
private val viewReference = WeakReference(view)
99+
private val viewTag = view.id
100+
101+
/**
102+
* The tapped view while it could still hold focus and [rootViewGroup] is the closest React root
103+
* above it. A view recycled under another tag since the tap stands for another component.
104+
*/
105+
fun findIn(rootViewGroup: ViewGroup): View? {
106+
val view = viewReference.get() ?: return null
107+
if (view.id != viewTag || !view.isShown || !view.isFocusableReactView()) {
108+
return null
109+
}
110+
var parent = view.parent
111+
while (parent != null && parent !is RootView) {
112+
parent = parent.parent
113+
}
114+
return if (parent === rootViewGroup) view else null
115+
}
116+
}
117+
118+
companion object {
119+
/** The view the window of [rootViewGroup] holds focused for a browser, if it is inside it. */
120+
fun findFocusedView(rootViewGroup: ViewGroup): View? =
121+
(rootViewGroup.rootView.getTag(R.id.tap_focused_view) as? TappedView)?.findIn(rootViewGroup)
122+
}
123+
}
124+
125+
/** A view without a positive id is no React view, so no JS event can name it. */
126+
private fun View.isFocusableReactView(): Boolean = id > 0 && isFocusable && isEnabled
127+
128+
private fun View.findNearestFocusableReactView(): View? =
129+
generateSequence(this) { it.parent as? View }.firstOrNull { it.isFocusableReactView() }

‎packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,7 @@
8888

8989
<!-- tag is used to store the safe area insets observer attached to the view -->
9090
<item type="id" name="safe_area_insets_observer"/>
91+
92+
<!-- tag is used to store the view the last tap in the window focused -->
93+
<item type="id" name="tap_focused_view"/>
9194
</resources>

0 commit comments

Comments
 (0)