Skip to content

Commit

Permalink
Refactor keyboard observer on iOS (#4360)
Browse files Browse the repository at this point in the history
## Summary

This PR refactors the logic in REAKeyboardEventObserver to address some
number of different issues:
1) no change notification when keyboard size changes due to change in
language or showing suggestions bar
2) issue with keyboard height notification when opening modal (see
#4339)
3) issue with non-zero height after keyboard hides completely.

Here is the list of the main thing that this refactor changes:
1) We no longer use willShow/willHide/didShow/didHide notifications for
keyboard and only use willChangeFrame which is the most reliable one and
triggers also when the keyboard updates
2) We no longer use a technique where the keyboard size is determined
based on the frame of the keyboard view. This was problematic as that
frame would sometimes reflect a one-frame delayed size which resulted in
the animation not coming down to 0 or not updating at all when
immediately hidden or appear (by a modal). Instead, we use UIView
animation to animate a complementary view that is placed outside of the
screen visible area. We use UIView animation to animate height of that
invisible view and then use display link to access presentation layer in
order to read up-to-date height.

There are few gatchas related to the new technique used:
1) Because we only look at frame change notification we need to properly
determine the state of the keyboard. We do it based on the target
height. If it is 0 then we consider the state to be either closing or
closed – we select the final state based on whether there is animation
running (closed state when no animation, or closing when we are
animating). This prevents us from changing state to closing when
keyboard is only becoming shorter (e.g. when text suggestion bar is
closed). We handle opening/open state in similar way.
2) Due to the UIView animation technique we need to check
presentationLayer.animationKeys to determine if the layer runs the
animation. If it doesn't then we extract height from the view frame and
not from the presentationLayer frame, as the presentationLayer may not
have an up-to-date information (it will be delayed by one frame).

## Test plan

Use example from #4339 and animated keyboard screen from Example app.
Open/close text suggestion bar to see how the app reacts when keyboard
is changing height and not just opening/closing.
  • Loading branch information
kmagiera authored Apr 18, 2023
1 parent 7cdabc5 commit 874d829
Showing 1 changed file with 51 additions and 131 deletions.
182 changes: 51 additions & 131 deletions ios/keyboardObserver/REAKeyboardEventObserver.m
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@ typedef NS_ENUM(NSUInteger, KeyboardState) {
};

@implementation REAKeyboardEventObserver {
UIView *_measuringView;
NSNumber *_nextListenerId;
NSMutableDictionary *_listeners;
CADisplayLink *displayLink;
int _windowsCount;
UIView *_keyboardView;
CADisplayLink *_displayLink;
KeyboardState _state;
bool _shouldInvalidateDisplayLink;
}

- (instancetype)init
Expand All @@ -27,52 +25,16 @@ - (instancetype)init
_listeners = [[NSMutableDictionary alloc] init];
_nextListenerId = @0;
_state = UNKNOWN;
_shouldInvalidateDisplayLink = false;

NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];

[notificationCenter addObserver:self
selector:@selector(clearListeners)
selector:@selector(cleanupListeners)
name:RCTBridgeDidInvalidateModulesNotification
object:nil];
return self;
}

// copied from
// https://github.com/tonlabs/UIKit/blob/bd5651e4723d547bde0cb86ca1c27813cedab4a9/casts/keyboard/ios/UIKitKeyboardIosFrameListener.m
- (UIView *)findKeyboardView
{
for (UIWindow *window in [UIApplication.sharedApplication.windows objectEnumerator]) {
if ([window isKindOfClass:NSClassFromString(@"UITextEffectsWindow")]) {
for (UIView *containerView in window.subviews) {
if ([containerView isKindOfClass:NSClassFromString(@"UIInputSetContainerView")]) {
for (UIView *hostView in containerView.subviews) {
if ([hostView isKindOfClass:NSClassFromString(@"UIInputSetHostView")]) {
return hostView;
}
}
}
}
}
}
return nil;
}

- (UIView *)getKeyboardView
{
/**
* If the count of windows has changed it means there might be a new UITextEffectsWindow,
* thus we have to obtain a new `keyboardView`
*/
int windowsCount = [UIApplication.sharedApplication.windows count];

if (_keyboardView == nil || windowsCount != _windowsCount) {
_keyboardView = [self findKeyboardView];
_windowsCount = windowsCount;
}
return _keyboardView;
}

#if TARGET_OS_TV
- (int)subscribeForKeyboardEvents:(KeyboardEventListenerBlock)listener
{
Expand All @@ -86,108 +48,83 @@ - (void)unsubscribeFromKeyboardEvents:(int)listenerId
}
#else

- (void)runAnimation
- (void)runUpdater
{
if (!displayLink) {
displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateKeyboardFrame)];
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
if (!_displayLink) {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateKeyboardFrame)];
_displayLink.preferredFramesPerSecond = 120; // will fallback to 60 fps for devices without Pro Motion display
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
_shouldInvalidateDisplayLink = false;
}

- (void)stopAnimation
{
_displayLink.paused = NO;
[self updateKeyboardFrame];
// there might be a case that keyboard will change height in the next frame
// (for example changing keyboard language so that suggestions appear)
// so we invalidate display link after we handle that in the next frame
_shouldInvalidateDisplayLink = true;
}

