Skip to content

Commit f2b9ec7

Browse files
rickhanloniifacebook-github-bot
authored andcommitted
Refactor RCTKeyCommands, allow hotkeys to be used without command key
Summary: This diff updates our RCTKeyCommands code to be more resilient by copying the [FLEX strategy for key commands](https://github.com/Flipboard/FLEX/blob/master/Classes/Utility/Keyboard/FLEXKeyboardShortcutManager.m). This strategy swizzles UIApplication handleKeyUIEvent which is further upstream than our UIResponder. It also allows for single key hotkeys like pressing just `r` instead of `cmd+r`. It does this without interfering with typing input by checking the first responder first. I've also updated our hotkey handling to support using just the keys like `r` in addition to `cmd+r`. In addition to brining these hotkeys more in line with other iOS tools, they're also easier to use and do not suffer the same issues hotkeys with modifiers like `cmd` have where keys are dropped. Changelog: [iOS] [Added] Allow hotkeys to be used without command key Reviewed By: shergin Differential Revision: D21635129 fbshipit-source-id: 36e0210a62b1f310473e152e8305165024cd338b
1 parent 5cde6c5 commit f2b9ec7

File tree

1 file changed

+100
-60
lines changed

1 file changed

+100
-60
lines changed

React/Base/RCTKeyCommands.m

+100-60
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,38 @@
99

1010
#import <UIKit/UIKit.h>
1111

12+
#import <objc/message.h>
13+
#import <objc/runtime.h>
1214
#import "RCTDefines.h"
1315
#import "RCTUtils.h"
1416

1517
#if RCT_DEV
1618

19+
@interface UIEvent (UIPhysicalKeyboardEvent)
20+
21+
@property (nonatomic) NSString *_modifiedInput;
22+
@property (nonatomic) NSString *_unmodifiedInput;
23+
@property (nonatomic) UIKeyModifierFlags _modifierFlags;
24+
@property (nonatomic) BOOL _isKeyDown;
25+
@property (nonatomic) long _keyCode;
26+
27+
@end
28+
1729
@interface RCTKeyCommand : NSObject <NSCopying>
1830

19-
@property (nonatomic, strong) UIKeyCommand *keyCommand;
31+
@property (nonatomic, copy, readonly) NSString *key;
32+
@property (nonatomic, readonly) UIKeyModifierFlags flags;
2033
@property (nonatomic, copy) void (^block)(UIKeyCommand *);
2134

2235
@end
2336

2437
@implementation RCTKeyCommand
2538

26-
- (instancetype)initWithKeyCommand:(UIKeyCommand *)keyCommand block:(void (^)(UIKeyCommand *))block
39+
- (instancetype)init:(NSString *)key flags:(UIKeyModifierFlags)flags block:(void (^)(UIKeyCommand *))block
2740
{
2841
if ((self = [super init])) {
29-
_keyCommand = keyCommand;
42+
_key = key;
43+
_flags = flags;
3044
_block = block;
3145
}
3246
return self;
@@ -41,29 +55,32 @@ - (id)copyWithZone:(__unused NSZone *)zone
4155

4256
- (NSUInteger)hash
4357
{
44-
return _keyCommand.input.hash ^ _keyCommand.modifierFlags;
58+
return _key.hash ^ _flags;
4559
}
4660

4761
- (BOOL)isEqual:(RCTKeyCommand *)object
4862
{
4963
if (![object isKindOfClass:[RCTKeyCommand class]]) {
5064
return NO;
5165
}
52-
return [self matchesInput:object.keyCommand.input flags:object.keyCommand.modifierFlags];
66+
return [self matchesInput:object.key flags:object.flags];
5367
}
5468

5569
- (BOOL)matchesInput:(NSString *)input flags:(UIKeyModifierFlags)flags
5670
{
57-
return [_keyCommand.input isEqual:input] && _keyCommand.modifierFlags == flags;
71+
// We consider the key command a match if the modifier flags match
72+
// exactly or is there are no modifier flags. This means that for
73+
// `cmd + r`, we will match both `cmd + r` and `r` but not `opt + r`.
74+
return [_key isEqual:input] && (_flags == flags || flags == 0);
5875
}
5976

6077
- (NSString *)description
6178
{
6279
return [NSString stringWithFormat:@"<%@:%p input=\"%@\" flags=%lld hasBlock=%@>",
6380
[self class],
6481
self,
65-
_keyCommand.input,
66-
(long long)_keyCommand.modifierFlags,
82+
_key,
83+
(long long)_flags,
6784
_block ? @"YES" : @"NO"];
6885
}
6986

@@ -75,67 +92,94 @@ @interface RCTKeyCommands ()
7592

7693
@end
7794

78-
@implementation UIResponder (RCTKeyCommands)
95+
@implementation RCTKeyCommands
7996

80-
+ (UIResponder *)RCT_getFirstResponder:(UIResponder *)view
97+
+ (void)initialize
8198
{
82-
UIResponder *firstResponder = nil;
99+
SEL originalKeyEventSelector = NSSelectorFromString(@"handleKeyUIEvent:");
100+
SEL swizzledKeyEventSelector = NSSelectorFromString(
101+
[NSString stringWithFormat:@"_rct_swizzle_%x_%@", arc4random(), NSStringFromSelector(originalKeyEventSelector)]);
83102

84-
if (view.isFirstResponder) {
85-
return view;
86-
} else if ([view isKindOfClass:[UIViewController class]]) {
87-
if ([(UIViewController *)view parentViewController]) {
88-
firstResponder = [UIResponder RCT_getFirstResponder:[(UIViewController *)view parentViewController]];
89-
}
90-
return firstResponder ? firstResponder : [UIResponder RCT_getFirstResponder:[(UIViewController *)view view]];
91-
} else if ([view isKindOfClass:[UIView class]]) {
92-
for (UIView *subview in [(UIView *)view subviews]) {
93-
firstResponder = [UIResponder RCT_getFirstResponder:subview];
94-
if (firstResponder) {
95-
return firstResponder;
96-
}
97-
}
98-
}
103+
void (^handleKeyUIEventSwizzleBlock)(UIApplication *, UIEvent *) = ^(UIApplication *slf, UIEvent *event) {
104+
[[[self class] sharedInstance] handleKeyUIEventSwizzle:event];
99105

100-
return firstResponder;
106+
((void (*)(id, SEL, id))objc_msgSend)(slf, swizzledKeyEventSelector, event);
107+
};
108+
109+
RCTSwapInstanceMethodWithBlock(
110+
[UIApplication class], originalKeyEventSelector, handleKeyUIEventSwizzleBlock, swizzledKeyEventSelector);
101111
}
102112

103-
- (NSArray<UIKeyCommand *> *)RCT_keyCommands
113+
- (void)handleKeyUIEventSwizzle:(UIEvent *)event
104114
{
105-
NSSet<RCTKeyCommand *> *commands = [RCTKeyCommands sharedInstance].commands;
106-
return [[commands valueForKeyPath:@"keyCommand"] allObjects];
107-
}
115+
NSString *modifiedInput = nil;
116+
UIKeyModifierFlags *modifierFlags = nil;
117+
BOOL isKeyDown = NO;
108118

109-
/**
110-
* Single Press Key Command Response
111-
* Command + KeyEvent (Command + R/D, etc.)
112-
*/
113-
- (void)RCT_handleKeyCommand:(UIKeyCommand *)key
114-
{
115-
// NOTE: throttle the key handler because on iOS 9 the handleKeyCommand:
116-
// method gets called repeatedly if the command key is held down.
117-
static NSTimeInterval lastCommand = 0;
118-
if (CACurrentMediaTime() - lastCommand > 0.5) {
119-
for (RCTKeyCommand *command in [RCTKeyCommands sharedInstance].commands) {
120-
if ([command.keyCommand.input isEqualToString:key.input] &&
121-
command.keyCommand.modifierFlags == key.modifierFlags) {
122-
if (command.block) {
123-
command.block(key);
124-
lastCommand = CACurrentMediaTime();
125-
}
119+
if ([event respondsToSelector:@selector(_modifiedInput)]) {
120+
modifiedInput = [event _modifiedInput];
121+
}
122+
123+
if ([event respondsToSelector:@selector(_modifierFlags)]) {
124+
modifierFlags = [event _modifierFlags];
125+
}
126+
127+
if ([event respondsToSelector:@selector(_isKeyDown)]) {
128+
isKeyDown = [event _isKeyDown];
129+
}
130+
131+
BOOL interactionEnabled = !UIApplication.sharedApplication.isIgnoringInteractionEvents;
132+
BOOL hasFirstResponder = NO;
133+
if (isKeyDown && modifiedInput.length > 0 && interactionEnabled) {
134+
UIResponder *firstResponder = nil;
135+
for (UIWindow *window in [self allWindows]) {
136+
firstResponder = [window valueForKey:@"firstResponder"];
137+
if (firstResponder) {
138+
hasFirstResponder = YES;
139+
break;
126140
}
127141
}
128-
}
129-
}
130142

131-
@end
143+
// Ignore key commands (except escape) when there's an active responder
144+
if (!firstResponder) {
145+
[self RCT_handleKeyCommand:modifiedInput flags:modifierFlags];
146+
}
147+
}
148+
};
132149

133-
@implementation RCTKeyCommands
150+
- (NSArray<UIWindow *> *)allWindows
151+
{
152+
BOOL includeInternalWindows = YES;
153+
BOOL onlyVisibleWindows = NO;
154+
155+
// Obfuscating selector allWindowsIncludingInternalWindows:onlyVisibleWindows:
156+
NSArray<NSString *> *allWindowsComponents =
157+
@[ @"al", @"lWindo", @"wsIncl", @"udingInt", @"ernalWin", @"dows:o", @"nlyVisi", @"bleWin", @"dows:" ];
158+
SEL allWindowsSelector = NSSelectorFromString([allWindowsComponents componentsJoinedByString:@""]);
159+
160+
NSMethodSignature *methodSignature = [[UIWindow class] methodSignatureForSelector:allWindowsSelector];
161+
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
162+
163+
invocation.target = [UIWindow class];
164+
invocation.selector = allWindowsSelector;
165+
[invocation setArgument:&includeInternalWindows atIndex:2];
166+
[invocation setArgument:&onlyVisibleWindows atIndex:3];
167+
[invocation invoke];
168+
169+
__unsafe_unretained NSArray<UIWindow *> *windows = nil;
170+
[invocation getReturnValue:&windows];
171+
return windows;
172+
}
134173

135-
+ (void)initialize
174+
- (void)RCT_handleKeyCommand:(NSString *)input flags:(UIKeyModifierFlags)modifierFlags
136175
{
137-
// swizzle UIResponder
138-
RCTSwapInstanceMethods([UIResponder class], @selector(keyCommands), @selector(RCT_keyCommands));
176+
for (RCTKeyCommand *command in [RCTKeyCommands sharedInstance].commands) {
177+
if ([command matchesInput:input flags:modifierFlags]) {
178+
if (command.block) {
179+
command.block(nil);
180+
}
181+
}
182+
}
139183
}
140184

141185
+ (instancetype)sharedInstance
@@ -163,11 +207,7 @@ - (void)registerKeyCommandWithInput:(NSString *)input
163207
{
164208
RCTAssertMainQueue();
165209

166-
UIKeyCommand *command = [UIKeyCommand keyCommandWithInput:input
167-
modifierFlags:flags
168-
action:@selector(RCT_handleKeyCommand:)];
169-
170-
RCTKeyCommand *keyCommand = [[RCTKeyCommand alloc] initWithKeyCommand:command block:block];
210+
RCTKeyCommand *keyCommand = [[RCTKeyCommand alloc] init:input flags:flags block:block];
171211
[_commands removeObject:keyCommand];
172212
[_commands addObject:keyCommand];
173213
}

0 commit comments

Comments
 (0)