Skip to content

Commit 374f02f

Browse files
committed
[Key handling] pass through all keys; allow specifying modifiers for validKeys[Down|Up]
There are scenarios where it might be necessary to look at the incoming events without removing from the system queue. Currently that's impossible today on React Native macOS, since views are required to specify `validKeysDown` or `validKeysUp`, and such events are always removed from the queue. To mitigate, let's add a new `passthroughAllKeyEvents` prop to `RCTView`. We could keep it forever (towards an interest to reduce event spam from native to JS), or we could use it towards the path to making it the default behavior (stage 1: default false, i.e. opt in, stage 2: default true, i.e. opt out, stage 3: remove, is default behavior). - React/Views/RCTView.h - React/Views/RCTView.m - React/Views/RCTViewManager.m Note that this doesn't properly work with `RCTUITextField` (i.e. single line text fields). From what I can tell, that would need us to possibly provide a custom field editor for the window. I am scoping this out for this PR. Another peculiarity to note is regarding `RCTUITextView` (i.e. multi line text fields). Here, it looks like the text view itself isn't exposed to the JS (this view doesn't have a `nativeTag`), so there's a `RCTView` holding a child `RCTUITextView` where the former dispatches events to JS on behalf for the latter. The reason this matters (specifically for "pass through" events) is because the latter can dispatch certain events to the JS, and then depending on the super class implementation (`NSTextView`), it may or may not *also* pass the `NSEvent` to the next responder (i.e. parent view, i.e. `RCTView`). Passing the action to the next responder *can* cause us to send duplicate JS events for the same `NSEvent`. I couldn't find anything in macOS APIs to determine if the view the event was generated for is a specific view, so I am introducing a book-keeping mechanism to not send duplicate events. Introduce `RCTHandledKey` for specifying modifiers for `validKeysDown` and `validKeysUp`. Behavior noted in type definitions. - Libraries/Text/TextInput/RCTBaseTextInputView.m - React/Base/RCTConvert.h - React/Base/RCTConvert.m - React/Views/RCTHandledKey.h - React/Views/RCTHandledKey.m - React/Views/RCTView.h - React/Views/RCTView.m - React/Views/RCTViewKeyboardEvent.m - React/Views/RCTViewManager.m - React/Views/ScrollView/RCTScrollView.m macOS *usually* does things on key down (as opposed to, say, Win32, which seems to *usually* does things on key up). Like `RCTUITextField`, passs `performKeyEquivalent:` to `textInputDelegate` so we can handle the alternate `keyDown:` path (e.g. Cmd+A). This will be needed for properly handling keystrokes that go through said alternate path. There are probably several other selectors that also need implementing (`deleteBackward:`) to full pass through every possible key, but I am leaving that for some other time. - Libraries/Text/TextInput/Multiline/RCTUITextView.m Make a totally unrelated fix to `RCTSwitch`. In a test page where I added an on-by-default switch, I noticed the first toggle (ON->OFF) doesn't do anything. The second toggle (OFF->ON) then doesn't (expectedly) do anything. Found wrong behavior on the switch test page -- tempted to instead remove `wasOn`, but for now repeating the pattern in `setOn:animated:` - React/Views/RCTSwitch.m Flow stuff. `passthroughAllKeyEvents` is now a valid thing to pass to `View` types. - Libraries/Components/View/ReactNativeViewAttributes.js - Libraries/Components/View/ViewPropTypes.js - Libraries/NativeComponent/BaseViewConfig.macos.js Update signatures for `validKeysDown` and `validKeysUp` - Libraries/Components/View/ViewPropTypes.js Remove duplicated specifications on `Pressable`. Just use the one from `View`. As a benefit, future changes allow us to not have to touch `Pressable` anymore. - Libraries/Components/Pressable/Pressable.js - Libraries/Components/View/ViewPropTypes.js Update test pages with `passthoughAllKeyEvents` and the keyboard events page with an example modifier usage. - packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js - packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js Testing: * Using the keyboard events test page, validate "pass through" of all events for simple view, single line text input, multi line text input. Sanity test existing (non-"pass through") behavior. * Using the text input test page, ordering of `keyDown` and `keyUp` events w.r.t. other events (such as `keyPress` -- which isn't dispatched for every key) * Using the switch test page, sanity test switch behaviors
1 parent 32b2311 commit 374f02f

File tree

18 files changed

+592
-131
lines changed

18 files changed

+592
-131
lines changed

Libraries/Components/Pressable/Pressable.js

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
BlurEvent,
1313
// [macOS
1414
FocusEvent,
15-
KeyEvent,
1615
LayoutEvent,
1716
MouseEvent,
1817
PressEvent,
@@ -26,6 +25,7 @@ import type {
2625
AccessibilityState,
2726
AccessibilityValue,
2827
} from '../View/ViewAccessibility';
28+
import type {KeyboardEventProps} from '../View/ViewPropTypes'; // [macOS]
2929