- (void)updateKeyboardFrame
{
UIView *keyboardView = [self getKeyboardView];
if (keyboardView == nil) {
return;
BOOL isAnimatingKeyboardChange = _measuringView.layer.presentationLayer.animationKeys.count != 0;
CGRect measuringFrame =
isAnimatingKeyboardChange ? _measuringView.layer.presentationLayer.frame : _measuringView.frame;
CGFloat keyboardHeight = measuringFrame.size.height;

if (!isAnimatingKeyboardChange) {
// measuring view is no longer running an animation, we should settle in OPEN/CLOSE state
if (_state == OPENING || _state == CLOSING) {
_state = _state == OPENING ? OPEN : CLOSED;
}
// stop display link updates if no animation is running
_displayLink.paused = YES;
}

CGFloat keyboardHeight = [self computeKeyboardHeight:keyboardView];
for (NSString *key in _listeners.allKeys) {
((KeyboardEventListenerBlock)_listeners[key])(_state, keyboardHeight);
}

if (_shouldInvalidateDisplayLink) {
_shouldInvalidateDisplayLink = false;
[displayLink invalidate];
displayLink = nil;
}
}

- (CGFloat)computeKeyboardHeight:(UIView *)keyboardView
- (void)keyboardWillChangeFrame:(NSNotification *)notification
{
CGFloat keyboardFrameY = [keyboardView.layer presentationLayer].frame.origin.y;
CGFloat keyboardWindowH = keyboardView.window.bounds.size.height;
CGFloat keyboardHeight = keyboardWindowH - keyboardFrameY;
return keyboardHeight;
}
NSDictionary *userInfo = [notification userInfo];
CGRect beginFrame = [[userInfo objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue];
CGRect endFrame = [[userInfo objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
NSTimeInterval animationDuration = [[userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue];
CGSize windowSize = [[[UIApplication sharedApplication] delegate] window].frame.size;

- (void)keyboardWillShow:(NSNotification *)notification
{
_state = OPENING;
[self runAnimation];
}

- (void)keyboardDidShow:(NSNotification *)notification
{
_state = OPEN;
[self stopAnimation];
}
CGFloat beginHeight = windowSize.height - beginFrame.origin.y;
CGFloat endHeight = windowSize.height - endFrame.origin.y;

- (void)keyboardWillHide:(NSNotification *)notification
{
_state = CLOSING;
[self runAnimation];
}
if (endHeight > 0 && _state != OPEN) {
_state = OPENING;
} else if (endHeight == 0 && _state != CLOSED) {
_state = CLOSING;
}

- (void)keyboardDidHide:(NSNotification *)notification
{
_state = CLOSED;
[self stopAnimation];
_measuringView.frame = CGRectMake(0, -1, 0, beginHeight);
[UIView animateWithDuration:animationDuration
animations:^{
self->_measuringView.frame = CGRectMake(0, -1, 0, endHeight);
}];
[self runUpdater];
}

- (int)subscribeForKeyboardEvents:(KeyboardEventListenerBlock)listener
{
NSNumber *listenerId = [_nextListenerId copy];
_nextListenerId = [NSNumber numberWithInt:[_nextListenerId intValue] + 1];
RCTExecuteOnMainQueue(^() {
if (!self->_measuringView) {
self->_measuringView = [[UIView alloc] initWithFrame:CGRectMake(0, -1, 0, 0)];
UIWindow *keyWindow = [[[UIApplication sharedApplication] delegate] window];
[keyWindow addSubview:self->_measuringView];
}
if ([self->_listeners count] == 0) {
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];

[notificationCenter addObserver:self
selector:@selector(keyboardWillHide:)
name:UIKeyboardWillHideNotification
object:nil];

[notificationCenter addObserver:self
selector:@selector(keyboardWillShow:)
name:UIKeyboardWillShowNotification
object:nil];

[notificationCenter addObserver:self
selector:@selector(keyboardDidHide:)
name:UIKeyboardDidHideNotification
object:nil];

[notificationCenter addObserver:self
selector:@selector(keyboardDidShow:)
name:UIKeyboardDidShowNotification
selector:@selector(keyboardWillChangeFrame:)
name:UIKeyboardWillChangeFrameNotification
object:nil];
}

[self->_listeners setObject:listener forKey:listenerId];
if (self->_state == UNKNOWN) {
[self recognizeInitialKeyboardState];
}
});
return [listenerId intValue];
}
Expand All @@ -198,34 +135,17 @@ - (void)unsubscribeFromKeyboardEvents:(int)listenerId
NSNumber *_listenerId = [NSNumber numberWithInt:listenerId];
[self->_listeners removeObjectForKey:_listenerId];
if ([self->_listeners count] == 0) {
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillHideNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardDidHideNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardDidShowNotification object:nil];
}
});
}

- (void)recognizeInitialKeyboardState
{
RCTExecuteOnMainQueue(^() {
UIView *keyboardView = [self getKeyboardView];
if (keyboardView == nil) {
self->_state = CLOSED;
} else {
CGFloat keyboardHeight = [self computeKeyboardHeight:keyboardView];
self->_state = keyboardHeight == 0 ? CLOSED : OPEN;
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillChangeFrameNotification object:nil];
}
[self updateKeyboardFrame];
});
}

- (void)clearListeners
- (void)cleanupListeners
{
RCTUnsafeExecuteOnMainQueueSync(^() {
[self->_listeners removeAllObjects];
[self->displayLink invalidate];
self->displayLink = nil;
[self->_displayLink invalidate];
self->_displayLink = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
});
}
Expand Down

0 comments on commit 874d829

Please sign in to comment.