Skip to content

Commit 2048ff4

Browse files
ybcd00facebook-github-bot
authored andcommitted
Report keyboard show notifications with an empty frame as hide events
Summary: When a foldable iPhone is folded, unfolded or rotated, iOS can send a "keyboard will show" notification for a keyboard with zero height while no text field is focused. `RCTKeyboardObserver` passed it to JS as a show, so screens acted as if a keyboard were open: inputs stayed in their keyboard-up position and some pages went blank. Now a zero-height show with no focused text field is sent as a hide. Everything else is unchanged, including hardware keyboards on iPad, which send zero-height shows while a field is focused. Changelog: [iOS][Fixed] - Report zero-height keyboard show notifications with no focused text field as `keyboardWillHide`/`keyboardDidHide` Reviewed By: cipolleschi Differential Revision: D127272268
1 parent eaf4c26 commit 2048ff4

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

‎packages/react-native/React/CoreModules/RCTKeyboardObserver.mm‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,31 @@
1818
@interface RCTKeyboardObserver () <NativeKeyboardObserverSpec>
1919
@end
2020

21+
#if !TARGET_OS_TV
22+
@interface UIResponder (RCTKeyboardObserver)
23+
- (void)reactCollectFirstResponder:(NSMutableArray<UIResponder *> *)firstResponders;
24+
@end
25+
26+
@implementation UIResponder (RCTKeyboardObserver)
27+
28+
- (void)reactCollectFirstResponder:(NSMutableArray<UIResponder *> *)firstResponders
29+
{
30+
if (self.isFirstResponder) {
31+
[firstResponders addObject:self];
32+
}
33+
}
34+
35+
@end
36+
37+
// UIKit sends a nil-targeted action to the first responder, if there is one, and then up its responder chain.
38+
static BOOL RCTHasFirstResponder()
39+
{
40+
NSMutableArray<UIResponder *> *firstResponders = [NSMutableArray new];
41+
[RCTSharedApplication() sendAction:@selector(reactCollectFirstResponder:) to:nil from:firstResponders forEvent:nil];
42+
return firstResponders.count > 0;
43+
}
44+
#endif
45+
2146
@implementation RCTKeyboardObserver
2247

2348
RCT_EXPORT_MODULE()
@@ -69,8 +94,34 @@ -(void)EVENT : (NSNotification *)notification
6994
[self sendEventWithName:@ #EVENT body:RCTParseKeyboardNotification(notification)]; \
7095
}
7196

