diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index a803387b98bf..763867ecd85d 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -213,6 +213,25 @@ - (void)scrollRangeToVisible:(NSRange)range [super scrollRangeToVisible:range]; } +// Since UITextView is a UIScrollView, UIKit only scrolls this view to reveal the cursor. If UITextView is obscured when +// enclosed in a ScrollView, the cursor is never revealed. Therefore we forward the reveal upward to the nearest +// scrollable ancestor. +- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated +{ + [super scrollRectToVisible:rect animated:animated]; + + if (!self.isFirstResponder) { + return; + } + + UIScrollView *scrollableAncestor = [self nearestScrollableAncestor]; + if (scrollableAncestor == nil) { + return; + } + + [scrollableAncestor scrollRectToVisible:[scrollableAncestor convertRect:rect fromView:self] animated:animated]; +} + - (void)paste:(id)sender { _textWasPasted = YES; @@ -379,4 +398,14 @@ - (CGRect)caretRectForPosition:(UITextPosition *)position #pragma mark - Utility Methods +- (nullable UIScrollView *)nearestScrollableAncestor +{ + for (UIView *superview = self.superview; superview != nil; superview = superview.superview) { + if ([superview isKindOfClass:[UIScrollView class]] && ((UIScrollView *)superview).isScrollEnabled) { + return (UIScrollView *)superview; + } + } + return nil; +} + @end diff --git a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js index 9bf56b7f6a16..750496f9a369 100644 --- a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js +++ b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js @@ -20,6 +20,7 @@ import { KeyboardAvoidingView, Modal, Pressable, + ScrollView, StyleSheet, Text, TextInput, @@ -47,74 +48,78 @@ const TextInputForm = () => { ); }; -const CloseButton = ( - props: - {behavior: any, setModalOpen: any} | {behavior: string, setModalOpen: any}, -) => { +const CloseButton = (props: {setModalOpen: boolean => void}) => { return ( - - props.setModalOpen(false)} - style={styles.closeButton}> - Close - - + props.setModalOpen(false)} + style={styles.closeButton}> + Close + ); }; -const KeyboardAvoidingViewBehaviour = () => { - const [modalOpen, setModalOpen] = useState(false); - const [behavior, setBehavior] = useState('padding'); +type KeyboardAvoidingBehavior = 'padding' | 'position' | 'height'; + +const BEHAVIORS: Array = [ + 'padding', + 'position', + 'height', +]; + +const BEHAVIOR_DESCRIPTIONS: {[KeyboardAvoidingBehavior]: string} = { + padding: 'Sets bottom padding equal to the keyboard overlap.', + position: 'Offsets the content container up by the keyboard overlap.', + height: 'Shrinks the view height by the keyboard overlap.', +}; + +const BehaviorPicker = (props: { + behavior: KeyboardAvoidingBehavior, + setBehavior: KeyboardAvoidingBehavior => void, +}) => { return ( - - - {/* $FlowFixMe[incompatible-type] Natural Inference rollout. See - * https://fburl.com/workplace/6291gfvu */} - - - setBehavior('padding')} - style={[ - styles.pillStyle, - {backgroundColor: behavior === 'padding' ? 'blue' : 'white'}, - ]}> - - Padding - - - setBehavior('position')} - style={[ - styles.pillStyle, - {backgroundColor: behavior === 'position' ? 'blue' : 'white'}, - ]}> - - Position - - + + + {BEHAVIORS.map(behavior => { + const selected = props.behavior === behavior; + return ( setBehavior('height')} + key={behavior} + onPress={() => props.setBehavior(behavior)} style={[ styles.pillStyle, - {backgroundColor: behavior === 'height' ? 'blue' : 'white'}, + {backgroundColor: selected ? 'blue' : 'white'}, ]}> - Height + {behavior} - - + ); + })} + + + {BEHAVIOR_DESCRIPTIONS[props.behavior]} + + + ); +}; + +const KeyboardAvoidingViewBehaviour = () => { + const [modalOpen, setModalOpen] = useState(false); + const [behavior, setBehavior] = useState('padding'); + return ( + + + + + @@ -140,7 +145,7 @@ const KeyboardAvoidingDisabled = () => { enabled={false} behavior={'height'} style={styles.container}> - + @@ -162,7 +167,7 @@ const KeyboardAvoidingVerticalOffset = () => { keyboardVerticalOffset={20} behavior={'padding'} style={styles.container}> - + @@ -185,7 +190,7 @@ const KeyboardAvoidingContentContainerStyle = () => { behavior={'position'} style={styles.container} contentContainerStyle={styles.contentContainer}> - + @@ -198,6 +203,46 @@ const KeyboardAvoidingContentContainerStyle = () => { ); }; +const KeyboardAvoidingScrollView = () => { + const [modalOpen, setModalOpen] = useState(false); + const [behavior, setBehavior] = useState('padding'); + return ( + + + + + + + + + {Array.from({length: 10}, (_, index) => ( + + Item {index + 1} + + ))} + + + + + + + setModalOpen(true)}> + Open Example + + + + ); +}; + const styles = StyleSheet.create({ outerContainer: { flex: 1, @@ -205,24 +250,43 @@ const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', - alignItems: 'center', + alignSelf: 'center', paddingHorizontal: 20, paddingTop: 20, + width: '100%', + maxWidth: 340, }, contentContainer: { paddingTop: 20, backgroundColor: '#abdebf', }, + scrollViewContainer: { + flex: 1, + paddingTop: 100, + }, + scrollViewContent: { + paddingBottom: 60, + paddingHorizontal: 20, + }, + fillerItem: { + backgroundColor: '#eeeeee', + borderRadius: 8, + paddingVertical: 16, + paddingHorizontal: 12, + marginBottom: 20, + alignItems: 'center', + }, textInput: { borderRadius: 5, borderWidth: 1, - height: 44, - width: 300, + minHeight: 44, marginBottom: 20, paddingHorizontal: 10, }, - closeView: { - alignSelf: 'stretch', + multilineTextInput: { + minHeight: 88, + paddingVertical: 10, + textAlignVertical: 'top', }, pillStyle: { padding: 10, @@ -233,8 +297,7 @@ const styles = StyleSheet.create({ borderColor: 'blue', }, closeButton: { - flexDirection: 'row', - justifyContent: 'flex-end', + alignSelf: 'flex-end', marginVertical: 10, padding: 10, }, @@ -242,6 +305,11 @@ const styles = StyleSheet.create({ fontWeight: '500', color: 'blue', }, + behaviorDescription: { + textAlign: 'center', + alignSelf: 'center', + width: 200, + }, }); exports.title = 'KeyboardAvoidingView'; @@ -251,7 +319,7 @@ exports.examples = [ { title: 'Keyboard Avoiding View with different behaviors', description: - ('Specify how to react to the presence of the keyboard. Android and iOS both interact' + + ('Specify how to react to the presence of the keyboard. Android and iOS both interact ' + 'with this prop differently. On both iOS and Android, setting behavior is recommended.') as string, render(): React.Node { return ; @@ -260,7 +328,7 @@ exports.examples = [ { title: 'Keyboard Avoiding View with keyboardVerticalOffset={distance}', description: - ('This is the distance between the top of the user screen and the react native' + + ('This is the distance between the top of the user screen and the React Native ' + 'view, may be non-zero in some use cases. Defaults to 0.') as string, render(): React.Node { return ; @@ -268,14 +336,26 @@ exports.examples = [ }, { title: 'Keyboard Avoiding View with enabled={false}', + description: 'Disable the KeyboardAvoidingView.' as string, render(): React.Node { return ; }, }, { title: 'Keyboard Avoiding View with contentContainerStyle', + description: + 'Specify the style of the content container View when behavior is set to position.' as string, render(): React.Node { return ; }, }, + { + title: 'Keyboard Avoiding View with ScrollView', + description: + ('A ScrollView with filler content and TextInputs at the bottom inside the scrollable content. ' + + 'TextInputs should still be visible when focused.') as string, + render(): React.Node { + return ; + }, + }, ] as Array;