Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: ScrollView automaticallyAdjustKeyboardInsets #31402

Conversation

mrousavy
Copy link
Contributor

@mrousavy mrousavy commented Apr 21, 2021

Summary

Currently, ScrollViews provide the prop keyboardDismissMode which lets you choose "interactive". However when the keyboard is shown, it will be rendered above the ScrollView, potentially blocking content.

With the automaticallyAdjustKeyboardInsets prop the ScrollView will automatically adjust it's contentInset, scrollIndicatorInsets and contentOffset (scroll Y) props to push the content up so nothing gets blocked.

  • The animation curve and duration of the Keyboard is exactly matched.
  • The absolute position of the ScrollView is respected, so if the Keyboard only overlaps 10 pixels of the ScrollView, it will only get inset by 10 pixels.
  • By respecting the absolute position on screen, this automatically makes it fully compatible with phones with notches (custom safe areas)
  • By using the keyboard frame, this also works for different sized keyboards and even <InputAccessoryView>s
  • This also supports maintainVisibleContentPosition and autoscrollToTopThreshold.
  • I also fixed an issue with the maintainVisibleContentPosition (autoscrollToTopThreshold) prop(s), so they behave more reliably when contentInsets are applied. (This makes automatically scrolling to new items fully compatible with automaticallyAdjustKeyboardInsets)

Changelog

  • [iOS] [Added] - ScrollView: automaticallyAdjustKeyboardInsets prop: Automatically animate contentInset, scrollIndicatorInsets and contentOffset (scroll Y) to avoid the Keyboard. (respecting absolute position on screen and safe-areas)
  • [iOS] [Fixed] - ScrollView: Respect contentInset when animating new items with autoscrollToTopThreshold, make automaticallyAdjustKeyboardInsets work with autoscrollToTopThreshold (includes vertical, vertical-inverted, horizontal and horizontal-inverted ScrollViews)

Test Plan

Before After
before_tap.MP4
after_tap.MP4

"Why not just use <KeyboardAvoidingView>?"

Before (with <KeyboardAvoidingView>) After (with automaticallyAdjustKeyboardInsets)
before_kav.MP4
after_interactive.MP4

Also notice how the <KeyboardAvoidingView> does not match the animation curve of the Keyboard

Usage

export const ChatPage = ({
  flatListProps,
  textInputProps
}: Props): React.ReactElement => (
  <>
    <FlatList
      {...flatListProps}
      keyboardDismissMode="interactive"
      automaticallyAdjustContentInsets={false}
      contentInsetAdjustmentBehavior="never"
      maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 100 }}
      automaticallyAdjustKeyboardInsets={true}
    />
    <InputAccessoryView backgroundColor={colors.white}>
      <ChatInput {...textInputProps} />
    </InputAccessoryView>
  </>
);

Related Issues

