Skip to content

Commit

Permalink
Refactor RCTKeyCommands, allow hotkeys to be used without command key
Browse files Browse the repository at this point in the history
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
  • Loading branch information
rickhanlonii authored and facebook-github-bot committed May 28, 2020
1 parent 5cde6c5 commit f2b9ec7
Showing 1 changed file with 100 additions and 60 deletions.
160 changes: 100 additions & 60 deletions React/Base/RCTKeyCommands.m
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,38 @@

#import <UIKit/UIKit.h>

#import <objc/message.h>
#import <objc/runtime.h>
#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 <NSCopying>

@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;
Expand All @@ -41,29 +55,32 @@ - (id)copyWithZone:(__unused NSZone *)zone

- (NSUInteger)hash
{
return _keyCommand.input.hash ^ _keyCommand.modifierFlags;
return _key.hash ^ _flags;
}

- (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
{
return [NSString stringWithFormat:@"<%@:%p input=\"%@\" flags=%lld hasBlock=%@>",
[self class],
self,
_keyCommand.input,
(long long)_keyCommand.modifierFlags,
_key,
(long long)_flags,
_block ? @"YES" : @"NO"];
}

Expand All @@ -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<UIKeyCommand *> *)RCT_keyCommands
- (void)handleKeyUIEventSwizzle:(UIEvent *)event
{
NSSet<RCTKeyCommand *> *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<UIWindow *> *)allWindows
{
BOOL includeInternalWindows = YES;
BOOL onlyVisibleWindows = NO;

// Obfuscating selector allWindowsIncludingInternalWindows:onlyVisibleWindows:
NSArray<NSString *> *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<UIWindow *> *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
Expand Down Expand Up @@ -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];
}
Expand Down

0 comments on commit f2b9ec7

Please sign in to comment.