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;