Repository navigation
fix(ios): center TextInput text when lineHeight > fontSize on Fabric - #56487
janicduplessis wants to merge 2 commits into
Conversation
606f1d7 to
4e79973
Compare
4e79973 to
bf72714
Compare
|
Looking for it |
9bcb81f to
c73a582
Compare
acf8672 to
e4b8473
Compare
|
Warning JavaScript API change detected This PR commits an update to
This change was flagged as: |
99c3b8b to
156b724
Compare
…ht > fontSize On iOS, when a TextInput's lineHeight exceeds its font's line height, UIKit anchors glyphs to the bottom of the attributed-string line box instead of centering them within it. The same misalignment affects the placeholder. On single-line UITextField the caret is also sized to the full line box. This patch fixes all three surfaces. The approach varies by UIKit rendering path: UITextView (multi-line) typed text — honors NSBaselineOffsetAttributeName. Call RCTApplyBaselineOffset in RCTTextInputComponentView._setAttributedString: to inject the offset. Re-seed NSParagraphStyleAttributeName from defaultTextAttributes on ranges missing it (or carrying a zero-line-height stub), because UIKit's typingAttributes drops the paragraph style between keystrokes — without the re-seed the helper sees maximumLineHeight == 0 and bails for typed content. UITextField (single-line) typed text — does NOT honor NSBaselineOffsetAttributeName. Per-range zero out paragraphStyle.minimumLineHeight / maximumLineHeight on the displayed attributedText so UITextField uses the font's natural line height; its built-in vertical centering then positions the glyph in the bounds, and the caret rect (sized from the same line box) shrinks to match. Other paragraph-style fields (alignment, indent) are preserved so nested <Text> styling survives. The shadow node measures from state, so undo the strip in _updateState before pushing — restore stripped line heights from defaultTextAttributes so the cell height stays at the configured lineHeight across edits. Placeholder on both UITextField.attributedPlaceholder (UILabel draw) and RCTUITextView._placeholderView — both honor NSBaselineOffsetAttributeName. Add the offset computation to _placeholderTextAttributes on both backing views so the placeholder is centered to match (single string, single set of attributes — a direct computation is equivalent to RCTApplyBaselineOffset). Bug only manifests on Fabric. Paper has had a similar baseline-offset fix in RCTUITextView.attributedText since 0.66 (see RCTUITextView setAttributedText:), which never made it into the Fabric component view.
156b724 to
3fbf76f
Compare
|
Haven’t had any feedback on this, I would recommend to avoid line height as a workaround. |
|
We ended up updating our TextInput wrapper to float a placeholder on top of the input. function BaseTextInput() {
// ...
return (
<View style={wrapperStyle}>
{input}
{hasPlaceholder && (
<Animated.View
style={[StyleSheet.absoluteFill, placeholderAnimatedStyle]}
pointerEvents="none"
>
<Text style={placeholderStyle} numberOfLines={props.multiline ? undefined : 1}>
{placeholder}
</Text>
</Animated.View>
)}
</View>
);
} |
|
Tested this, and it fixes the issue in every case I tried. I didn't find any regressions. Setup: current main (1c6e0c7) with this PR rebased on top. The only conflicts were the two The test screen puts a
Before (left) and after (right): The caret with this PR: I also tried the simpler fix first: adding the baseline offset to the input's text attributes, the way We also backported this to our app on 0.88.0-rc.3 and built it from source. The chat input's text is centred without the manual offset we've been using, and typing an emoji doesn't change its height. The test screen, if anyone wants to repeat this: https://gist.github.com/emmanuelchucks/40bc34ce0538ee6410b0455e4b5d4ad4 |
…eact#56487) This is a cherry-pick of a PR to fix TextInputs on iOS so that the text remains vertically-centered when a lineHeight is set. See react#56487 for more details Co-authored-by: Cursor <cursoragent@cursor.com>
…eact#56487) This is a cherry-pick of a PR to fix TextInputs on iOS so that the text remains vertically-centered when a lineHeight is set. See react#56487 for more details Co-authored-by: Cursor <cursoragent@cursor.com>
Summary: Fixes #59132. On iOS, a single-line `TextInput` with a `placeholder` and no `lineHeight` can draw its placeholder below the field's vertical centre. It happens when the field's native view was last used by a `TextInput` with a `lineHeight` (Fabric recycles `RCTTextInputComponentView`s), and when a `lineHeight` is removed from a mounted single-line field. Typed text is not affected. UITextField's placeholder label keeps the paragraph style of the previous `attributedPlaceholder` and applies it to a new placeholder that has none. `RCTNSTextAttributesFromTextAttributes` only adds an `NSParagraphStyle` when an alignment, writing direction, line break mode or strategy, or line height is set, so once the `lineHeight` is gone the new placeholder has no paragraph style, and the label keeps measuring and drawing it with the old line height. Setting `attributedPlaceholder` to `nil` first doesn't reset it. This change gives the placeholder a paragraph style of its own when the text attributes have none: an `NSMutableParagraphStyle` with `lineBreakMode = NSLineBreakByTruncatingTail`, which is what a fresh placeholder label uses. A paragraph style from the text attributes is used as before. In a right-to-left layout `RCTNSTextAttributesFromTextAttributes` always adds a paragraph style, so the new one only applies to left-to-right layouts without `textAlign`. In a plain UIKit test on the simulator, compared with a fresh `UITextField`, the placeholder renders to the same pixels (English and Hebrew text, with and without `semanticContentAttribute` forced to right-to-left), and `sizeWithAttributes:`, which `intrinsicContentSize` uses, returns the same size. The multiline `RCTUITextView` is unchanged: its placeholder is a plain `UILabel`, which doesn't keep the previous paragraph style. Related: #56487 changes `_placeholderTextAttributes` for fields whose `lineHeight` is larger than the font; this change is about fields without a `lineHeight`. #59184 (fixed in #59190) is another piece of state that outlives the recycling of `RCTTextInputComponentView`. #57748 and #58355 fixed other properties lost on the same single-line/multiline view swap. ## Changelog: [IOS] [FIXED] - Fix `TextInput` placeholder drawn below centre after its view is reused from a `TextInput` with `lineHeight` Pull Request resolved: #59195 Test Plan: Added `RCTUITextFieldTests` with two cases: - the placeholder loses a line height that was removed from the text attributes - a paragraph style from the text attributes is kept for the placeholder `React/Tests` isn't compiled by any open source target, so I added the file to the `RNTesterUnitTests` target locally (not part of this change) and ran it on an iPhone 17 Pro simulator, iOS 26.5: - without the fix: `testPlaceholderDropsLineHeightRemovedFromTextAttributes` fails (`((paragraphStyle) != nil) failed`), the other test passes - with the fix: `** TEST SUCCEEDED **`, both tests pass I tested manually in RNTester (Debug, new architecture, iPhone 17 Pro simulator, iOS 26.5) with this Playground code: <details> <summary>RNTesterPlayground.js</summary> ```js function Field({label, children}) { return ( <View style={styles.field}> <RNTesterText style={styles.label}>{label}</RNTesterText> <View style={styles.box}> {children} <View pointerEvents="none" style={styles.centreLine} /> </View> </View> ); } function Playground() { const [stage, setStage] = useState(0); const [lineHeight, setLineHeight] = useState(true); return ( <View style={styles.container}> <Field label="Reference (fresh view)"> <TextInput placeholder="Search…" style={styles.singleLine} /> </Field> <Button title="1. Mount multiline" onPress={() => setStage(1)} /> <Button title="2. Unmount it" onPress={() => setStage(2)} /> <Button title="3. Mount single-line" onPress={() => setStage(3)} /> {stage === 1 ? ( <TextInput key="multiline" multiline style={{fontSize: 17, lineHeight: 34, borderWidth: 1}} /> ) : null} {stage === 3 ? ( <Field label="Single-line (reused view)"> <TextInput key="singleline" placeholder="Search…" style={styles.singleLine} /> </Field> ) : null} <Button title={lineHeight ? 'Remove lineHeight' : 'Add lineHeight'} onPress={() => setLineHeight(value => !value)} /> <Field label={`Single-line, lineHeight ${lineHeight ? '34' : 'unset'}`}> <TextInput placeholder="Search…" style={[styles.singleLine, lineHeight ? {lineHeight: 34} : null]} /> </Field> </View> ); } const styles = StyleSheet.create({ box: {borderColor: '#888', borderWidth: StyleSheet.hairlineWidth}, centreLine: { backgroundColor: 'red', height: 1, left: 0, position: 'absolute', right: 0, top: 20.5, }, container: {gap: 8, padding: 10}, field: {gap: 4}, label: {fontSize: 12}, singleLine: {fontSize: 17, height: 42, paddingHorizontal: 12}, }); ``` </details> Steps: tap **1**, **2** and **3**. The single-line field mounted in step 3 reuses the view of the multiline field from step 1. Then tap **Remove lineHeight**. | Before | After | | --- | --- | | <img width="300" alt="59132-before" src="https://github.com/user-attachments/assets/34076c45-1736-4af2-98d4-396b57f82400" /> | <img width="300" alt="59132-after" src="https://github.com/user-attachments/assets/1c995d1c-9bfb-4efd-bbb1-c87a04e47d19" /> | Before: the placeholder of the reused field and the placeholder of the field whose `lineHeight` was removed sit below the red centre line. After: both are centred like the reference field. Tapping **Add lineHeight** again gives the field its `lineHeight` placeholder back, the same as 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 ** xcodebuild test -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -configuration Debug \ -destination 'platform=iOS Simulator,id=<iPhone 17 Pro, iOS 26.5 simulator UDID>' \ -only-testing:RNTesterUnitTests/RCTUITextFieldTests # with RCTUITextFieldTests.mm added to the target locally # ** TEST SUCCEEDED ** node scripts/clang-format.js --check \ packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm \ packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm # no output, exit code 0 ``` Reviewed By: christophpurrer Differential Revision: D126309241 Pulled By: cipolleschi fbshipit-source-id: 04ffa2930e3884f7a60d2dd9407d779ffc5f7775




