From f6063923ab828276be8c982a31672327b1c250de Mon Sep 17 00:00:00 2001 From: Jason Zheng Date: Tue, 15 Sep 2026 17:21:45 -0700 Subject: [PATCH] Fix KeyboardAvoidingView behavior for multi-line TextInputs on iOS (#58436) Summary: Fixes the issue described in [#16826](https://github.com/react/react-native/issues/16826), where KeyboardAvoidingView does not reveal multi-line TextInputs when used with an enclosing ScrollView on iOS. ### Root Cause When a text input is focused on iOS, UIKit reveals the input by applying a scroll operation on the innermost ScrollView. When a single-line UITextField is inside a ScrollView, this scrolls the ScrollView itself, revealing the input correctly. However, the multi-line UITextView itself extends UIScrollView. When a UITextView is inside a ScrollView, UIKit applies the scroll operation only on the input and not the surrounding ScrollView. If the input is in a position where it is covered by the keyboard, it will remain covered afterwards. Community members have found a workaround by setting `scrollEnabled={false}` on multi-line inputs. This disables scrolling on the UITextView, so the scroll operation is correctly applied on the surrounding ScrollView. However, this workaround comes with obvious downsides, and solutions offered by third-party libraries are often used instead. ### This PR Modify RCTUITextView to override the `scrollRectToVisible(_:animated:)` method which is what UIKit uses to implicitly reveal the text view. The scrolling operation is performed on the text view like before, but we then forward the same operation to the nearest scrollable ancestor (such as a surrounding ScrollView). This ensures that the text view itself is always scrolled into view as scrolling is always applied to the parent. ### Limitations The main limitation is that we rely on UIKit to reveal the text input, since KeyboardAvoidingView itself implements no scrolling logic. This also leads to some discrepancy in behavior with Android. On Android, the entire text box is revealed on focus, whereas on iOS with this fix, only the caret line is revealed. We can introduce additional logic in RCTUITextView to reveal the entire text view, but this won't apply to text views with `scrollEnabled={false}` which will still only reveal the caret line using UIKit's default behavior. Native approaches on iOS generally involve manually handling the keyboard avoiding behavior by adding keyboard event listeners and manually triggering `scrollView.scrollRectToVisible()` when receiving the `keyboardDidShow()` notification. We can do something similar in `RCTScrollViewComponentView`, but this explicitly violates the contract there which prohibits behavior that contradicts a standard UIScrollView. In contrast, this approach aligns with what is already being used to reveal both UITextField and UITextView with `scrollEnabled={false}`. Changelog: [iOS][Fixed] Reveal multi-line text inputs when using KeyboardAvoidingView with a ScrollView Reviewed By: cipolleschi Differential Revision: D119204440 --- .../Text/TextInput/Multiline/RCTUITextView.mm | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index a803387b98bf..e64f5543b960 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -213,6 +213,28 @@ - (void)scrollRangeToVisible:(NSRange)range [super scrollRangeToVisible:range]; } +// When scrollEnabled is true, UIKit's caret reveal only scrolls UITextView and not enclosing ScrollViews, so a caret +// can remain obscured by the keyboard. 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; + } + + // Expand the caret height by 4 on top and bottom so it has sufficient padding to avoid touching the keyboard border. + // This matches UIKit behavior for UITextView when scrollEnabled is false. + CGRect caretRect = [scrollableAncestor convertRect:CGRectInset(rect, 0, -4) fromView:self]; + + [scrollableAncestor scrollRectToVisible:caretRect animated:animated]; +} + - (void)paste:(id)sender { _textWasPasted = YES; @@ -379,4 +401,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