1

I have a UIScrollView which I've taught to automatically adjust contentInset and scrollIndicatorInsets to avoid the Keyboard.

I have achieved this by listening to the UIKeyboardWillChangeFrameNotification notification, and automatically inset the UIScrollView by the Keyboard Frame's height. Here's the code:

- (void)registerKeyboardListener
{
  [[NSNotificationCenter defaultCenter] addObserver:self
                                           selector:@selector(keyboardWillChangeFrame:)
                                               name:UIKeyboardWillChangeFrameNotification
                                             object:nil];
}

static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCurve curve)
{
  // UIViewAnimationCurve #7 is used for keyboard and therefore private - so we can't use switch/case here.
  // source: https://stackoverflow.com/a/7327374/5281431
  RCTAssert(UIViewAnimationCurveLinear << 16 == UIViewAnimationOptionCurveLinear, @"Unexpected implementation of UIViewAnimationCurve");
  return curve << 16;
}

- (void)keyboardWillChangeFrame:(NSNotification*)notification
{
  if (![self automaticallyAdjustKeyboardInsets]) {
    return;
  }
  if ([self isHorizontal:_scrollView]) {
    return;
  }

  double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
  UIViewAnimationCurve curve = (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue];
  CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue];
  CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];

  CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
  CGFloat scrollViewLowerY = self.inverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;

  UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
  CGFloat inset = MAX(scrollViewLowerY - endFrame.origin.y, 0);
  if (self.inverted) {
    newEdgeInsets.top = inset;
  } else {
    newEdgeInsets.bottom = inset;
  }

  CGPoint newContentOffset = _scrollView.contentOffset;
  CGFloat contentDiff = endFrame.origin.y - beginFrame.origin.y;
  if (self.inverted) {
    newContentOffset.y += contentDiff;
  } else {
    newContentOffset.y -= contentDiff;
  }

  [UIView animateWithDuration:duration
                        delay:0.0
                      options:animationOptionsWithCurve(curve)
                   animations:^{
    self->_scrollView.contentInset = newEdgeInsets;
    self->_scrollView.scrollIndicatorInsets = newEdgeInsets;
    [self scrollToOffset:newContentOffset animated:NO];
  } completion:nil];
}

Full source code: RCTScrollView.m

While this works perfectly fine when the Keyboard shows/hides, I'm experiencing a bit weird behaviour while navigating.

When the user navigates to a new page or pops the current page, my keyboardWillChangeFrame notification is called with a Keyboard .origin.y of exactly the screen height, in other words - the keyboard is fully dismissed.

This is a problem for two reasons:

  1. My inputAccessoryView also belongs to the Keyboard
  2. The Keyboard (and the inputAccessoryView) don't actually dismiss, so the keyboardWillChangeFrame notification seems to be fired incorrectly.

This looks really weird as everything seems to move when you navigate to a new page or pop the current one, and it gets even worse when you try to interactively dismiss the page via the pop gesture.

Here's a Demo Video of how that looks:

Demo GIF of bug

Full-res 60FPS version: Here

What really baffles me about all this, is that the keyboardWillChangeFrame function is only called once, and the UIScrollView animates that change interactively. Surely there must be a property to disable this?

Does anyone know why that's happening and how I can avoid it?

Link to the GitHub PR/discussion with more context: facebook/react-native #31402

mrousavy
  • 857
  • 8
  • 25

0 Answers0