Skip to content

Commit 79b09ce

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Add onKeyDown/onKeyUp support to Android (#54308)
Summary: Pull Request resolved: #54308 Adds support for `onKeyDown`/`onKeyUp` events for Android views. Since views can only retain one key listener at a time, to avoid stomping over existing implementations, this change relies upon the same logic that `ReactAndroidHWInputDeviceHelper` uses to globally dispatch key and focus events: through the `ReactRootView`. The event payload is based on [a subset of web's W3C spec](https://w3c.github.io/uievents/split/keyboard-events.html#events-keyboardevents), intentionally omitting legacy charCode/keyCode fields. The payload contains: - boolean values for all four modifier keys, - the key property [according to spec](https://w3c.github.io/uievents/split/keyboard-events.html#dom-keyboardeventinit-key), and - the code property [according to spec](https://w3c.github.io/uievents/split/keyboard-events.html#dom-keyboardeventinit-code), mapping Android keycode values to [this table](https://w3c.github.io/uievents-code/#code-value-tables) The event payload on JS also omits the `repeat` and `isComposing` fields for the sake of compatibility with desktop implementations. This change is gated behind a new feature flag introduced in #54295 called `enableKeyEvents` Changelog: [Android][Added] - Add onKeyDown/onKeyUp support to Android Reviewed By: alanleedev, sammy-SC Differential Revision: D85022034 fbshipit-source-id: dc04979b9ed84a1754fd9c1dce1b2456b0a8c26b
1 parent 630f80c commit 79b09ce

File tree

13 files changed

+493
-27
lines changed

13 files changed

+493
-27
lines changed

packages/react-native/Libraries/Components/View/ViewPropTypes.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type {
1616
BlurEvent,
1717
FocusEvent,
1818
GestureResponderEvent,
19+
KeyDownEvent,
20+
KeyUpEvent,
1921
LayoutChangeEvent,
2022
LayoutRectangle,
2123
MouseEvent,
@@ -115,6 +117,13 @@ type FocusEventProps = $ReadOnly<{
115117
onFocusCapture?: ?(event: FocusEvent) => void,
116118
}>;
117119

120+
type KeyEventProps = $ReadOnly<{
121+
onKeyDown?: ?(event: KeyDownEvent) => void,
122+
onKeyDownCapture?: ?(event: KeyDownEvent) => void,
123+
onKeyUp?: ?(event: KeyUpEvent) => void,
124+
onKeyUpCapture?: ?(event: KeyUpEvent) => void,
125+
}>;
126+
118127
type TouchEventProps = $ReadOnly<{
119128
onTouchCancel?: ?(e: GestureResponderEvent) => void,
120129
onTouchCancelCapture?: ?(e: GestureResponderEvent) => void,
@@ -505,6 +514,7 @@ export type ViewProps = $ReadOnly<{
505514
...MouseEventProps,
506515
...PointerEventProps,
507516
...FocusEventProps,
517+
...KeyEventProps,
508518
...TouchEventProps,
509519
...ViewPropsAndroid,
510520
...ViewPropsIOS,

packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,18 @@ const bubblingEventTypes = {
122122
bubbled: 'onFocus',
123123
},
124124
},
125+
topKeyDown: {
126+
phasedRegistrationNames: {
127+
captured: 'onKeyDownCapture',
128+
bubbled: 'onKeyDown',
129+
},
130+
},
131+
topKeyUp: {
132+
phasedRegistrationNames: {
133+
captured: 'onKeyUpCapture',
134+
bubbled: 'onKeyUp',
135+
},
136+
},
125137
};
126138

127139
const directEventTypes = {

packages/react-native/Libraries/Types/CoreEventTypes.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,34 @@ export type MouseEvent = NativeSyntheticEvent<
322322
timestamp: number,
323323
}>,
324324
>;
325+
326+
export type KeyEvent = $ReadOnly<{
327+
/**
328+
* The actual key that was pressed. For example, F would be "f" or "F" depending on the shift key.
329+
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
330+
*/
331+
key: string,
332+
/**
333+
* The key code of the key that was pressed. For example, F would be "KeyF"
334+
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
335+
*/
336+
code: string,
337+
altKey: boolean,
338+
ctrlKey: boolean,
339+
metaKey: boolean,
340+
shiftKey: boolean,
341+
/**
342+
* A boolean value that is true if the given key is being held down such that it is automatically repeating.
343+
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat
344+
*/
345+
repeat?: boolean,
346+
/**
347+
* Returns a boolean value indicating if the event is fired within a composition session
348+
* @see https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent/isComposing
349+
*/
350+
isComposing?: boolean,
351+
}>;
352+
353+
export type KeyUpEvent = NativeSyntheticEvent<KeyEvent>;
354+
355+
export type KeyDownEvent = NativeSyntheticEvent<KeyEvent>;

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ public class com/facebook/react/ReactRootView : android/widget/FrameLayout, com/
380380
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
381381
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;I)V
382382
protected fun dispatchDraw (Landroid/graphics/Canvas;)V
383+
protected fun dispatchJSKeyEvent (Landroid/view/KeyEvent;)V
383384
protected fun dispatchJSPointerEvent (Landroid/view/MotionEvent;Z)V
384385
protected fun dispatchJSTouchEvent (Landroid/view/MotionEvent;)V
385386
public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z
@@ -3121,6 +3122,7 @@ public final class com/facebook/react/runtime/ReactSurfaceView : com/facebook/re
31213122
public fun isViewAttachedToReactInstance ()Z
31223123
public fun onChildEndedNativeGesture (Landroid/view/View;Landroid/view/MotionEvent;)V
31233124
public fun onChildStartedNativeGesture (Landroid/view/View;Landroid/view/MotionEvent;)V
3125+
public fun requestChildFocus (Landroid/view/View;Landroid/view/View;)V
31243126
public fun requestDisallowInterceptTouchEvent (Z)V
31253127
public fun setIsFabric (Z)V
31263128
}

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

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import com.facebook.react.modules.deviceinfo.DeviceInfoModule;
5858
import com.facebook.react.uimanager.DisplayMetricsHolder;
5959
import com.facebook.react.uimanager.IllegalViewOperationException;
60+
import com.facebook.react.uimanager.JSKeyDispatcher;
6061
import com.facebook.react.uimanager.JSPointerDispatcher;
6162
import com.facebook.react.uimanager.JSTouchDispatcher;
6263
import com.facebook.react.uimanager.PixelUtil;
@@ -105,6 +106,7 @@ public interface ReactRootViewEventListener {
105106
private boolean mShouldLogContentAppeared;
106107
private @Nullable JSTouchDispatcher mJSTouchDispatcher;
107108
private @Nullable JSPointerDispatcher mJSPointerDispatcher;
109+
private @Nullable JSKeyDispatcher mJSKeyDispatcher;
108110
private final ReactAndroidHWInputDeviceHelper mAndroidHWInputDeviceHelper =
109111
new ReactAndroidHWInputDeviceHelper();
110112
private boolean mWasMeasured = false;
@@ -333,10 +335,17 @@ public boolean dispatchKeyEvent(KeyEvent ev) {
333335
FLog.w(TAG, "Unable to handle key event as the catalyst instance has not been attached");
334336
return super.dispatchKeyEvent(ev);
335337
}
338+
336339
ReactContext context = getCurrentReactContext();
337-
if (context != null) {
338-
mAndroidHWInputDeviceHelper.handleKeyEvent(ev, context);
340+
if (context == null) {
341+
return super.dispatchKeyEvent(ev);
339342
}
343+
344+
mAndroidHWInputDeviceHelper.handleKeyEvent(ev, context);
345+
346+
// Dispatch during the capture phase before children handle the event as the focus could shift
347+
dispatchJSKeyEvent(ev);
348+
340349
return super.dispatchKeyEvent(ev);
341350
}
342351

@@ -352,6 +361,17 @@ protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyF
352361
ReactContext context = getCurrentReactContext();
353362
if (context != null) {
354363
mAndroidHWInputDeviceHelper.clearFocus(context);
364+
365+
if (mJSKeyDispatcher != null && ReactNativeFeatureFlags.enableKeyEvents()) {
366+
if (gainFocus) {
367+
@Nullable View focusedChild = getFocusedChild();
368+
if (focusedChild != null) {
369+
mJSKeyDispatcher.setFocusedView(focusedChild.getId());
370+
}
371+
} else {
372+
mJSKeyDispatcher.clearFocus();
373+
}
374+
}
355375
}
356376
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
357377
}
@@ -369,6 +389,10 @@ public void requestChildFocus(View child, View focused) {
369389
ReactContext context = getCurrentReactContext();
370390
if (context != null) {
371391
mAndroidHWInputDeviceHelper.onFocusChanged(focused, context);
392+
393+
if (mJSKeyDispatcher != null && ReactNativeFeatureFlags.enableKeyEvents()) {
394+
mJSKeyDispatcher.setFocusedView(focused.getId());
395+
}
372396
}
373397
super.requestChildFocus(child, focused);
374398
}
@@ -410,6 +434,31 @@ protected void dispatchJSTouchEvent(MotionEvent event) {
410434
}
411435
}
412436

