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

Fix Multiline TextInput with a fixed height scrolls to the bottom when prepending new lines to the text #38679

Closed
wants to merge 26 commits into from
Closed
Changes from 10 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8658471
fix input scroll up
fabOnReact Jul 29, 2023
3ac73aa
Merge branch 'main' into fix-input-field-scrolls-up
fabOnReact Jul 29, 2023
64b64e4
adding conditional checks before disabling scroll
fabOnReact Aug 17, 2023
323cd2d
minor changes
fabOnReact Aug 17, 2023
ce0c4d8
Merge branch 'main' into fix-input-field-scrolls-up
fabOnReact Aug 18, 2023
56a2859
refactor logic
fabOnReact Aug 18, 2023
39d0351
adding conditional checks
fabOnReact Aug 19, 2023
0a29729
minor changes
fabOnReact Aug 19, 2023
d996eef
minor changes
fabOnReact Aug 19, 2023
abbd60e
change type previousScrollEnabled to BOOL
fabOnReact Aug 19, 2023
00bcc51
example
fabOnReact Aug 19, 2023
3318f3e
Revert "example"
fabOnReact Aug 19, 2023
61dd72d
Merge branch 'main' into fix-input-field-scrolls-up
fabOnReact Dec 17, 2023
44207f0
Merge branch 'main' into fix-input-field-scrolls-up
fabOnReact Feb 16, 2024
2e13d47
scroll to the previous position after re-storing the cursor
fabOnReact Feb 16, 2024
e3dfac0
using scrollRangeToVisible instead of scrollEnabled=false
fabOnReact Feb 16, 2024
22b7747
remove scrollEnabled = NO
fabOnReact Feb 16, 2024
65e9946
Merge branch 'main' into fix-input-field-scrolls-up
fabOnReact Feb 18, 2024
6340d60
add log message to RCTUITextField
fabOnReact Feb 18, 2024
a44d620
use self.selectedRange instead of calculating new range
fabOnReact Feb 18, 2024
358706c
adding explanatory comments
fabOnReact Feb 18, 2024
38a7bf0
don’t use instance variable, instead use the offsetStart variable.
fabOnReact Feb 19, 2024
b2580e3
updating comments
fabOnReact Feb 19, 2024
55d11bf
updating comments
fabOnReact Feb 19, 2024
48b2207
Merge branch 'main' into fix-input-field-scrolls-up
fabOnReact Feb 19, 2024
aff06a4
updating comments
fabOnReact Feb 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,22 @@ - (void)_setAttributedString:(NSAttributedString *)attributedString
}
Copy link
Contributor

@NickGerleman NickGerleman Feb 13, 2024

Choose a reason for hiding this comment

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

From sammy-SC:

I'm for fixing UIKit problems and making it better in React Native.

Can we add an e2e test specifically for this behaviour? This is, unfortunately, something that an OSS contributor can't do.

Context for the last one, we've been trying to more often leverage Meta's existing UI testing infra, against examples in RNTester, when we are making adjacent changes.

There isn't currently an equivalent in OSS, so I will be writing some tests, that look like:

