Skip to content

Commit acf8672

Browse files
fix(ios): center TextInput text, placeholder, and caret when lineHeight > fontSize
On iOS, when a `TextInput` has `lineHeight > fontSize` (a common pattern for tap-target sizing), UIKit misrenders three surfaces: 1. Typed text — glyphs anchor to the bottom of the attributed-string line box instead of centering within it. 2. Placeholder — inherits the paragraph style from `defaultTextAttributes` and sits low. 3. Single-line caret — sized to the full paragraph line-box height rather than the font height, visibly taller than the glyph. ## Why UIKit's text layout draws each glyph at the baseline of a line box whose height comes from the paragraph style's `maximumLineHeight`. With `font.lineHeight = 16` and `maximumLineHeight = 32`, the glyph sits at `y = 32 - descent`, anchored to the bottom of the box rather than centered. UIKit text rendering exposes `NSBaselineOffsetAttributeName` to nudge glyphs vertically within their line box — `<Text>` already uses it via `RCTApplyBaselineOffset` — but `<TextInput>` was never wired into that path. The fix isn't uniform across the three surfaces because UIKit uses three different draw paths and they don't all honor the same attributes: | Surface | Backing view / draw path | Honors `NSBaselineOffsetAttributeName`? | |---|---|---| | Multi-line typed text | `UITextView` (TextKit) | yes | | Placeholder (single + multi) | `UILabel` (`attributedPlaceholder` / `_placeholderView`) | yes | | Single-line typed text | `UITextField` → `UIFieldEditor` | **no** | | Single-line caret | `UITextField` (caret rect derived from paragraph line-box) | **no** | The three surfaces split into two strategies: - **Surfaces that honor `NSBaselineOffsetAttributeName`** (multi-line typed text, both placeholders): apply a baseline offset to re-center the glyph in the line box. For multi-line typed text we delegate to the existing `RCTApplyBaselineOffset` helper used by `<Text>` rendering, so per-fragment fonts are handled correctly: the helper computes `(maxLineHeight − maxFontLineHeight) / 2` from the actual fonts present in the string, and respects the `enableIOSTextBaselineOffsetPerLine` feature flag for per-line offsets when nested `<Text>` children produce different fonts on different lines. For the placeholder, which is always built from a single set of attributes, an inline computation is equivalent. - **Surfaces that don't** (single-line typed text + caret): zero `paragraphStyle.minimumLineHeight` / `maximumLineHeight` per range on the attributed string handed to UIKit. UITextField falls back to the font's natural line height; its built-in vertical centering positions the glyph in the bounds, and the caret rect — derived from the same paragraph-style line box — shrinks to match. Other paragraph-style fields (alignment, indent, etc.) on each range are preserved so nested `<Text>` styling still applies. `defaultTextAttributes` and `_defaultTextAttributes` keep the unmodified `paragraphStyle` so the placeholder path still sees the real `lineHeight`. Yoga's frame-height measurement is also unaffected — the modification only touches what UITextField uses for per-line rendering. ## Why the fix lives where it does - **Typed text fix in `RCTTextInputComponentView._setAttributedString:`** (Fabric, single entry point): - Each keystroke round-trips through state → `_setAttributedString:`. UIKit's `typingAttributes` drop `NSParagraphStyleAttributeName` on the round-trip, so chars typed since the last state push arrive with no paragraph style at all. Without re-seeding, lines composed of stripped chars collapse to the font's natural line height (UITextView resolves `paragraphStyle` per-character with no fallback dictionary), so multi-line inputs would show inconsistent line heights as soon as you type past the initial value. Re-applying the default `paragraphStyle` to ranges where it is missing or has a zero-line-height stub — and only those ranges, so user paragraph styles on nested `<Text>` are preserved — restores consistent line composition. - The early-return guard (`_textOf:equals:`) is preserved by mutating `attributedString` _before_ the comparison: both sides of `RCTIsAttributedStringEffectivelySame` carry the same baseline offset, so identity state pushes still short-circuit. - Doing this in the backing view's `setAttributedText:` instead would defeat that early return, since the backing view's stored attributedText (modified) would never compare equal to the incoming raw value (unmodified). - **Placeholder fix in `_placeholderTextAttributes`** of each backing view: the placeholder is built from this dictionary and then handed to UIKit via `attributedPlaceholder` (UITextField) or `_placeholderView.attributedText` (UITextView). One spot per backing view, no propagation needed. ## Performance Per-keystroke cost when `lineHeight ≤ font.lineHeight` (the common case): **zero allocations** — the guard `paragraphStyle.maximumLineHeight > font.lineHeight` short-circuits before any mutation. When `lineHeight > font.lineHeight`: - Multi-line: `mutableCopy` + per-range re-seed (only fills missing/zero paragraph styles, no-ops on ranges that already carry one) + `RCTApplyBaselineOffset` (which adds two enumeration passes over the string to compute `max(paragraphStyle.maximumLineHeight)` and `max(font.lineHeight)`, plus one `addAttribute:` for the offset). - Single-line: `mutableCopy` + per-range strip (preserves alignment, indent, etc., stripping only the line-height fields). ## Changelog [IOS] [FIXED] - Center typed TextInput text, placeholder, and single-line caret when `lineHeight > fontSize`. ## Test Plan RN Tester → **TextInput → lineHeight baseline** renders one single-line and one multi-line `TextInput` with `fontSize: 16, lineHeight: 32`. Before: placeholder sits low; typed glyphs anchor to the bottom of the line box; the single-line caret overshoots the glyph. After: placeholder and typed text render vertically centered; the single-line caret matches the glyph height and sits alongside it. Verified on iOS 26 simulator (iPhone 16 Pro / iPhone 17 Pro Max) on Fabric. The same fix also exercises correctly with nested `<Text>` children that have different `fontSize` values (mixed-font case): `RCTApplyBaselineOffset` reads the per-range fonts and applies a correct offset based on the actual maximum font line height, matching how `<Text>` renders the same content. Paper is unaffected (the typed-text fix is in `RCTTextInputComponentView`, which is Fabric-only; the backing-view `_placeholderTextAttributes` change is shared and benefits Paper as a no-cost side effect). Related: #38359, #39145, #53092.
1 parent c7969f8 commit acf8672

