diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h index 8edb46868b1..12f4209f63a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h @@ -35,6 +35,9 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, strong, readonly) UIScrollView *scrollView; +/** Focus area of newly-activated text input relative to the window to compare against UIKeyboardFrameBegin/End */ +@property (nonatomic, assign) CGRect firstResponderFocus; + /* * Returns the subview of the scroll view that the component uses to mount all subcomponents into. That's useful to * separate component views from auxiliary views to be able to reliably implement pull-to-refresh- and RTL-related @@ -59,4 +62,10 @@ NS_ASSUME_NONNULL_BEGIN @end +@interface UIView (RCTScrollViewComponentView) + +- (void)reactUpdateResponderOffsetForScrollView:(RCTScrollViewComponentView *)scrollView; + +@end + NS_ASSUME_NONNULL_END 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 899d38bc249..837741d938d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -98,6 +98,7 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt // some other part of the system scrolls scroll view. BOOL _isUserTriggeredScrolling; BOOL _shouldUpdateContentInsetAdjustmentBehavior; + BOOL _automaticallyAdjustKeyboardInsets; CGPoint _contentOffsetWhenClipped; @@ -128,12 +129,16 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt ((RCTEnhancedScrollView *)_scrollView).overridingDelegate = self; _isUserTriggeredScrolling = NO; _shouldUpdateContentInsetAdjustmentBehavior = YES; + _automaticallyAdjustKeyboardInsets = NO; [self addSubview:_scrollView]; _containerView = [[UIView alloc] initWithFrame:CGRectZero]; [_scrollView addSubview:_containerView]; [self.scrollViewDelegateSplitter addDelegate:self]; +#if TARGET_OS_IOS + [self _registerKeyboardListener]; +#endif _scrollEventThrottle = 0; _endDraggingSensitivityMultiplier = 1; @@ -149,6 +154,111 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt [self.scrollViewDelegateSplitter removeAllDelegates]; } +#if TARGET_OS_IOS +- (void)_registerKeyboardListener +{ + // According to Apple docs, we don't need to explicitly unregister the observer, it's done automatically. + // See the Apple documentation: + // https://developer.apple.com/documentation/foundation/nsnotificationcenter/1413994-removeobserver?language=objc + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(_keyboardWillChangeFrame:) + name:UIKeyboardWillChangeFrameNotification + object:nil]; +} + +- (void)_keyboardWillChangeFrame:(NSNotification *)notification +{ + if (!_automaticallyAdjustKeyboardInsets) { + return; + } + BOOL isHorizontal = _scrollView.contentSize.width > self.frame.size.width; + if (isHorizontal) { + return; + } + + bool isInverted = [self isInverted]; + double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + + UIViewAnimationCurve curve = + (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue]; + CGRect keyboardEndFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + + CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil]; + CGFloat scrollViewLowerY = isInverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height; + + UIEdgeInsets newEdgeInsets = _scrollView.contentInset; + CGFloat inset = MAX(scrollViewLowerY - keyboardEndFrame.origin.y, 0); + if (isInverted) { + newEdgeInsets.top = MAX(inset, _scrollView.contentInset.top); + } else { + newEdgeInsets.bottom = MAX(inset, _scrollView.contentInset.bottom); + } + + CGPoint newContentOffset = _scrollView.contentOffset; + self.firstResponderFocus = CGRectNull; + + CGFloat contentDiff = 0; + if ([[UIApplication sharedApplication] sendAction:@selector(reactUpdateResponderOffsetForScrollView:) + to:nil + from:self + forEvent:nil]) { + if (CGRectEqualToRect(_firstResponderFocus, CGRectNull)) { + // Text input view is outside of the scroll view. + return; + } + + CGRect viewIntersection = CGRectIntersection(self.firstResponderFocus, keyboardEndFrame); + + if (CGRectIsNull(viewIntersection)) { + return; + } + + // Inner text field focused + CGFloat focusEnd = CGRectGetMaxY(self.firstResponderFocus); + if (focusEnd > keyboardEndFrame.origin.y) { + // Text field active region is below visible area with keyboard - update diff to bring into view + contentDiff = keyboardEndFrame.origin.y - focusEnd; + } + } + + if (isInverted) { + newContentOffset.y += contentDiff; + } else { + newContentOffset.y -= contentDiff; + } + + if (@available(iOS 14.0, *)) { + // On iOS when Prefer Cross-Fade Transitions is enabled, the keyboard position + // & height is reported differently (0 instead of Y position value matching height of frame) + // Fixes similar issue we saw with https://github.com/facebook/react-native/pull/34503 + if (UIAccessibilityPrefersCrossFadeTransitions() && keyboardEndFrame.size.height == 0) { + newContentOffset.y = 0; + newEdgeInsets.bottom = 0; + } + } + + [UIView animateWithDuration:duration + delay:0.0 + options:animationOptionsWithCurve(curve) + animations:^{ + self->_scrollView.contentInset = newEdgeInsets; + self->_scrollView.verticalScrollIndicatorInsets = newEdgeInsets; + [self scrollToOffset:newContentOffset animated:NO]; + } + completion: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; +} +#endif + - (RCTGenericDelegateSplitter> *)scrollViewDelegateSplitter { return ((RCTEnhancedScrollView *)_scrollView).delegateSplitter; @@ -190,6 +300,12 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt } } +- (bool)isInverted +{ + // Look into the entry at position 2,2 to check if scaleY is applied + return self.layer.transform.m22 == -1; +} + - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps { const auto &oldScrollViewProps = static_cast(*_props); @@ -225,6 +341,10 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt MAP_SCROLL_VIEW_PROP(showsHorizontalScrollIndicator); MAP_SCROLL_VIEW_PROP(showsVerticalScrollIndicator); + if (oldScrollViewProps.automaticallyAdjustKeyboardInsets != newScrollViewProps.automaticallyAdjustKeyboardInsets) { + _automaticallyAdjustKeyboardInsets = newScrollViewProps.automaticallyAdjustKeyboardInsets; + } + if (oldScrollViewProps.scrollIndicatorInsets != newScrollViewProps.scrollIndicatorInsets) { _scrollView.scrollIndicatorInsets = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.scrollIndicatorInsets); } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 8e7cc58c43a..3ddda9c5c29 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -12,6 +12,7 @@ #import #import +#import #import #import #import @@ -22,6 +23,9 @@ #import "RCTFabricComponentsPlugins.h" +/** Native iOS text field bottom keyboard offset amount */ +static const CGFloat kSingleLineKeyboardBottomOffset = 15.0; + using namespace facebook::react; @interface RCTTextInputComponentView () @@ -96,6 +100,30 @@ static NSSet *returnKeyTypesSet; [self _restoreTextSelection]; } +- (void)reactUpdateResponderOffsetForScrollView:(RCTScrollViewComponentView *)scrollView +{ + if (![self isDescendantOfView:scrollView.scrollView] || !_backedTextInputView.isFirstResponder) { + // View is outside scroll view or it's not a first responder. + return; + } + + UITextRange *selectedTextRange = _backedTextInputView.selectedTextRange; + UITextSelectionRect *selection = [_backedTextInputView selectionRectsForRange:selectedTextRange].firstObject; + CGRect focusRect; + if (selection == nil) { + // No active selection or caret - fallback to entire input frame + focusRect = self.bounds; + } else { + // Focus on text selection frame + focusRect = selection.rect; + BOOL isMultiline = [_backedTextInputView isKindOfClass:[UITextView class]]; + if (!isMultiline) { + focusRect.size.height += kSingleLineKeyboardBottomOffset; + } + } + scrollView.firstResponderFocus = [self convertRect:focusRect toView:nil]; +} + #pragma mark - RCTViewComponentView overrides - (NSObject *)accessibilityElement diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp index 54a72d28088..df2adf005c6 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp @@ -91,6 +91,15 @@ ScrollViewProps::ScrollViewProps( "automaticallyAdjustsScrollIndicatorInsets", sourceProps.automaticallyAdjustsScrollIndicatorInsets, true)), + automaticallyAdjustKeyboardInsets( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.automaticallyAdjustKeyboardInsets + : convertRawProp( + context, + rawProps, + "automaticallyAdjustKeyboardInsets", + sourceProps.automaticallyAdjustKeyboardInsets, + false)), decelerationRate( CoreFeatures::enablePropIteratorSetter ? sourceProps.decelerationRate diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h index d93441a08ff..387ff3ec035 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h @@ -40,6 +40,7 @@ class ScrollViewProps final : public ViewProps { bool centerContent{}; bool automaticallyAdjustContentInsets{}; bool automaticallyAdjustsScrollIndicatorInsets{true}; + bool automaticallyAdjustKeyboardInsets{false}; Float decelerationRate{0.998f}; Float endDraggingSensitivityMultiplier{1}; bool enableSyncOnScroll{false};