437+
protected void dispatchJSKeyEvent(KeyEvent ev) {
438+
if (!ReactNativeFeatureFlags.enableKeyEvents()) {
439+
// Silently return early if key events are disabled
440+
return;
441+
}
442+
if (!hasActiveReactContext() || !isViewAttachedToReactInstance()) {
443+
FLog.w(
444+
TAG, "Unable to dispatch key event to JS as the catalyst instance has not been attached");
445+
return;
446+
}
447+
if (mJSKeyDispatcher == null) {
448+
FLog.w(TAG, "Unable to dispatch key event to JS before the dispatcher is available");
449+
return;
450+
}
451+
ReactContext context = getCurrentReactContext();
452+
if (context != null) {
453+
EventDispatcher eventDispatcher =
454+
UIManagerHelper.getEventDispatcher(context, getUIManagerType());
455+
int surfaceId = UIManagerHelper.getSurfaceId(context);
456+
if (eventDispatcher != null) {
457+
mJSKeyDispatcher.handleKeyEvent(ev, eventDispatcher, surfaceId);
458+
}
459+
}
460+
}
461+
413462
@Override
414463
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
415464
// Override in order to still receive events to onInterceptTouchEvent even when some other
@@ -666,6 +715,10 @@ public void onAttachedToReactInstance() {
666715
mJSPointerDispatcher = new JSPointerDispatcher(this);
667716
}
668717