test('TextInput: maintains position inserting paragraph in center', async () => {
  await navigateToTestCase(device, 'TextInput', 'multiline');
  await device.click('multiline-textinput');
  await device.type('multiline-textinput', 'Line 1\n');
  await device.type('multiline-textinput', 'Line 2\n');
  await device.type('multiline-textinput', 'Line 3\n');
  
  await device.click(':text("Line 2")');
  await device.type('multiline-textinput', '\n');
  
  await expect('multiline-textinput').toMatchScreenshot();
}

If we wanted to consider a matrix of different scroll offsets, characters, cursor positions, etc, what would the full test matrix look like?

Copy link
Contributor Author

@fabOnReact fabOnReact Feb 19, 2024

Choose a reason for hiding this comment

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

@NickGerleman #38679 (comment)

I'm for fixing UIKit problems and making it better in React Native.

Yes. It is a UIKit problem (Expensify/App#19507 (comment), Expensify/App#19507 (comment) and https://developer.apple.com/forums/thread/652653).

If we wanted to consider a matrix of different scroll offsets, characters, cursor positions, etc, what would the full test matrix look like?

I prepared the following test cases:

Sourcecode of the Example

function TextInputExample() {
  const [text, setText] = React.useState('');
  const [color, setColor] = React.useState('black');

  const changeColorAndText = () => {
    if (color === 'black') {
      setColor('red');
    } else {
      setColor('black');
    }
    setText(text + ' updated.');
  };

  const changeColor = () => {
    if (color === 'black') {
      setColor('red');
    } else {
      setColor('black');
    }
  };

  const appendText = () => {
    setText(text + ' updated.');
  };

  return (
    <View style={{marginTop: 200}}>
      <TextInput
        multiline={true}
        style={[styles.text]}
        onChangeText={text => setText(text)}>
        <Text style={{color: color}}>{text}</Text>
      </TextInput>
      <Button title="change color" onPress={changeColor} />
      <Button title="change color and text" onPress={changeColorAndText} />
      <Button title="append text" onPress={appendText} />
    </View>
  );
}

  • Text selection is not empty (after)
  • Adding a new paragraph (\n) at position 0, then deleting it (after)
  • Adding a new paragraph (\n) in the middle of the text (after)
  • Changing the text color (after)
  • Adding text to the end of the string (after)
  • Changing the text characters (after)
  • Increasing the line height (after)

UITextRange *selectedRange = _backedTextInputView.selectedTextRange;
NSInteger oldTextLength = _backedTextInputView.attributedText.string.length;
BOOL previousScrollEnabled = _backedTextInputView.scrollEnabled;
Copy link
Contributor

Choose a reason for hiding this comment

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

From sammy-SC

can we add a comment and highlight the relevant section to highlight that this is the case it handles?
I know we can always come to this diff but as time passes, it will be harder to find.

NSInteger previousOffsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument
toPosition:selectedRange.start];
if (previousScrollEnabled && previousOffsetStart > 0 && attributedString.string.length >= previousOffsetStart) {
NSString *lastChar = [attributedString.string substringWithRange:NSMakeRange(previousOffsetStart - 1, 1)];
NSInteger previousOffsetFromEnd = oldTextLength - previousOffsetStart;
if (previousOffsetFromEnd > 0 && [lastChar isEqual:@"\n"]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

From sammy-SC

this is oddly specific. If anything else besides new line is inserted, it is fine?

Copy link
Contributor

Choose a reason for hiding this comment

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

This is jogging some memories of what I was confused about back in August, and needed to refresh on again 😅. Could be good to keep an explanation of how newline specifically effects paragraph composition, which is why it is being special cased.

Like, the comment from Sam, about keeping some of the less intuitive details, in code, instead of in review.

Copy link
Contributor Author

@fabOnReact fabOnReact Feb 19, 2024

Choose a reason for hiding this comment

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

@NickGerleman #38679 (comment)

this is oddly specific. If anything else besides new line is inserted, it is fine?

I updated the PR to handle any change in the AttributedString (for example color, adding or deleting a paragraph, changing the lineHeight). Expensify/App#19507 (comment)

Could be good to keep an explanation of how newline specifically effects paragraph composition, which is why it is being special cased.

FIRST EXAMPLE: Adding a new paragraph with the new line character \n changes the UITextView AttributedText and moves the cursor to the end of the text (Expensify/App#19507 (comment)).

SECOND EXAMPLE: Modifying Text styles lineHeight or color causes mutation of the UITextView AttributedText and moves the cursor to the end of the text (Expensify/App#19507 (comment)).

  1. The Text minimumLineHeight changes from 0 to 20
  2. The UITextView attributes lineHeight changes from 0 to 20 (screenshot)
  3. The change in the AttributesText causes the TextInput cursor to move to the end of the text. The change in position of the cursor triggers scroll position to the end of the text.

UIKit issue https://developer.apple.com/forums/thread/652653.

_backedTextInputView.scrollEnabled = NO;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you explain this heuristic? Is it specific to adding newlines?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @NickGerleman

Could you explain this heuristic?

https://github.com/facebook/react-native/pull/38679/files#diff-65bdfd8888173755da0fd38dae8c70ba1561c11ec453e690195a2f327fa6b62bR603

Scrolling is temporarily disabled when prepending a new line character to the attributed string:

  • the cursor is not at the end of the string (previousOffsetFromEnd == 0)
  • the last typed character (lastChar) is a new line character

Is it specific to adding newlines?

Yes. It is specific to adding new lines.

It is iOS issue 652653, previously addressed with commit Maintain cursor position when the text changes in the text input.

  1. Adding a new line changes the text spans and paragraph alignment. The old attributed string may have three spans with different paragraph alignments. The new attributed string could have one span with paragraph alignment 4 (see the test case below for a breakdown).
  2. Changing the AttributedText (text) attributes (alignment) triggers a mutation of the attributed text (_textOf)
  3. Mutating the AttributedString causes the cursor to move to the end of the string
  4. Moving the cursor to the end of the string causes the TextView to scroll to the end

https://developer.apple.com/forums/thread/652653
https://stackoverflow.com/questions/34914948/how-to-stop-cursor-changing-position-when-i-setattributedtext-in-uitextview-dele

Reproducing the issue on an iOS App without react-native

2023-06-17.18-16-16.mp4

Sourcecode available at Expensify/App#19507 (comment).

Details of the changes in the AttributedString attributes (paragraph alignment) when adding new line

Before one of the AttributedString spans has NSParagraphStyle Alignment 0. The string text is "1\n\n2".

CLICK TO OPEN ATTRIBUTED STRING DETAILS

Printing description of oldAttributedString->attributes:
Length 5 (8 blocks, 3 used, block 0 is at 0)
 2 0x600002e70230 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}
 1 0x600002f9d5e0 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 0, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}
 2 0x600002e70230 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}

After The AttributedString has only one span. The span has NSParagraphStyle Alignment 4. The string text is "1\n\n2".

CLICK TO OPEN ATTRIBUTED STRING DETAILS

Printing description of attributedString->attributes:
Length 5 (2 blocks, 1 used, block 0 is at 0)
 5 0x600002e70230 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}

I included a video of the two examples at this link.


_backedTextInputView.attributedText = attributedString;
if (!_backedTextInputView.scrollEnabled && previousScrollEnabled) {
_backedTextInputView.scrollEnabled = YES;
}

_backedTextInputView.attributedText = attributedString;
if (selectedRange.empty) {
// Maintaining a cursor position relative to the end of the old text.
Expand Down