Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/react-native/React/CoreModules/RCTKeyboardObserver.mm
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@
@interface RCTKeyboardObserver () <NativeKeyboardObserverSpec>
@end

#if !TARGET_OS_TV
@interface UIResponder (RCTKeyboardObserver)
- (void)reactCollectFirstResponder:(NSMutableArray<UIResponder *> *)firstResponders;
@end

@implementation UIResponder (RCTKeyboardObserver)

- (void)reactCollectFirstResponder:(NSMutableArray<UIResponder *> *)firstResponders
{
if (self.isFirstResponder) {
[firstResponders addObject:self];
}
}

@end

// UIKit sends a nil-targeted action to the first responder, if there is one, and then up its responder chain.
static BOOL RCTHasFirstResponder()
{
NSMutableArray<UIResponder *> *firstResponders = [NSMutableArray new];
[RCTSharedApplication() sendAction:@selector(reactCollectFirstResponder:) to:nil from:firstResponders forEvent:nil];
return firstResponders.count > 0;
}
#endif

@implementation RCTKeyboardObserver

RCT_EXPORT_MODULE()
Expand Down Expand Up @@ -69,8 +94,34 @@ -(void)EVENT : (NSNotification *)notification
[self sendEventWithName:@ #EVENT body:RCTParseKeyboardNotification(notification)]; \
}

#if !TARGET_OS_TV
// UIKit can post a show notification with an empty end frame while no view is first responder, for example when the
// window is resized. No keyboard can be shown then, so it is sent as the matching hide.
- (void)keyboardWillShow:(NSNotification *)notification
{
if (self.callableJSModules == nil) {
return;
}
BOOL isShown =
!CGRectIsEmpty([notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]) || RCTHasFirstResponder();
[self sendEventWithName:isShown ? @"keyboardWillShow" : @"keyboardWillHide"
body:RCTParseKeyboardNotification(notification)];
}

- (void)keyboardDidShow:(NSNotification *)notification
{
if (self.callableJSModules == nil) {
return;
}
BOOL isShown =
!CGRectIsEmpty([notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]) || RCTHasFirstResponder();
[self sendEventWithName:isShown ? @"keyboardDidShow" : @"keyboardDidHide"
body:RCTParseKeyboardNotification(notification)];
}
#else
IMPLEMENT_KEYBOARD_HANDLER(keyboardWillShow)
IMPLEMENT_KEYBOARD_HANDLER(keyboardDidShow)
#endif
IMPLEMENT_KEYBOARD_HANDLER(keyboardWillHide)
IMPLEMENT_KEYBOARD_HANDLER(keyboardDidHide)
IMPLEMENT_KEYBOARD_HANDLER(keyboardWillChangeFrame)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <React/RCTBridgeModule.h>
#import <React/RCTKeyboardObserver.h>
#import <XCTest/XCTest.h>

#if !TARGET_OS_TV

@interface RCTKeyboardObserver (Tests)
- (void)keyboardWillShow:(NSNotification *)notification;
- (void)keyboardDidShow:(NSNotification *)notification;
- (void)keyboardWillHide:(NSNotification *)notification;
- (void)keyboardDidHide:(NSNotification *)notification;
- (void)keyboardWillChangeFrame:(NSNotification *)notification;
- (void)keyboardDidChangeFrame:(NSNotification *)notification;
@end

@interface RCTRecordingKeyboardObserver : RCTKeyboardObserver
@property (nonatomic, readonly) NSMutableArray<NSString *> *sentEventNames;
@end

@implementation RCTRecordingKeyboardObserver

- (instancetype)init
{
if (self = [super init]) {
_sentEventNames = [NSMutableArray new];
}
return self;
}

- (void)sendEventWithName:(NSString *)name body:(id)body
{
[_sentEventNames addObject:name];
}

@end

static NSNotification *keyboardNotification(NSNotificationName name, CGRect endFrame)
{
return [NSNotification notificationWithName:name
object:nil
userInfo:@{
UIKeyboardFrameBeginUserInfoKey : [NSValue valueWithCGRect:CGRectZero],
UIKeyboardFrameEndUserInfoKey : [NSValue valueWithCGRect:endFrame],
}];
}

@interface RCTKeyboardObserverTests : XCTestCase
@end

@implementation RCTKeyboardObserverTests {
RCTCallableJSModules *_callableJSModules;
RCTRecordingKeyboardObserver *_observer;
}

- (void)setUp
{
[super setUp];
_callableJSModules = [RCTCallableJSModules new];
_observer = [RCTRecordingKeyboardObserver new];
_observer.callableJSModules = _callableJSModules;
}

- (void)testShowWithKeyboardFrameIsSentAsShow
{
CGRect keyboardFrame = CGRectMake(0, 389, 466, 289);
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, keyboardFrame)];
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, keyboardFrame)];

XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ]));
}

- (void)testShowWithKeyboardFrameAtTopIsSentAsShow
{
CGRect keyboardFrameAtTop = CGRectMake(0, 0, 466, 289);
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, keyboardFrameAtTop)];
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, keyboardFrameAtTop)];

XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ]));
}

- (void)testShowWithEmptyFrameIsSentAsHide
{
CGRect zeroHeightFrame = CGRectMake(0, 951, 669, 0);
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, zeroHeightFrame)];
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, zeroHeightFrame)];

CGRect zeroWidthFrame = CGRectMake(273, 0, 0, 264);
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, zeroWidthFrame)];
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, zeroWidthFrame)];

XCTAssertEqualObjects(
_observer.sentEventNames,
(@[ @"keyboardWillHide", @"keyboardDidHide", @"keyboardWillHide", @"keyboardDidHide" ]));
}

- (void)testShowWithEmptyFrameAndFirstResponderIsSentAsShow
{
UIWindowScene *windowScene = (UIWindowScene *)UIApplication.sharedApplication.connectedScenes.anyObject;
if (windowScene == nil) {
XCTFail(@"The test host has no window scene");
return;
}
UIWindow *window = [[UIWindow alloc] initWithWindowScene:windowScene];
UITextField *textField = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, 200, 40)];
[window addSubview:textField];
[window makeKeyAndVisible];
XCTAssertTrue([textField becomeFirstResponder]);

CGRect emptyFrame = CGRectMake(0, 1180, 0, 0);
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, emptyFrame)];
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, emptyFrame)];

[textField resignFirstResponder];
window.hidden = YES;
XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ]));
}

- (void)testHideAndFrameChangeAreUnchanged
{
CGRect emptyFrame = CGRectMake(0, 678, 466, 0);
[_observer keyboardWillHide:keyboardNotification(UIKeyboardWillHideNotification, emptyFrame)];
[_observer keyboardDidHide:keyboardNotification(UIKeyboardDidHideNotification, emptyFrame)];
[_observer keyboardWillChangeFrame:keyboardNotification(UIKeyboardWillChangeFrameNotification, emptyFrame)];
[_observer keyboardDidChangeFrame:keyboardNotification(UIKeyboardDidChangeFrameNotification, emptyFrame)];

XCTAssertEqualObjects(
_observer.sentEventNames,
(@[ @"keyboardWillHide", @"keyboardDidHide", @"keyboardWillChangeFrame", @"keyboardDidChangeFrame" ]));
}

- (void)testNothingIsSentWithoutJSModules
{
_observer.callableJSModules = nil;
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, CGRectMake(0, 951, 669, 0))];
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, CGRectMake(0, 389, 466, 289))];

XCTAssertEqual(_observer.sentEventNames.count, 0u);
}

@end

#endif
Loading