mrousavy added 9 commits July 9, 2020 13:51
Sets the [isModalInPresentation](https://developer.apple.com/documentation/uikit/uiviewcontroller/3229894-ismodalinpresentation) property to `NO` if the Presentation Style is formSheet or pageSheet, otherwise `YES`
Controls whether the ScrollView should automatically adjust it's contentInset and scrollViewInsets when the Keyboard changes it's size. The default value is false.
Sets the [isModalInPresentation](https://developer.apple.com/documentation/uikit/uiviewcontroller/3229894-ismodalinpresentation) property to `NO` if the Presentation Style is formSheet or pageSheet, otherwise `YES`
@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Apr 21, 2021
@analysis-bot
Copy link

analysis-bot commented Apr 22, 2021

Platform Engine Arch Size (bytes) Diff
android hermes arm64-v8a 7,706,017 +0
android hermes armeabi-v7a 7,236,134 +0
android hermes x86 8,126,071 +0
android hermes x86_64 8,091,037 +0
android jsc arm64-v8a 9,625,917 +0
android jsc armeabi-v7a 8,543,561 +0
android jsc x86 9,640,254 +0
android jsc x86_64 10,248,905 +0

Base commit: b494ae0

@analysis-bot
Copy link

analysis-bot commented Apr 22, 2021

Platform Engine Arch Size (bytes) Diff
ios - universal n/a --

Base commit: b494ae0

@mrousavy
Copy link
Contributor Author

Btw sorry for the commit history I just noticed that I had some old stuff on my fork's master 🙈

@mrousavy
Copy link
Contributor Author

Why are the tests failing?

@mrousavy
Copy link
Contributor Author

I have also considered putting all this logic inside automaticallyAdjustContentInsets, so that the user only has a single prop to configure if he wants the ScrollView to automatically dodge any overlapping views (navigation bars, tab bars, toolbars and keyboards).
Let me know what you guys prefer.

Copy link
Contributor

@Ashoat Ashoat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been a longstanding limitation of React Native for iOS. Would be amazing if it were finally resolved.

Code looks good to me but admittedly I don't have a whole lot of context on RCTScrollView.

Co-authored-by: Bartosz Kaszubowski <gosimek@gmail.com>
@RohovDmytro
Copy link

The Man! Cool feature!

@ebellumat
Copy link

Hooohaay! You're a hero man! Everyone had this problem and no one solved, ultil now!

@mrousavy
Copy link
Contributor Author

mrousavy commented Apr 26, 2021

While this feature works fine, I have noticed something strange when navigating:

RPReplay_Final1619439728.MP4

I have traced this back to find out where it actually updates the contentOffset (or contentInset) values, and noticed that the "Keyboard Did Change Frame" notification was called once with the keyboard being fully dismissed (it's .origin.y value was exactly my screen's height), but it still smoothly animated the ScrollView downwards. Strange.

So in general, everytime this UIViewController blurs, the keyboard notification gets invoked with a keyboard frame (which is fully dismissed), and somehow it interactively slides down the ScrollView. That is, when another page gets pushed, when this page gets popped, and it happens interactively when you use the pop to dismiss gesture for the UIViewController.

This is obviously only the case when a Keyboard is extended, but since an <InputAccessoryView> also counts as the Keyboard, this happens everytime in my case.

- (void)keyboardWillChangeFrame:(NSNotification*)notification
{
if (![self automaticallyAdjustKeyboardInsets]) {
return;
}
if ([self isHorizontal:_scrollView]) {
return;
}
double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
UIViewAnimationCurve curve = (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue];
CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue];
CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
CGFloat scrollViewLowerY = self.inverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;
UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
CGFloat inset = MAX(scrollViewLowerY - endFrame.origin.y, 0);
if (self.inverted) {
newEdgeInsets.top = inset;
} else {
newEdgeInsets.bottom = inset;
}
CGPoint newContentOffset = _scrollView.contentOffset;
CGFloat contentDiff = endFrame.origin.y - beginFrame.origin.y;
if (self.inverted) {
newContentOffset.y += contentDiff;
} else {
newContentOffset.y -= contentDiff;
}
[UIView animateWithDuration:duration
delay:0.0
options:animationOptionsWithCurve(curve)
animations:^{
self->_scrollView.contentInset = newEdgeInsets;
self->_scrollView.scrollIndicatorInsets = newEdgeInsets;
[self scrollToOffset:newContentOffset animated:NO];
} completion:nil];
}

Does anyone have an idea why that's happening? Since it is sliding down interactively, there surely must be a way to disable that behaviour.. 🤔

Edit

I think that's iOS' CoreAnimation behaviour. Not sure if this is also reproduceable with non-native screens (react-navigation), but I'm pretty sure it's not caused by my PR.

@jonitef
Copy link

jonitef commented May 14, 2021

Any time estimate when this is going to get merged?

@kelset
Copy link
Contributor

kelset commented Jan 20, 2022

Nudge for @sota000 & @mrousavy - as far as I can see, since it was reverted (8595f3f) there was no follow up on this, is it still planned to go in again?

I didn't see the previous comment, my bad 🤦‍♂️

@sota000
Copy link
Contributor

sota000 commented Jan 20, 2022

This was re-merged in 49a1460 (please see the react-native-bot comment on December 6th, 2021)

@mattgabor
Copy link

mattgabor commented Feb 7, 2022

Is this feature included in the 0.67.2 release?

Nevermind, I see it has v0.68.0-rc.0 tag. Can't wait!

@Bardiamist
Copy link
Contributor

Bardiamist commented Apr 12, 2022

Tried it now in React native 0.68.0, looks good <3
Where it was few years ago :)

