From 2f19220aaee10f8678f86515731e468a4ef134d9 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 21 Oct 2026 17:12:42 +0300 Subject: [PATCH] Apply keyboard insets when automaticallyAdjustKeyboardInsets changes while the keyboard is visible The ScrollView only applied or removed its keyboard inset in response to a UIKeyboardWillChangeFrameNotification. Turning the prop on while the keyboard was up gave no inset until the keyboard moved again, and turning it off left the inset in place for good, since the hide notification was ignored too. Remember the keyboard's end frame from every notification, and when the prop changes while the keyboard is visible, apply the inset as if the keyboard had just appeared, or remove it and clamp the content offset. Co-Authored-By: Claude Opus 5.5 --- .../ScrollView/RCTScrollViewComponentView.mm | 85 +++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index d9e3b901824e..d6dba385c2be 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -104,6 +104,10 @@ @implementation RCTScrollViewComponentView { BOOL _isUserTriggeredScrolling; BOOL _shouldUpdateContentInsetAdjustmentBehavior; BOOL _automaticallyAdjustKeyboardInsets; + // The keyboard's end frame from the latest keyboard notification, tracked even while + // `automaticallyAdjustKeyboardInsets` is off so that turning it on can apply the inset. + // CGRectNull until the first notification. + CGRect _keyboardEndFrame; CGPoint _contentOffsetWhenClipped; @@ -147,6 +151,7 @@ - (instancetype)initWithFrame:(CGRect)frame _isUserTriggeredScrolling = NO; _shouldUpdateContentInsetAdjustmentBehavior = YES; _automaticallyAdjustKeyboardInsets = NO; + _keyboardEndFrame = CGRectNull; _isAccessibilityAPIUsed = NO; [self addSubview:_scrollView]; @@ -186,21 +191,78 @@ - (void)_registerKeyboardListener - (void)_keyboardWillChangeFrame:(NSNotification *)notification { + CGRect keyboardEndFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + _keyboardEndFrame = keyboardEndFrame; if (!_automaticallyAdjustKeyboardInsets) { return; } + + double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationCurve curve = + (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue]; + CGRect keyboardBeginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue]; + + [self _adjustInsetsForKeyboardEndFrame:keyboardEndFrame + keyboardBeginFrame:keyboardBeginFrame + duration:duration + curve:curve]; +} + +- (BOOL)_isKeyboardVisible +{ + return !CGRectIsNull(_keyboardEndFrame) && CGRectGetHeight(_keyboardEndFrame) > 0 && + CGRectGetMinY(_keyboardEndFrame) < CGRectGetMaxY(self.window.screen.bounds); +} + +/* + * The inset is otherwise only updated by keyboard notifications, so when + * `automaticallyAdjustKeyboardInsets` changes while the keyboard is visible, apply or remove it here. + */ +- (void)_updateInsetsForAutomaticallyAdjustKeyboardInsetsChange +{ + if (self.window == nil || ![self _isKeyboardVisible]) { + return; + } + + if (_automaticallyAdjustKeyboardInsets) { + // Treat the keyboard as if it had just appeared where it is now. + [self _adjustInsetsForKeyboardEndFrame:_keyboardEndFrame + keyboardBeginFrame:_keyboardEndFrame + duration:0 + curve:UIViewAnimationCurveLinear]; + return; + } + BOOL isHorizontal = _scrollView.contentSize.width > self.frame.size.width; if (isHorizontal) { return; } - bool isInverted = [self isInverted]; - double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + // Remove the keyboard inset, which no keyboard notification will do now that the prop is off. + const auto &props = static_cast(*_props); + UIEdgeInsets newEdgeInsets = _scrollView.contentInset; + if ([self isInverted]) { + newEdgeInsets.top = props.contentInset.top; + } else { + newEdgeInsets.bottom = props.contentInset.bottom; + } + _scrollView.contentInset = newEdgeInsets; + _scrollView.verticalScrollIndicatorInsets = newEdgeInsets; + // Clamps the offset to the smaller scrollable range. + [self scrollTo:_scrollView.contentOffset.x y:_scrollView.contentOffset.y animated:NO]; +} - UIViewAnimationCurve curve = - (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue]; - CGRect keyboardEndFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; - CGRect keyboardBeginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue]; +- (void)_adjustInsetsForKeyboardEndFrame:(CGRect)keyboardEndFrame + keyboardBeginFrame:(CGRect)keyboardBeginFrame + duration:(double)duration + curve:(UIViewAnimationCurve)curve +{ + BOOL isHorizontal = _scrollView.contentSize.width > self.frame.size.width; + if (isHorizontal) { + return; + } + + bool isInverted = [self isInverted]; CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil]; CGFloat scrollViewLowerY = isInverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height; @@ -360,6 +422,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & MAP_SCROLL_VIEW_PROP(showsHorizontalScrollIndicator); MAP_SCROLL_VIEW_PROP(showsVerticalScrollIndicator); +#if TARGET_OS_IOS + BOOL automaticallyAdjustKeyboardInsetsChanged = + _automaticallyAdjustKeyboardInsets != newScrollViewProps.automaticallyAdjustKeyboardInsets; +#endif _automaticallyAdjustKeyboardInsets = newScrollViewProps.automaticallyAdjustKeyboardInsets; if (oldScrollViewProps.scrollIndicatorInsets != newScrollViewProps.scrollIndicatorInsets) { @@ -458,6 +524,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // Must run after `super updateProps:` so it is based on the latest calculated view. ((RCTEnhancedScrollView *)_scrollView).inverted = [self isInverted]; + +#if TARGET_OS_IOS + // Runs after the contentInset prop is applied above, which would overwrite the keyboard inset. + if (automaticallyAdjustKeyboardInsetsChanged) { + [self _updateInsetsForAutomaticallyAdjustKeyboardInsetsChange]; + } +#endif } - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState