From f2b9ec798172db76dfb55f390e1fcea90dd341da Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Thu, 28 May 2020 11:22:06 -0700 Subject: [PATCH] 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 --- React/Base/RCTKeyCommands.m | 160 ++++++++++++++++++++++-------------- 1 file changed, 100 insertions(+), 60 deletions(-) diff --git a/React/Base/RCTKeyCommands.m b/React/Base/RCTKeyCommands.m index 974e415181ee8f..220a8b321f09cf 100644 --- a/React/Base/RCTKeyCommands.m +++ b/React/Base/RCTKeyCommands.m @@ -9,24 +9,38 @@ #import +#import +#import #import "RCTDefines.h" #import "RCTUtils.h" #if RCT_DEV +@interface UIEvent (UIPhysicalKeyboardEvent) + +@property (nonatomic) NSString *_modifiedInput; +@property (nonatomic) NSString *_unmodifiedInput; +@property (nonatomic) UIKeyModifierFlags _modifierFlags; +@property (nonatomic) BOOL _isKeyDown; +@property (nonatomic) long _keyCode; + +@end + @interface RCTKeyCommand : NSObject -@property (nonatomic, strong) UIKeyCommand *keyCommand; +@property (nonatomic, copy, readonly) NSString *key; +@property (nonatomic, readonly) UIKeyModifierFlags flags; @property (nonatomic, copy) void (^block)(UIKeyCommand *); @end @implementation RCTKeyCommand -- (instancetype)initWithKeyCommand:(UIKeyCommand *)keyCommand block:(void (^)(UIKeyCommand *))block +- (instancetype)init:(NSString *)key flags:(UIKeyModifierFlags)flags block:(void (^)(UIKeyCommand *))block { if ((self = [super init])) { - _keyCommand = keyCommand; + _key = key; + _flags = flags; _block = block; } return self; @@ -41,7 +55,7 @@ - (id)copyWithZone:(__unused NSZone *)zone - (NSUInteger)hash { - return _keyCommand.input.hash ^ _keyCommand.modifierFlags; + return _key.hash ^ _flags; } - (BOOL)isEqual:(RCTKeyCommand *)object @@ -49,12 +63,15 @@ - (BOOL)isEqual:(RCTKeyCommand *)object if (![object isKindOfClass:[RCTKeyCommand class]]) { return NO; } - return [self matchesInput:object.keyCommand.input flags:object.keyCommand.modifierFlags]; + return [self matchesInput:object.key flags:object.flags]; } - (BOOL)matchesInput:(NSString *)input flags:(UIKeyModifierFlags)flags { - return [_keyCommand.input isEqual:input] && _keyCommand.modifierFlags == flags; + // We consider the key command a match if the modifier flags match + // exactly or is there are no modifier flags. This means that for + // `cmd + r`, we will match both `cmd + r` and `r` but not `opt + r`. + return [_key isEqual:input] && (_flags == flags || flags == 0); } - (NSString *)description @@ -62,8 +79,8 @@ - (NSString *)description return [NSString stringWithFormat:@"<%@:%p input=\"%@\" flags=%lld hasBlock=%@>", [self class], self, - _keyCommand.input, - (long long)_keyCommand.modifierFlags, + _key, + (long long)_flags, _block ? @"YES" : @"NO"]; } @@ -75,67 +92,94 @@ @interface RCTKeyCommands () @end -@implementation UIResponder (RCTKeyCommands) +@implementation RCTKeyCommands -+ (UIResponder *)RCT_getFirstResponder:(UIResponder *)view ++ (void)initialize { - UIResponder *firstResponder = nil; + SEL originalKeyEventSelector = NSSelectorFromString(@"handleKeyUIEvent:"); + SEL swizzledKeyEventSelector = NSSelectorFromString( + [NSString stringWithFormat:@"_rct_swizzle_%x_%@", arc4random(), NSStringFromSelector(originalKeyEventSelector)]); - if (view.isFirstResponder) { - return view; - } else if ([view isKindOfClass:[UIViewController class]]) { - if ([(UIViewController *)view parentViewController]) { - firstResponder = [UIResponder RCT_getFirstResponder:[(UIViewController *)view parentViewController]]; - } - return firstResponder ? firstResponder : [UIResponder RCT_getFirstResponder:[(UIViewController *)view view]]; - } else if ([view isKindOfClass:[UIView class]]) { - for (UIView *subview in [(UIView *)view subviews]) { - firstResponder = [UIResponder RCT_getFirstResponder:subview]; - if (firstResponder) { - return firstResponder; - } - } - } + void (^handleKeyUIEventSwizzleBlock)(UIApplication *, UIEvent *) = ^(UIApplication *slf, UIEvent *event) { + [[[self class] sharedInstance] handleKeyUIEventSwizzle:event]; - return firstResponder; + ((void (*)(id, SEL, id))objc_msgSend)(slf, swizzledKeyEventSelector, event); + }; + + RCTSwapInstanceMethodWithBlock( + [UIApplication class], originalKeyEventSelector, handleKeyUIEventSwizzleBlock, swizzledKeyEventSelector); } -- (NSArray *)RCT_keyCommands +- (void)handleKeyUIEventSwizzle:(UIEvent *)event { - NSSet *commands = [RCTKeyCommands sharedInstance].commands; - return [[commands valueForKeyPath:@"keyCommand"] allObjects]; -} + NSString *modifiedInput = nil; + UIKeyModifierFlags *modifierFlags = nil; + BOOL isKeyDown = NO; -/** - * Single Press Key Command Response - * Command + KeyEvent (Command + R/D, etc.) - */ -- (void)RCT_handleKeyCommand:(UIKeyCommand *)key -{ - // NOTE: throttle the key handler because on iOS 9 the handleKeyCommand: - // method gets called repeatedly if the command key is held down. - static NSTimeInterval lastCommand = 0; - if (CACurrentMediaTime() - lastCommand > 0.5) { - for (RCTKeyCommand *command in [RCTKeyCommands sharedInstance].commands) { - if ([command.keyCommand.input isEqualToString:key.input] && - command.keyCommand.modifierFlags == key.modifierFlags) { - if (command.block) { - command.block(key); - lastCommand = CACurrentMediaTime(); - } + if ([event respondsToSelector:@selector(_modifiedInput)]) { + modifiedInput = [event _modifiedInput]; + } + + if ([event respondsToSelector:@selector(_modifierFlags)]) { + modifierFlags = [event _modifierFlags]; + } + + if ([event respondsToSelector:@selector(_isKeyDown)]) { + isKeyDown = [event _isKeyDown]; + } + + BOOL interactionEnabled = !UIApplication.sharedApplication.isIgnoringInteractionEvents; + BOOL hasFirstResponder = NO; + if (isKeyDown && modifiedInput.length > 0 && interactionEnabled) { + UIResponder *firstResponder = nil; + for (UIWindow *window in [self allWindows]) { + firstResponder = [window valueForKey:@"firstResponder"]; + if (firstResponder) { + hasFirstResponder = YES; + break; } } - } -} -@end + // Ignore key commands (except escape) when there's an active responder + if (!firstResponder) { + [self RCT_handleKeyCommand:modifiedInput flags:modifierFlags]; + } + } +}; -@implementation RCTKeyCommands +- (NSArray *)allWindows +{ + BOOL includeInternalWindows = YES; + BOOL onlyVisibleWindows = NO; + + // Obfuscating selector allWindowsIncludingInternalWindows:onlyVisibleWindows: + NSArray *allWindowsComponents = + @[ @"al", @"lWindo", @"wsIncl", @"udingInt", @"ernalWin", @"dows:o", @"nlyVisi", @"bleWin", @"dows:" ]; + SEL allWindowsSelector = NSSelectorFromString([allWindowsComponents componentsJoinedByString:@""]); + + NSMethodSignature *methodSignature = [[UIWindow class] methodSignatureForSelector:allWindowsSelector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature]; + + invocation.target = [UIWindow class]; + invocation.selector = allWindowsSelector; + [invocation setArgument:&includeInternalWindows atIndex:2]; + [invocation setArgument:&onlyVisibleWindows atIndex:3]; + [invocation invoke]; + + __unsafe_unretained NSArray *windows = nil; + [invocation getReturnValue:&windows]; + return windows; +} -+ (void)initialize +- (void)RCT_handleKeyCommand:(NSString *)input flags:(UIKeyModifierFlags)modifierFlags { - // swizzle UIResponder - RCTSwapInstanceMethods([UIResponder class], @selector(keyCommands), @selector(RCT_keyCommands)); + for (RCTKeyCommand *command in [RCTKeyCommands sharedInstance].commands) { + if ([command matchesInput:input flags:modifierFlags]) { + if (command.block) { + command.block(nil); + } + } + } } + (instancetype)sharedInstance @@ -163,11 +207,7 @@ - (void)registerKeyCommandWithInput:(NSString *)input { RCTAssertMainQueue(); - UIKeyCommand *command = [UIKeyCommand keyCommandWithInput:input - modifierFlags:flags - action:@selector(RCT_handleKeyCommand:)]; - - RCTKeyCommand *keyCommand = [[RCTKeyCommand alloc] initWithKeyCommand:command block:block]; + RCTKeyCommand *keyCommand = [[RCTKeyCommand alloc] init:input flags:flags block:block]; [_commands removeObject:keyCommand]; [_commands addObject:keyCommand]; }