diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm index 233c76ed84092f..b0d71dcd3508bb 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm @@ -10,6 +10,7 @@ #import #import #import +#import #import #import #import @@ -19,6 +20,9 @@ #import #import +/** Native iOS text field bottom keyboard offset amount */ +static const CGFloat kSingleLineKeyboardBottomOffset = 15.0; + @implementation RCTBaseTextInputView { __weak RCTBridge *_bridge; __weak id _eventDispatcher; @@ -27,6 +31,30 @@ @implementation RCTBaseTextInputView { BOOL _didMoveToWindow; } +- (void)reactUpdateResponderOffsetForScrollView:(RCTScrollView *)scrollView +{ + if (![self isDescendantOfView:scrollView]) { + // View is outside scroll view + return; + } + + UITextRange *selectedTextRange = self.backedTextInputView.selectedTextRange; + UITextSelectionRect *selection = [self.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 = [self.backedTextInputView isKindOfClass:[UITextView class]]; + if (!isMultiline) { + focusRect.size.height += kSingleLineKeyboardBottomOffset; + } + } + scrollView.firstResponderFocus = [self convertRect:focusRect toView:nil]; +} + - (instancetype)initWithBridge:(RCTBridge *)bridge { RCTAssertParam(bridge); diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.h b/packages/react-native/React/Views/ScrollView/RCTScrollView.h index 14554f688ac6d6..d57793b65d9fe7 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.h +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.h @@ -48,6 +48,8 @@ @property (nonatomic, assign) BOOL snapToEnd; @property (nonatomic, copy) NSString *snapToAlignment; @property (nonatomic, assign) BOOL inverted; +/** Focus area of newly-activated text input relative to the window to compare against UIKeyboardFrameBegin/End */ +@property (nonatomic, assign) CGRect firstResponderFocus; // NOTE: currently these event props are only declared so we can export the // event names to JS - we don't call the blocks directly because scroll events @@ -61,6 +63,12 @@ @end +@interface UIView (RCTScrollView) + +- (void)reactUpdateResponderOffsetForScrollView:(RCTScrollView *)scrollView; + +@end + @interface RCTScrollView (Internal) - (void)updateContentSizeIfNeeded; diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index 16f300b97668d0..1b506c877ebdc9 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -307,6 +307,7 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification } double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationCurve curve = (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue]; CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue]; @@ -324,7 +325,24 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification } CGPoint newContentOffset = _scrollView.contentOffset; - CGFloat contentDiff = endFrame.origin.y - beginFrame.origin.y; + self.firstResponderFocus = CGRectNull; + + CGFloat contentDiff = 0; + if ([[UIApplication sharedApplication] sendAction:@selector(reactUpdateResponderOffsetForScrollView:) + to:nil + from:self + forEvent:nil]) { + // Inner text field focused + CGFloat focusEnd = CGRectGetMaxY(self.firstResponderFocus); + BOOL didFocusExternalTextField = focusEnd == INFINITY; + if (!didFocusExternalTextField && focusEnd > endFrame.origin.y) { + // Text field active region is below visible area with keyboard - update diff to bring into view + contentDiff = endFrame.origin.y - focusEnd; + } + } else if (endFrame.origin.y <= beginFrame.origin.y) { + // Keyboard opened for other reason + contentDiff = endFrame.origin.y - beginFrame.origin.y; + } if (self.inverted) { newContentOffset.y += contentDiff; } else { diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewKeyboardInsetsIOSExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewKeyboardInsetsIOSExample.js new file mode 100644 index 00000000000000..04ace36d56c323 --- /dev/null +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewKeyboardInsetsIOSExample.js @@ -0,0 +1,165 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import * as React from 'react'; + +import { + ScrollView, + FlatList, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from 'react-native'; + +export function ScrollViewKeyboardInsetsExample() { + const [automaticallyAdjustKeyboardInsets, setAutomaticallyAdjustKeyboardInsets] = React.useState(true); + const [flatList, setFlatList] = React.useState(false); + const [inverted, setInverted] = React.useState(false); + const [heightRestricted, setHeightRestricted] = React.useState(false); + + const scrollViewProps = { + style: heightRestricted && styles.scrollViewHeightRestricted, + contentContainerStyle: styles.scrollViewContent, + automaticallyAdjustKeyboardInsets: automaticallyAdjustKeyboardInsets, + keyboardDismissMode: 'interactive', + }; + + const data = [...Array(20).keys()]; + const renderItem = ({ item, index }) => { + const largeInput = (index % 5) === 4; + return ( + + + + ); + }; + + return ( + + + automaticallyAdjustKeyboardInsets is {automaticallyAdjustKeyboardInsets + ''} + setAutomaticallyAdjustKeyboardInsets(v)} + value={automaticallyAdjustKeyboardInsets} + style={styles.controlSwitch}/> + + + FlatList is {flatList + ''} + setFlatList(v)} + value={flatList} + style={styles.controlSwitch}/> + + {flatList && ( + + inverted is {inverted + ''} + setInverted(v)} + value={inverted} + style={styles.controlSwitch}/> + + )} + + HeightRestricted is {heightRestricted + ''} + setHeightRestricted(v)} + value={heightRestricted} + style={styles.controlSwitch}/> + + + + + {flatList + ? ( + + ) + : ( + + {data.map((item, index) => renderItem({ item, index }))} + + ) + } + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'stretch', + justifyContent: 'flex-start', + }, + scrollViewHeightRestricted: { + marginVertical: 50, + borderColor: '#f00', + borderWidth: 1, + }, + scrollViewContent: { + paddingVertical: 5, + paddingHorizontal: 10, + }, + textInputRow: { + borderWidth: 1, + marginVertical: 8, + borderColor: '#999', + }, + textInput: { + width: '100%', + backgroundColor: '#fff', + fontSize: 24, + padding: 8, + }, + textInputLarger: { + minHeight: 200, + }, + controlRow: { + padding: 10, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: '#fff', + borderTopWidth: 1, + borderTopColor: '#ccc', + borderBottomWidth: 1, + borderBottomColor: '#ccc', + }, + controlSwitch: { + }, + controlTextInput: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 10, + borderWidth: 2, + borderColor: '#ccc', + borderRadius: 8, + }, + code: { + fontSize: 12, + fontFamily: 'Courier', + }, +}); + +exports.title = 'ScrollViewKeyboardInsets'; +exports.category = 'iOS'; +exports.description = + 'ScrollView automaticallyAdjustKeyboardInsets adjusts keyboard insets when soft keyboard is activated.'; +exports.examples = [ + { + title: ' automaticallyAdjustKeyboardInsets Example', + render: (): React.Node => , + }, +]; diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js index ed8ca43be2f221..fa0587ccd1454c 100644 --- a/packages/rn-tester/js/utils/RNTesterList.ios.js +++ b/packages/rn-tester/js/utils/RNTesterList.ios.js @@ -89,6 +89,10 @@ const Components: Array = [ key: 'ScrollViewIndicatorInsetsExample', module: require('../examples/ScrollView/ScrollViewIndicatorInsetsIOSExample'), }, + { + key: 'ScrollViewKeyboardInsetsExample', + module: require('../examples/ScrollView/ScrollViewKeyboardInsetsIOSExample'), + }, { key: 'SectionListIndex', module: require('../examples/SectionList/SectionListIndex'),