3030
import {PressabilityDebugView} from '../../Pressability/PressabilityDebug';
3131
import usePressability from '../../Pressability/usePressability';
@@ -174,27 +174,7 @@ type Props = $ReadOnly<{|
174174
*/
175175
onBlur?: ?(event: BlurEvent) => void,
176176

177-
/**
178-
* Called after a key down event is detected.
179-
*/
180-
onKeyDown?: ?(event: KeyEvent) => void,
181-
182-
/**
183-
* Called after a key up event is detected.
184-
*/
185-
onKeyUp?: ?(event: KeyEvent) => void,
186-
187-
/**
188-
* Array of keys to receive key down events for
189-
* For arrow keys, add "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
190-
*/
191-
validKeysDown?: ?Array<string>,
192-
193-
/**
194-
* Array of keys to receive key up events for
195-
* For arrow keys, add "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
196-
*/
197-
validKeysUp?: ?Array<string>,
177+
...KeyboardEventProps, // [macOS]
198178

199179
/**
200180
* Specifies whether the view should receive the mouse down event when the

Libraries/Components/View/ReactNativeViewAttributes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const UIView = {
4747
onDrop: true,
4848
onKeyDown: true,
4949
onKeyUp: true,
50+
passthroughAllKeyEvents: true,
5051
validKeysDown: true,
5152
validKeysUp: true,
5253
draggedTypes: true,

Libraries/Components/View/ViewPropTypes.js

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,22 +100,64 @@ type DirectEventProps = $ReadOnly<{|
100100
|}>;
101101

102102
// [macOS
103-
type KeyboardEventProps = $ReadOnly<{|
103+
/**
104+
* Represents a key that could be passed to `validKeysDown` and `validKeysUp`.
105+
*
106+
* `key` is the actual key, such as "a", or one of the special values:
107+
* "Tab", "Escape", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
108+
* "Backspace", "Delete", "Home", "End", "PageUp", "PageDown".
109+
*
110+
* The rest are modifiers that when absent mean don't care (either state -- true or false --
111+
* matches), and when present require that modifier state to match. All present modifers must
112+
* match for the event to pass the filter.
113+
*
114+
* @platform macos
115+
*/
116+
export type HandledKey = $ReadOnly<{|
117+
key: string,
118+
capsLock?: ?boolean,
119+
shift?: ?boolean,
120+
ctrl?: ?boolean,
121+
alt?: ?boolean,
122+
meta?: ?boolean,
123+
numericPad?: ?boolean,
124+
help?: ?boolean,
125+
function?: ?boolean,
126+
|}>;
127+
128+
export type KeyboardEventProps = $ReadOnly<{|
129+
/**
130+
* Called after a key down event is detected.
131+
*/
104132
onKeyDown?: ?(event: KeyEvent) => void,
133+
134+
/**
135+
* Called after a key up event is detected.
136+
*/
105137
onKeyUp?: ?(event: KeyEvent) => void,
138+
139+
/**
140+
* When `true`, allows `onKeyDown` and `onKeyUp` to receive events not specified in
141+
* `validKeysDown` and `validKeysUp`, respectively. Events matching `validKeysDown` and `validKeysUp`
142+
* are still removed from the event queue, but the others are not.
143+
*
144+
* @platform macos
145+
*/
146+
passthroughAllKeyEvents?: ?boolean,
147+
106148
/**
107-
* Array of keys to receive key down events for
149+
* Array of keys to receive key down events for. These events are always removed from the system event queue.
108150
*
109151
* @platform macos
110152
*/
111-
validKeysDown?: ?Array<string>,
153+
validKeysDown?: ?Array<string | HandledKey>,
112154

113155
/**
114-
* Array of keys to receive key up events for
156+
* Array of keys to receive key up events for. These events are always removed from the system event queue.
115157
*
116158
* @platform macos
117159
*/
118-
validKeysUp?: ?Array<string>,
160+
validKeysUp?: ?Array<string | HandledKey>,
119161
|}>;
120162
// macOS]
121163

Libraries/NativeComponent/BaseViewConfig.macos.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const validAttributesForNonEventProps = {
5252
draggedTypes: true,
5353
enableFocusRing: true,
5454
tooltip: true,
55+
passthroughAllKeyEvents: true,
5556
validKeysDown: true,
5657
validKeysUp: true,
5758
mouseDownCanMoveWindow: true,

Libraries/Text/TextInput/Multiline/RCTUITextView.m

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,14 @@ - (void)deleteBackward {
550550
}
551551
}
552552
#else // [macOS
553+
- (BOOL)performKeyEquivalent:(NSEvent *)event {
554+
if (!self.hasMarkedText && ![self.textInputDelegate textInputShouldHandleKeyEvent:event]) {
555+
return YES;
556+
}
557+
558+
return [super performKeyEquivalent:event];
559+
}
560+
553561
- (void)keyDown:(NSEvent *)event {
554562
// If has marked text, handle by native and return
555563
// Do this check before textInputShouldHandleKeyEvent as that one attempts to send the event to JS

Libraries/Text/TextInput/RCTBaseTextInputView.m

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#import <React/RCTTextSelection.h>
2222
#import <React/RCTUITextView.h> // [macOS]
2323
#import "../RCTTextUIKit.h" // [macOS]
24+
#import "RCTHandledKey.h" // [macOS]
2425

2526
@implementation RCTBaseTextInputView {
2627
__weak RCTBridge *_bridge;
@@ -662,7 +663,8 @@ - (BOOL)textInputShouldHandleDeleteForward:(__unused id)sender {
662663
}
663664

664665
- (BOOL)hasValidKeyDownOrValidKeyUp:(NSString *)key {
665-
return [self.validKeysDown containsObject:key] || [self.validKeysUp containsObject:key];
666+
return [RCTHandledKey key:key matchesFilter:self.validKeysDown]
667+
|| [RCTHandledKey key:key matchesFilter:self.validKeysUp];
666668
}
667669

668670
- (NSDragOperation)textInputDraggingEntered:(id<NSDraggingInfo>)draggingInfo

React/Base/RCTConvert.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
#import <WebKit/WebKit.h>
2121
#endif
2222

23+
@class RCTHandledKey; // [macOS]
24+
2325
/**
2426
* This class provides a collection of conversion functions for mapping
2527
* JSON objects to native types and classes. These are useful when writing
@@ -147,6 +149,8 @@ typedef BOOL css_backface_visibility_t;
147149

148150
#if TARGET_OS_OSX // [macOS
149151
+ (NSString *)accessibilityRoleFromTraits:(id)json;
152+
153+
+ (NSArray<RCTHandledKey *> *)RCTHandledKeyArray:(id)json;
150154
#endif // macOS]
151155
@end
152156

React/Base/RCTConvert.m

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#import <CoreText/CoreText.h>
1313

1414
#import "RCTDefines.h"
15+
#import "RCTHandledKey.h" // [macOS]
1516
#import "RCTImageSource.h"
1617
#import "RCTParserUtils.h"
1718
#import "RCTUtils.h"
@@ -1500,6 +1501,9 @@ + (NSString *)accessibilityRoleFromTraits:(id)json
15001501
}
15011502
return NSAccessibilityUnknownRole;
15021503
}
1504+
1505+
RCT_JSON_ARRAY_CONVERTER(RCTHandledKey);
1506+
15031507
#endif // macOS]
15041508

15051509
@end

React/Views/RCTHandledKey.h

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
#if TARGET_OS_OSX // [macOS
9+
#import <React/RCTConvert.h>
10+
11+
@interface RCTHandledKey : NSObject
12+
13+
+ (BOOL)event:(NSEvent *)event matchesFilter:(NSArray<RCTHandledKey *> *)filter;
14+
+ (BOOL)key:(NSString *)key matchesFilter:(NSArray<RCTHandledKey *> *)filter;
15+
16+
- (instancetype)initWithKey:(NSString *)key;
17+
- (BOOL)matchesEvent:(NSEvent *)event;
18+
19+
@property (nonatomic, copy) NSString *key;
20+
@property (nonatomic, assign) NSNumber *capsLock; // boolean; nil == don't care
21+
@property (nonatomic, assign) NSNumber *shift; // boolean; nil == don't care
22+
@property (nonatomic, assign) NSNumber *ctrl; // boolean; nil == don't care
23+
@property (nonatomic, assign) NSNumber *alt; // boolean; nil == don't care
24+
@property (nonatomic, assign) NSNumber *meta; // boolean; nil == don't care
25+
@property (nonatomic, assign) NSNumber *numericPad; // boolean; nil == don't care
26+
@property (nonatomic, assign) NSNumber *help; // boolean; nil == don't care
27+
@property (nonatomic, assign) NSNumber *function; // boolean; nil == don't care
28+
29+
@end
30+
31+
@interface RCTConvert (RCTHandledKey)
32+
33+
+ (RCTHandledKey *)RCTHandledKey:(id)json;
34+
35+
@end
36+
37+
#endif // macOS]

React/Views/RCTHandledKey.m

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
#import "objc/runtime.h"
9+
#import <React/RCTAssert.h>
10+
#import <React/RCTUtils.h>
11+
#import <RCTConvert.h>
12+
#import <RCTHandledKey.h>
13+
#import <RCTViewKeyboardEvent.h>
14+
15+
#if TARGET_OS_OSX // [macOS
16+
17+
@implementation RCTHandledKey
18+
19+
+ (NSArray<NSString *> *)validModifiers {
20+
// keep in sync with actual properties and RCTViewKeyboardEvent
21+
return @[@"capsLock", @"shift", @"ctrl", @"alt", @"meta", @"numericPad", @"help", @"function"];
22+
}
23+
24+
+ (BOOL)event:(NSEvent *)event matchesFilter:(NSArray<RCTHandledKey *> *)filter {
25+
for (RCTHandledKey *key in filter) {
26+
if ([key matchesEvent:event]) {
27+
return YES;
28+
}
29+
}
30+
31+
return NO;
32+
}
33+
34+
+ (BOOL)key:(NSString *)key matchesFilter:(NSArray<RCTHandledKey *> *)filter {
35+
for (RCTHandledKey *aKey in filter) {
36+
if ([[aKey key] isEqualToString:key]) {
37+
return YES;
38+
}
39+
}
40+
41+
return NO;
42+
}
43+
44+
- (instancetype)initWithKey:(NSString *)key {
45+
if ((self = [super init])) {
46+
self.key = key;
47+
}
48+
return self;
49+
}
50+
51+
- (BOOL)matchesEvent:(NSEvent *)event
52+
{
53+
NSEventType type = [event type];
54+
if (type != NSEventTypeKeyDown && type != NSEventTypeKeyUp) {
55+
RCTFatal(RCTErrorWithMessage([NSString stringWithFormat:@"Wrong event type (%d) sent to -[RCTHandledKey matchesEvent:]", (int)type]));
56+
return NO;
57+
}
58+
59+
NSDictionary *body = [RCTViewKeyboardEvent bodyFromEvent:event];
60+
if (![body[@"key"] isEqualToString:self.key]) {
61+
return NO;
62+
}
63+
64+
NSArray<NSString *> *modifiers = [RCTHandledKey validModifiers];
65+
for (NSString *modifier in modifiers) {
66+
NSString *modifierKey = [modifier stringByAppendingString:@"Key"];
67+
NSNumber *myValue = [self valueForKey:modifier];
68+
69+
if (myValue == nil) {
70+
continue;
71+
}
72+
73+
NSNumber *eventValue = (NSNumber *)body[modifierKey];
74+
if (eventValue == nil) {
75+
RCTFatal(RCTErrorWithMessage([NSString stringWithFormat:@"Event body has missing value for %@", modifierKey]));
76+
return NO;
77+
}
78+
79+
if (![eventValue isKindOfClass:[NSNumber class]]) {
80+
RCTFatal(RCTErrorWithMessage([NSString stringWithFormat:@"Event body has unexpected value of class %@ for %@",
81+
NSStringFromClass(object_getClass(eventValue)), modifierKey]));
82+
return NO;
83+
}
84+
85+
if (![myValue isEqualToNumber:body[modifierKey]]) {
86+
return NO;
87+
}
88+
}
89+
90+
return YES; // keys matched; all present modifiers matched
91+
}
92+
93+
@end
94+
95+
@implementation RCTConvert (RCTHandledKey)
96+
97+
+ (RCTHandledKey *)RCTHandledKey:(id)json
98+
{
99+
if ([json isKindOfClass:[NSString class]]) {
100+
return [[RCTHandledKey alloc] initWithKey:(NSString *)json];
101+
}
102+
103+
if ([json isKindOfClass:[NSDictionary class]]) {
104+
NSDictionary *dict = (NSDictionary *)json;
105+
NSString *key = dict[@"key"];
106+
if (key == nil) {
107+
RCTLogConvertError(dict, @"a RCTHandledKey -- must include \"key\"");
108+
return nil;
109+
}
110+
111+
RCTHandledKey *handledKey = [[RCTHandledKey alloc] initWithKey:key];
112+
NSArray<NSString *> *modifiers = RCTHandledKey.validModifiers;
113+
for (NSString *key in modifiers) {
114+
id value = dict[key];
115+
if (value == nil) {
116+
continue;
117+
}
118+
119+
if (![value isKindOfClass:[NSNumber class]]) {
120+
RCTLogConvertError(value, @"a boolean");
121+
return nil;
122+
}
123+
124+
[handledKey setValue:@([(NSNumber *)value boolValue]) forKey:key];
125+
}
126+
127+
return handledKey;
128+
}
129+
130+
RCTLogConvertError(json, @"a RCTHandledKey -- allowed types are string and object");
131+
return nil;
132+
}
133+
134+
@end
135+
136+
#endif // macOS]

0 commit comments

Comments
 (0)