97+
#if !TARGET_OS_TV
98+
// UIKit can post a show notification with an empty end frame while no view is first responder, for example when the
99+
// window is resized. No keyboard can be shown then, so it is sent as the matching hide.
100+
- (void)keyboardWillShow:(NSNotification *)notification
101+
{
102+
if (self.callableJSModules == nil) {
103+
return;
104+
}
105+
BOOL isShown =
106+
!CGRectIsEmpty([notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]) || RCTHasFirstResponder();
107+
[self sendEventWithName:isShown ? @"keyboardWillShow" : @"keyboardWillHide"
108+
body:RCTParseKeyboardNotification(notification)];
109+
}
110+
111+
- (void)keyboardDidShow:(NSNotification *)notification
112+
{
113+
if (self.callableJSModules == nil) {
114+
return;
115+
}
116+
BOOL isShown =
117+
!CGRectIsEmpty([notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]) || RCTHasFirstResponder();
118+
[self sendEventWithName:isShown ? @"keyboardDidShow" : @"keyboardDidHide"
119+
body:RCTParseKeyboardNotification(notification)];
120+
}
121+
#else
72122
IMPLEMENT_KEYBOARD_HANDLER(keyboardWillShow)
73123
IMPLEMENT_KEYBOARD_HANDLER(keyboardDidShow)
124+
#endif
74125
IMPLEMENT_KEYBOARD_HANDLER(keyboardWillHide)
75126
IMPLEMENT_KEYBOARD_HANDLER(keyboardDidHide)
76127
IMPLEMENT_KEYBOARD_HANDLER(keyboardWillChangeFrame)
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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 <React/RCTBridgeModule.h>
9+
#import <React/RCTKeyboardObserver.h>
10+
#import <XCTest/XCTest.h>
11+
12+
#if !TARGET_OS_TV
13+
14+
@interface RCTKeyboardObserver (Tests)
15+
- (void)keyboardWillShow:(NSNotification *)notification;
16+
- (void)keyboardDidShow:(NSNotification *)notification;
17+
- (void)keyboardWillHide:(NSNotification *)notification;
18+
- (void)keyboardDidHide:(NSNotification *)notification;
19+
- (void)keyboardWillChangeFrame:(NSNotification *)notification;
20+
- (void)keyboardDidChangeFrame:(NSNotification *)notification;
21+
@end
22+
23+
@interface RCTRecordingKeyboardObserver : RCTKeyboardObserver
24+
@property (nonatomic, readonly) NSMutableArray<NSString *> *sentEventNames;
25+
@end
26+
27+
@implementation RCTRecordingKeyboardObserver
28+
29+
- (instancetype)init
30+
{
31+
if (self = [super init]) {
32+
_sentEventNames = [NSMutableArray new];
33+
}
34+
return self;
35+
}
36+
37+
- (void)sendEventWithName:(NSString *)name body:(id)body
38+
{
39+
[_sentEventNames addObject:name];
40+
}
41+
42+
@end
43+
44+
static NSNotification *keyboardNotification(NSNotificationName name, CGRect endFrame)
45+
{
46+
return [NSNotification notificationWithName:name
47+
object:nil
48+
userInfo:@{
49+
UIKeyboardFrameBeginUserInfoKey : [NSValue valueWithCGRect:CGRectZero],
50+
UIKeyboardFrameEndUserInfoKey : [NSValue valueWithCGRect:endFrame],
51+
}];
52+
}
53+
54+
@interface RCTKeyboardObserverTests : XCTestCase
55+
@end
56+
57+
@implementation RCTKeyboardObserverTests {
58+
RCTCallableJSModules *_callableJSModules;
59+
RCTRecordingKeyboardObserver *_observer;
60+
}
61+
62+
- (void)setUp
63+
{
64+
[super setUp];
65+
_callableJSModules = [RCTCallableJSModules new];
66+
_observer = [RCTRecordingKeyboardObserver new];
67+
_observer.callableJSModules = _callableJSModules;
68+
}
69+
70+
- (void)testShowWithKeyboardFrameIsSentAsShow
71+
{
72+
CGRect keyboardFrame = CGRectMake(0, 389, 466, 289);
73+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, keyboardFrame)];
74+
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, keyboardFrame)];
75+
76+
XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ]));
77+
}
78+
79+
- (void)testShowWithKeyboardFrameAtTopIsSentAsShow
80+
{
81+
CGRect keyboardFrameAtTop = CGRectMake(0, 0, 466, 289);
82+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, keyboardFrameAtTop)];
83+
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, keyboardFrameAtTop)];
84+
85+
XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ]));
86+
}
87+
88+
- (void)testShowWithEmptyFrameIsSentAsHide
89+
{
90+
CGRect zeroHeightFrame = CGRectMake(0, 951, 669, 0);
91+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, zeroHeightFrame)];
92+
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, zeroHeightFrame)];
93+
94+
CGRect zeroWidthFrame = CGRectMake(273, 0, 0, 264);
95+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, zeroWidthFrame)];
96+
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, zeroWidthFrame)];
97+
98+
XCTAssertEqualObjects(
99+
_observer.sentEventNames,
100+
(@[ @"keyboardWillHide", @"keyboardDidHide", @"keyboardWillHide", @"keyboardDidHide" ]));
101+
}
102+
103+
- (void)testShowWithEmptyFrameAndFirstResponderIsSentAsShow
104+
{
105+
UIWindowScene *windowScene = (UIWindowScene *)UIApplication.sharedApplication.connectedScenes.anyObject;
106+
if (windowScene == nil) {
107+
XCTFail(@"The test host has no window scene");
108+
return;
109+
}
110+
UIWindow *window = [[UIWindow alloc] initWithWindowScene:windowScene];
111+
UITextField *textField = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, 200, 40)];
112+
[window addSubview:textField];
113+
[window makeKeyAndVisible];
114+
XCTAssertTrue([textField becomeFirstResponder]);
115+
116+
CGRect emptyFrame = CGRectMake(0, 1180, 0, 0);
117+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, emptyFrame)];
118+
[_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, emptyFrame)];
119+
120+
[textField resignFirstResponder];
121+
window.hidden = YES;
122+
XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ]));
123+
}
124+
125+
- (void)testHideAndFrameChangeAreUnchanged
126+
{
127+
CGRect emptyFrame = CGRectMake(0, 678, 466, 0);
128+
[_observer keyboardWillHide:keyboardNotification(UIKeyboardWillHideNotification, emptyFrame)];
129+
[_observer keyboardDidHide:keyboardNotification(UIKeyboardDidHideNotification, emptyFrame)];
130+
[_observer keyboardWillChangeFrame:keyboardNotification(UIKeyboardWillChangeFrameNotification, emptyFrame)];
131+
[_observer keyboardDidChangeFrame:keyboardNotification(UIKeyboardDidChangeFrameNotification, emptyFrame)];
132+
133+
XCTAssertEqualObjects(
134+
_observer.sentEventNames,
135+
(@[ @"keyboardWillHide", @"keyboardDidHide", @"keyboardWillChangeFrame", @"keyboardDidChangeFrame" ]));
136+
}
137+
138+
- (void)testNothingIsSentWithoutJSModules
139+
{
140+
_observer.callableJSModules = nil;
141+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, CGRectMake(0, 951, 669, 0))];
142+
[_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, CGRectMake(0, 389, 466, 289))];
143+
144+
XCTAssertEqual(_observer.sentEventNames.count, 0u);
145+
}
146+
147+
@end
148+
149+
#endif

0 commit comments

Comments
 (0)