Skip to content

Commit e10d6ba

Browse files
jkasprzyk17meta-codesync[bot]
authored andcommitted
Fix off-center caret and text in a recycled TextInput on iOS (#59190)
Summary: Fixes #59184. On iOS, a single-line `TextInput` can draw its caret and typed text a few points above its vertical center while it is focused. This happens when its native view was last used by a `TextInput` with a larger font that was given a value, and it lasts for the life of the view. When text is set from JS, `_setAttributedString:` restores the caret position, even when the field is not focused. `UITextField` answers every selection change by scrolling its field editor to the caret. If the text is taller than the field, the editor scrolls even though nobody is editing it. In the issue's reproducer the 32 pt value leaves a vertical offset of 4.67 pt. The editor keeps that offset after the text fits again, and keeps it through `prepareForRecycle`. The next field that gets the recycled view edits through that editor, so its text is drawn too high. This change restores the caret only when a single-line input is first responder. A `UITextField` that is not being edited has no caret to keep, because UIKit places the caret when editing begins. I checked this in RNTester: in a field that was blurred with the caret at index 2 and whose value then changed from JS, `focus()` puts the caret at the end, with and without this change. Multiline inputs keep the current behavior, because `UITextView` has no separate field editor and #38679 relies on the restore and scroll there. The `selection` prop is still applied as before. Both ways the bug shows up go through `_setAttributedString:`: - **Value from state on mount:** on `Insert`, `updateState:` runs before `updateLayoutMetrics:`, so a recycled view still has the previous owner's frame when it gets the new value. - **Controlled value from JS:** `setTextAndSelection` sets the value while the field isn't focused. JS passes `-1` when there's no `selection` prop, so only `_setAttributedString:` changes the selection. This happens even with the correct frame when the text is taller than the field. ## Changelog: [IOS] [FIXED] - Fix caret and text of a single-line `TextInput` drawn above center while focused, after its view was reused from a `TextInput` with a larger font Pull Request resolved: #59190 Test Plan: I reproduced the issue in RNTester with a port of the reproducer from the issue (Debug, new architecture, iPhone 17 Pro simulator, iOS 26.5). While testing, I added a temporary log in `textInputDidBeginEditing` that prints the field editor's frame, `contentSize` and `contentOffset`. The log is not part of this change. <details> <summary>RNTesterPlayground.js</summary> ```js function SingleLineField({autoFocus}) { return ( <View style={styles.field}> <TextInput style={styles.singleLine} autoFocus={autoFocus} placeholder="Type here" /> <View pointerEvents="none" style={styles.centerLine} /> </View> ); } function ControlledPrice() { const [value, setValue] = useState(''); return ( <View> <TextInput value={value} style={styles.price} /> <Button title="Set 32 pt value" onPress={() => setValue('2 000 Kč')} /> </View> ); } function Playground() { const [step, setStep] = useState('none'); return ( <View style={styles.container}> <Text>Reference, mounted once:</Text> <SingleLineField /> <Button title="1. 17 pt" onPress={() => setStep('field')} /> <Button title="2. 32 pt value" onPress={() => setStep('price')} /> <Button title="2b. 32 pt controlled" onPress={() => setStep('priceControlled')} /> <Button title="3. 17 pt reused" onPress={() => setStep('reused')} /> {step === 'field' && <SingleLineField key="field" />} {step === 'price' && <TextInput key="price" value="2 000 Kč" style={styles.price} />} {step === 'priceControlled' && <ControlledPrice key="priceControlled" />} {step === 'reused' && <SingleLineField key="reused" autoFocus />} </View> ); } const styles = StyleSheet.create({ container: {padding: 10, gap: 12}, field: {height: 44, borderWidth: 1, borderColor: '#ccc', borderRadius: 8}, singleLine: {height: 42, paddingHorizontal: 16, fontSize: 17}, centerLine: {position: 'absolute', left: 0, right: 0, top: 20.5, height: 1, backgroundColor: 'red'}, price: {height: 40, fontSize: 32, fontWeight: '800', textAlign: 'center'}, }); ``` </details> Steps: tap 1, then 2 (or 2b and "Set 32 pt value"), then 3, and type. The field from step 3 reuses the view of the 32 pt field. I relaunched the app before each run so the recycle pool was empty. | Run | Before | After | | --- | --- | --- | | 1 → 2 → 3 (value from state) | `contentOffset {0, 4.67}`, `contentSize {348, 42}` | `contentOffset {0, 0}` | | 1 → 2b → set value → 3 (controlled value) | `contentOffset {0, 5.67}`, `contentSize {348, 42}` | `contentOffset {0, 0}` | <img width="2452" height="795" alt="before-after" src="https://github.com/user-attachments/assets/f3307829-ed8f-4ace-bfbd-0be336b047b2" /> Before: the typed text sits above the red center line. After: it is centered, like the placeholder of the reference field. I also checked that editing still works with this change: - **Focused field, value changed from JS:** a controlled field that uppercases its text in `onChangeText`, with the caret at index 2. Typing "z" and "y" gave `ABZYCDEF` with selection 4-4, so the caret is still restored while editing. - **Unfocused field, value changed from JS:** caret at index 2, blur, prepend "X" from JS, then `focus()`. The caret is at the end of the text, with and without this change. Commands: ```sh yarn install yarn --cwd packages/react-native-codegen build cd packages/rn-tester pod install pod update hermes-engine --no-repo-update # Podfile.lock pinned an older Hermes cd ../.. xcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -configuration Debug \ -destination 'platform=iOS Simulator,id=<iPhone 17 Pro, iOS 26.5 simulator UDID>' build # ** BUILD SUCCEEDED ** node scripts/clang-format.js --check \ packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm # no output, exit code 0 ``` I didn't add a unit test. `React/Tests` isn't compiled by any open source target, and the field editor is private to UIKit and attached only while the field is editing. A test would need a window and a first responder to see the offset. Not covered: a selection that is set explicitly on a field that isn't focused (a non-empty `selection` prop, `clear()`, or `setSelection()`) still goes through `setSelectedTextRange:`. On a large-font field it could still leave an offset behind. Those calls ask for a specific selection, so skipping them would change what they do, and I left them out of this PR. Reviewed By: Abbondanzo Differential Revision: D126072650 Pulled By: cipolleschi fbshipit-source-id: 68c540fd4a9ed327e0e38fba34d88db4fc0ca7ad
1 parent fa2baa2 commit e10d6ba

1 file changed

Lines changed: 19 additions & 13 deletions

File tree

‎packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -796,19 +796,25 @@ - (void)_setAttributedString:(NSAttributedString *)attributedString
796796
UITextRange *selectedRange = _backedTextInputView.selectedTextRange;
797797
NSInteger oldTextLength = _backedTextInputView.attributedText.string.length;
798798
_backedTextInputView.attributedText = attributedString;
799-
// Updating the UITextView attributedText, for example changing the lineHeight, the color or adding
800-
// a new paragraph with \n, causes the cursor to move to the end of the Text and scroll.
801-
// This is fixed by restoring the cursor position and scrolling to that position (iOS issue 652653).
802-
// Maintaining a cursor position relative to the end of the old text.
803-
NSInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument
804-
toPosition:selectedRange.start];
805-
NSInteger offsetFromEnd = oldTextLength - offsetStart;
806-
NSInteger newOffset = attributedString.string.length - offsetFromEnd;
807-
UITextPosition *position = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument
808-
offset:newOffset];
809-
[_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:position toPosition:position]
810-
notifyDelegate:YES];
811-
[_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetStart, 0)];
799+
// A single-line input that is not being edited has no cursor to keep: UIKit places one when editing begins.
800+
// Restoring it anyway makes UIKit scroll the field editor to it when the text doesn't fit, and the editor keeps
801+
// that offset after the text fits again, so text typed later in this view (also after recycling) is drawn too high.
802+
BOOL isMultiline = [_backedTextInputView isKindOfClass:[UITextView class]];
803+
if (isMultiline || _backedTextInputView.isFirstResponder) {
804+
// Updating the UITextView attributedText, for example changing the lineHeight, the color or adding
805+
// a new paragraph with \n, causes the cursor to move to the end of the Text and scroll.
806+
// This is fixed by restoring the cursor position and scrolling to that position (iOS issue 652653).
807+
// Maintaining a cursor position relative to the end of the old text.
808+
NSInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument
809+
toPosition:selectedRange.start];
810+
NSInteger offsetFromEnd = oldTextLength - offsetStart;
811+
NSInteger newOffset = attributedString.string.length - offsetFromEnd;
812+
UITextPosition *position = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument
813+
offset:newOffset];
814+
[_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:position toPosition:position]
815+
notifyDelegate:YES];
816+
[_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetStart, 0)];
817+
}
812818

813819
// A zero-length selection range can cause the caret position to change on iOS,
814820
// and we have already updated the caret position, so we can safely ignore caret changing in this place.

0 commit comments

Comments
 (0)