718+
if (ReactNativeFeatureFlags.enableKeyEvents()) {
719+
mJSKeyDispatcher = new JSKeyDispatcher();
720+
}
721+
669722
if (mRootViewEventListener != null) {
670723
mRootViewEventListener.onAttachedToReactInstance(this);
671724
}
@@ -744,6 +797,9 @@ public void runApplication() {
744797
if (ReactFeatureFlags.dispatchPointerEvents) {
745798
mJSPointerDispatcher = new JSPointerDispatcher(this);
746799
}
800+
if (ReactNativeFeatureFlags.enableKeyEvents()) {
801+
mJSKeyDispatcher = new JSKeyDispatcher();
802+
}
747803
}
748804

749805
@VisibleForTesting

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package com.facebook.react.runtime
1212
import android.content.Context
1313
import android.graphics.Point
1414
import android.graphics.Rect
15+
import android.view.KeyEvent
1516
import android.view.MotionEvent
1617
import android.view.View
1718
import com.facebook.common.logging.FLog
@@ -20,7 +21,9 @@ import com.facebook.react.bridge.ReactContext
2021
import com.facebook.react.common.annotations.FrameworkAPI
2122
import com.facebook.react.common.annotations.UnstableReactNativeAPI
2223
import com.facebook.react.config.ReactFeatureFlags
24+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
2325
import com.facebook.react.uimanager.IllegalViewOperationException
26+
import com.facebook.react.uimanager.JSKeyDispatcher
2427
import com.facebook.react.uimanager.JSPointerDispatcher
2528
import com.facebook.react.uimanager.JSTouchDispatcher
2629
import com.facebook.react.uimanager.common.UIManagerType
@@ -37,6 +40,7 @@ public class ReactSurfaceView(context: Context?, private val surface: ReactSurfa
3740
ReactRootView(context) {
3841
private val jsTouchDispatcher: JSTouchDispatcher = JSTouchDispatcher(this)
3942
private var jsPointerDispatcher: JSPointerDispatcher? = null
43+
private var jsKeyDispatcher: JSKeyDispatcher? = null
4044
private var wasMeasured = false
4145
private var widthMeasureSpec = 0
4246
private var heightMeasureSpec = 0
@@ -45,6 +49,9 @@ public class ReactSurfaceView(context: Context?, private val surface: ReactSurfa
4549
if (ReactFeatureFlags.dispatchPointerEvents) {
4650
jsPointerDispatcher = JSPointerDispatcher(this)
4751
}
52+
if (ReactNativeFeatureFlags.enableKeyEvents()) {
53+
jsKeyDispatcher = JSKeyDispatcher()
54+
}
4855
}
4956

5057
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -188,6 +195,51 @@ public class ReactSurfaceView(context: Context?, private val surface: ReactSurfa
188195
}
189196
}
190197

198+
override fun dispatchJSKeyEvent(event: KeyEvent) {
199+
if (jsKeyDispatcher == null) {
200+
if (!ReactNativeFeatureFlags.enableKeyEvents()) {
201+
return
202+
}
203+
FLog.w(TAG, "Unable to dispatch key events to JS before the dispatcher is available")
204+
return
205+
}
206+
val eventDispatcher = surface.eventDispatcher
207+
if (eventDispatcher != null) {
208+
jsKeyDispatcher?.handleKeyEvent(event, eventDispatcher, surface.surfaceID)
209+
} else {
210+
FLog.w(
211+
TAG,
212+
"Unable to dispatch key events to JS as the React instance has not been attached",
213+
)
214+
}
215+
}
216+
217+
override fun requestChildFocus(child: View?, focused: View?) {
218+
super.requestChildFocus(child, focused)
219+
220+
if (ReactNativeFeatureFlags.enableKeyEvents()) {
221+
val focusedViewTag = focused?.id
222+
if (focusedViewTag != null) {
223+
jsKeyDispatcher?.setFocusedView(focusedViewTag)
224+
}
225+
}
226+
}
227+
228+
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
229+
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
230+
231+
if (ReactNativeFeatureFlags.enableKeyEvents()) {
232+
if (gainFocus) {
233+
val focusedViewTag = focusedChild?.id
234+
if (focusedViewTag != null) {
235+
jsKeyDispatcher?.setFocusedView(focusedViewTag)
236+
}
237+
} else {
238+
jsKeyDispatcher?.clearFocus()
239+
}
240+
}
241+
}
242+
191243
override fun hasActiveReactContext(): Boolean =
192244
surface.isAttached && surface.reactHost?.currentReactContext != null
193245

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,16 @@ protected void onAfterUpdateTransaction(@NonNull T view) {
770770
MapBuilder.of(
771771
"phasedRegistrationNames",
772772
MapBuilder.of("bubbled", "onFocus", "captured", "onFocusCapture")))
773+
.put(
774+
"topKeyDown",
775+
MapBuilder.of(
776+
"phasedRegistrationNames",
777+
MapBuilder.of("bubbled", "onKeyDown", "captured", "onKeyDownCapture")))
778+
.put(
779+
"topKeyUp",
780+
MapBuilder.of(
781+
"phasedRegistrationNames",
782+
MapBuilder.of("bubbled", "onKeyUp", "captured", "onKeyUpCapture")))
773783
.build());
774784
return eventTypeConstants;
775785
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.view.KeyEvent as AndroidKeyEvent
11+
import android.view.View
12+
import com.facebook.react.uimanager.events.EventDispatcher
13+
import com.facebook.react.uimanager.events.KeyDownEvent
14+
import com.facebook.react.uimanager.events.KeyUpEvent
15+
16+
/**
17+
* JSKeyDispatcher handles dispatching keyboard events to JS from RootViews. It sends keydown and
18+
* keyup events according to the W3C KeyboardEvent specification, supporting both capture and bubble
19+
* phases.
20+
*
21+
* 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+
*/
24+
internal class JSKeyDispatcher {
25+
private var focusedViewTag: Int = View.NO_ID
26+
27+
fun handleKeyEvent(
28+
keyEvent: AndroidKeyEvent,
29+
eventDispatcher: EventDispatcher,
30+
surfaceId: Int,
31+
) {
32+
if (focusedViewTag == View.NO_ID) {
33+
return
34+
}
35+
36+
when (keyEvent.action) {
37+
AndroidKeyEvent.ACTION_DOWN -> {
38+
eventDispatcher.dispatchEvent(
39+
KeyDownEvent(
40+
surfaceId,
41+
focusedViewTag,
42+
keyEvent,
43+
)
44+
)
45+
}
46+
AndroidKeyEvent.ACTION_UP -> {
47+
eventDispatcher.dispatchEvent(
48+
KeyUpEvent(
49+
surfaceId,
50+
focusedViewTag,
51+
keyEvent,
52+
)
53+
)
54+
}
55+
}
56+
}
57+
58+
fun setFocusedView(viewTag: Int) {
59+
focusedViewTag = viewTag
60+
}
61+
62+
fun clearFocus() {
63+
focusedViewTag = View.NO_ID
64+
}
65+
}

0 commit comments

Comments
 (0)