Commit acf8672
committed
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
- Singleline
- React/Fabric/Mounting/ComponentViews/TextInput
Lines changed: 9 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
364 | 364 | | |
365 | 365 | | |
366 | 366 | | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
367 | 376 | | |
368 | 377 | | |
369 | 378 | | |
| |||
Lines changed: 8 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
172 | 180 | | |
173 | 181 | | |
174 | 182 | | |
| |||
Lines changed: 50 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
768 | 768 | | |
769 | 769 | | |
770 | 770 | | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
771 | 821 | | |
772 | 822 | | |
773 | 823 | | |
| |||
0 commit comments