Skip to content

Commit

Permalink
Feature: Improvements to automaticallyAdjustKeyboardInsets (#37766)
Browse files Browse the repository at this point in the history
Summary:
This is a reopened version of #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 #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: #37766

Test Plan:
The videos below compare the current and proposed behaviors for the following code:

```js
<ScrollView
  automaticallyAdjustKeyboardInsets
  keyboardDismissMode="interactive">
  {[...Array(10).keys()].map(item => (
    <CustomTextInput placeholder={item.toString()} key={item} />
  ))}
</ScrollView>
```

| 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
<KeyboardAvoidingView
  style={{flex: 1, flexDirection: 'column', justifyContent: 'center'}}
  behavior="padding"
  enabled>
  <ScrollView>
    {[...Array(10).keys()].map(item => (
      <CustomTextInput placeholder={item.toString()} key={item} />
    ))}
  </ScrollView>
</KeyboardAvoidingView>
```

 ```js
// rn-keyboard-aware-scroll-view
<KeyboardAwareScrollView>
  {[...Array(10).keys()].map(item => (
    <CustomTextInput placeholder={item.toString()} key={item} />
  ))}
</KeyboardAwareScrollView>
```

Reviewed By: sammy-SC

Differential Revision: D49269426

Pulled By: javache

fbshipit-source-id: 6ec2e7b45f6854dd34b9dbb06ab77053b6419733
  • Loading branch information
adamaveray authored and facebook-github-bot committed Sep 15, 2023
1 parent 17154a6 commit 9ca1660
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#import <React/RCTBridge.h>
#import <React/RCTConvert.h>
#import <React/RCTEventDispatcherProtocol.h>
#import <React/RCTScrollView.h>
#import <React/RCTUIManager.h>
#import <React/RCTUtils.h>
#import <React/UIView+React.h>
Expand All @@ -19,6 +20,9 @@
#import <React/RCTTextAttributes.h>
#import <React/RCTTextSelection.h>

/** Native iOS text field bottom keyboard offset amount */
static const CGFloat kSingleLineKeyboardBottomOffset = 15.0;

@implementation RCTBaseTextInputView {
__weak RCTBridge *_bridge;
__weak id<RCTEventDispatcherProtocol> _eventDispatcher;
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/react-native/React/Views/ScrollView/RCTScrollView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,6 +63,12 @@

@end

@interface UIView (RCTScrollView)

- (void)reactUpdateResponderOffsetForScrollView:(RCTScrollView *)scrollView;

@end

@interface RCTScrollView (Internal)

- (void)updateContentSizeIfNeeded;
Expand Down
20 changes: 19 additions & 1 deletion packages/react-native/React/Views/ScrollView/RCTScrollView.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<View key={item} style={styles.textInputRow}>
<TextInput placeholder={item.toString()}
multiline={largeInput}
style={[styles.textInput, largeInput && styles.textInputLarger]}/>
</View>
);
};

return (
<View style={styles.container}>
<View style={styles.controlRow}>
<Text><Text style={styles.code}>automaticallyAdjustKeyboardInsets</Text> is {automaticallyAdjustKeyboardInsets + ''}</Text>
<Switch
onValueChange={v => setAutomaticallyAdjustKeyboardInsets(v)}
value={automaticallyAdjustKeyboardInsets}
style={styles.controlSwitch}/>
</View>
<View style={styles.controlRow}>
<Text><Text style={styles.code}>FlatList</Text> is {flatList + ''}</Text>
<Switch
onValueChange={v => setFlatList(v)}
value={flatList}
style={styles.controlSwitch}/>
</View>
{flatList && (
<View style={styles.controlRow}>
<Text><Text style={styles.code}>inverted</Text> is {inverted + ''}</Text>
<Switch
onValueChange={v => setInverted(v)}
value={inverted}
style={styles.controlSwitch}/>
</View>
)}
<View style={styles.controlRow}>
<Text><Text style={styles.code}>HeightRestricted</Text> is {heightRestricted + ''}</Text>
<Switch
onValueChange={v => setHeightRestricted(v)}
value={heightRestricted}
style={styles.controlSwitch}/>
</View>
<View style={styles.controlRow}>
<TextInput placeholder={'Text input outside scroll view'} style={styles.controlTextInput} />
</View>
{flatList
? (
<FlatList
{...scrollViewProps}
inverted={inverted}
data={data}
renderItem={renderItem}/>
)
: (
<ScrollView {...scrollViewProps}>
{data.map((item, index) => renderItem({ item, index }))}
</ScrollView>
)
}
</View>
);
}

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: '<ScrollView> automaticallyAdjustKeyboardInsets Example',
render: (): React.Node => <ScrollViewKeyboardInsetsExample/>,
},
];
4 changes: 4 additions & 0 deletions packages/rn-tester/js/utils/RNTesterList.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ const Components: Array<RNTesterModuleInfo> = [
key: 'ScrollViewIndicatorInsetsExample',
module: require('../examples/ScrollView/ScrollViewIndicatorInsetsIOSExample'),
},
{
key: 'ScrollViewKeyboardInsetsExample',
module: require('../examples/ScrollView/ScrollViewKeyboardInsetsIOSExample'),
},
{
key: 'SectionListIndex',
module: require('../examples/SectionList/SectionListIndex'),
Expand Down

0 comments on commit 9ca1660

Please sign in to comment.