@mrousavy
Copy link
Contributor Author

Where it was few years ago :)

My bad, I was still in school

@sshquack
Copy link

Thank you @mrousavy 🙏

Will this new automaticallyAdjustKeyboardInsets ScrollView prop automatically be displayed in the docs page? It's currently not visible in the 0.68 and next docs https://reactnative.dev/docs/scrollview

@jamninetyfive
Copy link

jamninetyfive commented May 7, 2022

Can someone tell me how to add this PR in my version 0.66.4
I can't upgrade to the 0.68 of for some reasons.

@cenzovit
Copy link

I am almost certainly naive to how these different elements act/interact, but I am curious as to why this type of functionality is not applied to KeyboardAvoidingView as well? I have a situation where I have a simple screen with a search input at the top, a scroll view in the middle (of search results), and action buttons on the bottom. For the life of me, I cannot figure out how to get the UX to play nice with keyboardDismissMode interactive. You would think that this fix in conjunction with wrapping the actions in an InputAccessoryView would be sufficient, but due to where the input field is in the layout, the InputAccessoryView does not remain "docked" to the bottom of the screen when the keyboard is dismissed / the input loses focus. Ideally, KeyboardAvoidingView worked properly when the keyboard is dismissed interactively (in which case there would be no need for an InputAccessoryView at all as the buttons could just live in a KeyboardAvoidingView. If someone has any insight into the correct way to keep an InputAccessoryView docked to the bottom for all inputs in a view (in my case a single TextInput), please save me from my misery.

All that said, from someone who has been building social chat applications in react native for the past 4 years, huge kudos to @mrousavy for fixing an an incredibly frustrating issue that has plagued making a basic chat implementation feel native on iOS.

@savelichalex
Copy link

savelichalex commented May 28, 2022

@cenzovit you probably set nativeID to InputAccessuryView, try to remove it, then it should be "docked"

@cenzovit
Copy link

@cenzovit you probably set nativeID to InputAccessuryView, try to remove it, then it should be "docked"

I believe that only works if the TextInput is a child of the InputAccessoryView. In my case, the text input is at the top of the page and is a sibling to the action buttons I would like to dock to the bottom. So, if I exclude the nativeID, although the InputAccessoryView renders initially, when the input is focused, the InputAccessoryView does not mount to the keyboard.

@cenzovit
Copy link

There seems to be some strange behavior occurring between automaticallyAdjustKeyboardInsets and react-native-screens where swiping back is triggering scrollview insets to change interactively with the swipe even though the keyboard (and/or InputAccessoryView) is not being dismissed. I am not sure if it is related, but I have a keyboard will change frame listener and it would appear that the native change frame events are firing even though the keyboard is not actually changing frames. I have a hack in my listener to ignore events with a duration of 350ms because that appears to be the event that is being triggered erroneously on the transition away from the screen.

@mrousavy
Copy link
Contributor Author

@cenzovit I've noticed that as well, maybe this is a RN Screens issue?

@savelichalex
Copy link

When I did my custom implementation of InputAccessoryView I also found the issue, and when I traced it, it appears that a TextField is losing a focus when swipe occurs, I fixed it that way: https://github.com/tonlabs/UIKit/blob/development/casts/keyboard/ios/UIInputAccessoryView%2BListenTextField.m#L77-L91
https://github.com/tonlabs/UIKit/blob/development/casts/keyboard/ios/UIViewController%2BFixUIInputAccessoryView.m#L42-L92
basically I intercept calls for such transition and prevent unset of a focus for a currently focused TextField

@Yassir4
Copy link

Yassir4 commented Sep 30, 2022

This works perfectly fine in normal usage, but when using it in a nested list (ScrollView parent set to horizontal inside it a ScrollView with automaticallyAdjustKeyboardInsets) then things start to break like the keyboard will be dismissed on the first scroll attempt(when you just put the finger to scroll).

facebook-github-bot pushed a commit that referenced this pull request Sep 15, 2023
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
@ServiceTouchDevelopers
Copy link

While this feature works fine, I have noticed something strange when navigating:

RPReplay_Final1619439728.MP4
I have traced this back to find out where it actually updates the contentOffset (or contentInset) values, and noticed that the "Keyboard Did Change Frame" notification was called once with the keyboard being fully dismissed (it's .origin.y value was exactly my screen's height), but it still smoothly animated the ScrollView downwards. Strange.

So in general, everytime this UIViewController blurs, the keyboard notification gets invoked with a keyboard frame (which is fully dismissed), and somehow it interactively slides down the ScrollView. That is, when another page gets pushed, when this page gets popped, and it happens interactively when you use the pop to dismiss gesture for the UIViewController.

This is obviously only the case when a Keyboard is extended, but since an <InputAccessoryView> also counts as the Keyboard, this happens everytime in my case.

- (void)keyboardWillChangeFrame:(NSNotification*)notification
{
if (![self automaticallyAdjustKeyboardInsets]) {
return;
}
if ([self isHorizontal:_scrollView]) {
return;
}
double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
UIViewAnimationCurve curve = (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue];
CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue];
CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
CGFloat scrollViewLowerY = self.inverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;
UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
CGFloat inset = MAX(scrollViewLowerY - endFrame.origin.y, 0);
if (self.inverted) {
newEdgeInsets.top = inset;
} else {
newEdgeInsets.bottom = inset;
}
CGPoint newContentOffset = _scrollView.contentOffset;
CGFloat contentDiff = endFrame.origin.y - beginFrame.origin.y;
if (self.inverted) {
newContentOffset.y += contentDiff;
} else {
newContentOffset.y -= contentDiff;
}
[UIView animateWithDuration:duration
delay:0.0
options:animationOptionsWithCurve(curve)
animations:^{
self->_scrollView.contentInset = newEdgeInsets;
self->_scrollView.scrollIndicatorInsets = newEdgeInsets;
[self scrollToOffset:newContentOffset animated:NO];
} completion:nil];
}

Does anyone have an idea why that's happening? Since it is sliding down interactively, there surely must be a way to disable that behaviour.. 🤔

Edit

I think that's iOS' CoreAnimation behaviour. Not sure if this is also reproduceable with non-native screens (react-navigation), but I'm pretty sure it's not caused by my PR.

Hey guys,
Has anyone managed to resolve this problem?
Grateful.

@fabOnReact
Copy link
Contributor

Hello, I have contributed to this project for 4 years and I have 58 pull requests. I can start working on this PR, fix the issue with nested scrollview and release it with the npm package react-native-improved in 7-8 days. Could you let me know if you are still interested in this issue? Thanks

@fabOnReact
Copy link
Contributor

fabOnReact commented Jan 29, 2024

@ServiceTouchDevelopers @Yassir4 @savelichalex @cenzovit @jamninetyfive @Bardiamist @mattgabor @jonitef Still interested in having this implemented in react-native? We have 2000 issues, so I would need to know if you are still interested in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Merged This PR has been merged. Needs: React Native Team Attention Reverted
Projects
None yet