3 files changed

Lines changed: 67 additions & 0 deletions

File tree

packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,15 @@ - (void)_updatePlaceholder
364364
[textAttributes setValue:defaultPlaceholderFont() forKey:NSFontAttributeName];
365365
}
366366

367+
// The placeholder UILabel honors NSBaselineOffsetAttributeName. Center the placeholder
368+
// glyph in the line box when `lineHeight > font.lineHeight` (placeholder is a single
369+
// string with one set of attributes, so a direct computation is equivalent to RCTApplyBaselineOffset).
370+
NSParagraphStyle *paragraphStyle = textAttributes[NSParagraphStyleAttributeName];
371+
UIFont *font = textAttributes[NSFontAttributeName];
372+
if (paragraphStyle && font && paragraphStyle.maximumLineHeight > font.lineHeight) {
373+
textAttributes[NSBaselineOffsetAttributeName] = @((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0);
374+
}
375+
367376
return textAttributes;
368377
}
369378

packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ - (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts
169169
[textAttributes removeObjectForKey:NSForegroundColorAttributeName];
170170
}
171171

172+
// The UILabel-based draw path used by `attributedPlaceholder` honors
173+
// NSBaselineOffsetAttributeName (unlike the typed-text path).
174+
NSParagraphStyle *paragraphStyle = textAttributes[NSParagraphStyleAttributeName];
175+
UIFont *font = textAttributes[NSFontAttributeName];
176+
if (paragraphStyle && font && paragraphStyle.maximumLineHeight > font.lineHeight) {
177+
textAttributes[NSBaselineOffsetAttributeName] = @((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0);
178+
}
179+
172180
return textAttributes;
173181
}
174182

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,56 @@ - (void)_restoreTextSelectionAndIgnoreCaretChange:(BOOL)ignore
768768

769769
- (void)_setAttributedString:(NSAttributedString *)attributedString
770770
{
771+
// When `lineHeight > font.lineHeight`, UIKit's draw paths anchor glyphs to the bottom of the
772+
// paragraph line box. UITextView honors NSBaselineOffsetAttributeName to re-center; UITextField
773+
// does not, so for single-line we instead zero the paragraph-style line height (UITextField
774+
// then renders at the font's natural line height and its built-in vertical centering positions
775+
// the glyph in the bounds; the caret rect, sized from the same line box, shrinks to match).
776+
NSDictionary<NSAttributedStringKey, id> *defaults = _backedTextInputView.defaultTextAttributes;
777+
NSParagraphStyle *defaultParagraphStyle = defaults[NSParagraphStyleAttributeName];
778+
UIFont *defaultFont = defaults[NSFontAttributeName];
779+
if (attributedString.length > 0 && defaultParagraphStyle && defaultFont &&
780+
defaultParagraphStyle.maximumLineHeight > defaultFont.lineHeight) {
781+
NSMutableAttributedString *mutableString = [attributedString mutableCopy];
782+
NSRange fullRange = NSMakeRange(0, mutableString.length);
783+
if ([_backedTextInputView isKindOfClass:[RCTUITextView class]]) {
784+
// UIKit's typingAttributes drop NSParagraphStyle on the round-trip, so chars typed
785+
// since the last state push arrive without a paragraph style. Re-seed those ranges
786+
// (and ranges with a zero-line-height stub) from the default so UITextView resolves a
787+
// consistent line-box height across the whole string. RCTApplyBaselineOffset then
788+
// computes the centering offset using the max font.lineHeight present on each line —
789+
// correct for mixed-font input from nested <Text> children.
790+
[mutableString enumerateAttribute:NSParagraphStyleAttributeName
791+
inRange:fullRange
792+
options:0
793+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
794+
if (!style || style.maximumLineHeight == 0) {
795+
[mutableString addAttribute:NSParagraphStyleAttributeName
796+
value:defaultParagraphStyle
797+
range:range];
798+
}
799+
}];
800+
RCTApplyBaselineOffset(mutableString);
801+
} else {
802+
// Single-line: per-range zero out the paragraph-style line height while preserving any
803+
// other paragraph-style fields (alignment, indent) the user set on nested <Text>.
804+
[mutableString enumerateAttribute:NSParagraphStyleAttributeName
805+
inRange:fullRange
806+
options:0
807+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
808+
NSParagraphStyle *source = style ?: defaultParagraphStyle;
809+
if (source.maximumLineHeight == 0 && source.minimumLineHeight == 0) {
810+
return;
811+
}
812+
NSMutableParagraphStyle *stripped = [source mutableCopy];
813+
stripped.minimumLineHeight = 0;
814+
stripped.maximumLineHeight = 0;
815+
[mutableString addAttribute:NSParagraphStyleAttributeName value:stripped range:range];
816+
}];
817+
}
818+
attributedString = mutableString;
819+
}
820+
771821
if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) {
772822
return;
773823
}

0 commit comments

Comments
 (0)