Summary
On iOS, when a
TextInputhaslineHeight > fontSize, UIKit anchors typed text and the placeholder to the bottom of the paragraph line box, and sizes the single-line caret to the full line-box height instead of the font height.fontSize: 16, lineHeight: 80. iOS 26 simulator, Fabric.Why
The fix splits two ways because UIKit's draw paths don't all honor the same attributes:
NSBaselineOffsetAttributeName?UITextView)UILabel)UITextField/UIFieldEditor)RCTApplyBaselineOffsethelper that<Text>already uses, so per-fragment fonts (nested<Text>with differentfontSize) are handled correctly.paragraphStyle.minimumLineHeight/maximumLineHeightper range on the attributed string handed to UIKit. UITextField then renders at the font's natural line height and its built-in vertical centering positions the glyph in the bounds; the caret rect — derived from the same line box — shrinks to match. OtherparagraphStylefields (alignment, indent) are preserved per range. The shadow node measures fromTextInputState, so_updateStatere-inflates stripped line heights fromdefaultTextAttributesbefore pushing — without that step the cell would shrink to the font's natural height after the first keystroke.The typed-text fix lives in
RCTTextInputComponentView._setAttributedString:(single Fabric entry point, preserves the_textOf:equals:early-return). The placeholder fix lives in_placeholderTextAttributesof each backing view.Performance
When
lineHeight ≤ font.lineHeight(common case): zero allocations — guarded out before any mutation.Changelog:
[IOS] [FIXED] - Center typed TextInput text, placeholder, and single-line caret when
lineHeight > fontSize.Test Plan
RN Tester → TextInput → lineHeight baseline. Verified on iOS 26 simulator (iPhone 16 Pro / iPhone 17 Pro Max) on Fabric, including nested
<Text>mixed-font cases. Confirmed cell height stays stable across edits.Related: #38359, #39145, #53092.