From 9ca16605e0eb7b30996f10109aa9080088078995 Mon Sep 17 00:00:00 2001 From: Adam Averay Date: Fri, 15 Sep 2023 02:34:40 -0700 Subject: [PATCH] Feature: Improvements to `automaticallyAdjustKeyboardInsets` (#37766) Summary: This is a reopened version of https://github.com/facebook/react-native/issues/35224 by isidoro98 which was closed without explanation, updated to resolve new merge conflicts and now includes an example in the RN-Tester app. Aside from that it is unchanged. Here is isidoro98's description from their original PR: This PR builds on top of https://github.com/facebook/react-native/issues/31402, which introduced the `automaticallyAdjustsScrollIndicatorInsets` functionality. It aims to fix one of RN's longstanding pain point regarding the keyboard. The changes provide a better way of handling the `ScrollView` offset when a keyboard opens. Currently, when a keyboard opens we apply an **offset** to the `Scrollview` that matches the size of the keyboard. This approach is great if we are using an `InputAccessoryView` but if we have multiple `TextInputs` in a `ScrollView`; offsetting the content by the size of the keyboard doesn't yield the best user experience. ## Changelog: [iOS] [Changed] - Scroll `ScrollView` text fields into view with `automaticallyAdjustsScrollIndicatorInsets` Pull Request resolved: https://github.com/facebook/react-native/pull/37766 Test Plan: The videos below compare the current and proposed behaviors for the following code: ```js {[...Array(10).keys()].map(item => ( ))} ``` | Current behaviour | Proposal | |-|-| | ![https://user-images.githubusercontent.com/25139053/200194972-1ac5f1cd-2d61-4118-ad77-95c04d30c98d.mov](https://user-images.githubusercontent.com/25139053/200194972-1ac5f1cd-2d61-4118-ad77-95c04d30c98d.mov) | ![https://user-images.githubusercontent.com/25139053/200194990-53f28296-be11-4a47-be70-cec917d7deb1.mov](https://user-images.githubusercontent.com/25139053/200194990-53f28296-be11-4a47-be70-cec917d7deb1.mov) | As can be seen in the video, the **current behavior** applies an offset to the `ScrollView` content regardless of where the `TextInput` sits on the screen. The proposal checks if the `TextInput` will be covered by the keyboard, and only then applies an offset. The offset applied is not the full size of the keyboard but instead only the required amount so that the `TextInput` is a **specific** distance above the top of the keyboard (customizable using the new `bottomKeyboardOffset` prop). This achieves a less "jumpy" experience for the user. The proposal doesn't change the behavior of the `ScrollView` offset when an `InputAccessory` view is used, since it checks if the `TextField` that triggered the keyboard is a **descendant** of the `ScrollView` or not. ## Why not use other existing solutions? RN ecosystem offers other alternatives for dealing with a keyboard inside a ScrollView, such as a `KeyboardAvoidingView` or using third party libraries like `react-native-keyboard-aware-scroll-view`. But as shown in the recordings below, these solutions don't provide the smoothness or behavior that can be achieved with `automaticallyAdjustsScrollIndicatorInsets`. | KeyboardAvoidingView | rn-keyboard-aware-scroll-view | |-|-| | ![https://user-images.githubusercontent.com/25139053/200195145-de742f0a-6913-4099-83c4-7693448a8933.mov](https://user-images.githubusercontent.com/25139053/200195145-de742f0a-6913-4099-83c4-7693448a8933.mov) | ![https://user-images.githubusercontent.com/25139053/200195151-80745533-16b5-4aa0-b6cd-d01041dbd001.mov](https://user-images.githubusercontent.com/25139053/200195151-80745533-16b5-4aa0-b6cd-d01041dbd001.mov) | As shown in the videos, the `TextInput` is hidden by the keyboard for a split second before becoming visible. Code for the videos above: ```js // KeyboardAvoidingView {[...Array(10).keys()].map(item => ( ))} ``` ```js // rn-keyboard-aware-scroll-view {[...Array(10).keys()].map(item => ( ))} ``` Reviewed By: sammy-SC Differential Revision: D49269426 Pulled By: javache fbshipit-source-id: 6ec2e7b45f6854dd34b9dbb06ab77053b6419733 --- .../Text/TextInput/RCTBaseTextInputView.mm | 28 +++ .../React/Views/ScrollView/RCTScrollView.h | 8 + .../React/Views/ScrollView/RCTScrollView.m | 20 ++- .../ScrollViewKeyboardInsetsIOSExample.js | 165 ++++++++++++++++++ .../rn-tester/js/utils/RNTesterList.ios.js | 4 + 5 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 packages/rn-tester/js/examples/ScrollView/ScrollViewKeyboardInsetsIOSExample.js 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'),