Skip to content

Commit bf72714

Browse files
fix(ios): center TextInput text, placeholder, and caret when lineHeight > 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, because UIKit's typingAttributes drops the paragraph style between keystrokes and _updateState round-trips the stripped attributedText through TextInputState — without the re-seed the helper sees maximumLineHeight == 0 and bails for typed content. Placeholder on both UITextField.attributedPlaceholder (UILabel draw) and RCTUITextView._placeholderView — both honor NSBaselineOffsetAttributeName. Add the offset computation to _placeholderTextAttributes on both backing views. The fix applies to both Paper and Fabric because the backing views are shared. UITextField (single-line) typed text — the UIFieldEditor draw path does NOT honor NSBaselineOffsetAttributeName, and it sizes the caret to the paragraph-style line box height. Override setAttributedText: to forward a copy with paragraphStyle's minimumLineHeight/maximumLineHeight zeroed out (and NSBaselineOffsetAttributeName removed) to super. UITextField then renders the line at the font's natural height and its built-in vertical centering positions it correctly in the bounds; the caret rect likewise shrinks to the natural font height. _defaultTextAttributes (the local ivar) keeps the unmodified paragraph style so the placeholder path still sees the real lineHeight. Yoga's frame-height measurement is unaffected.
1 parent 4464ab0 commit bf72714

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

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

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

367+
NSParagraphStyle *paragraphStyle = textAttributes[NSParagraphStyleAttributeName];
368+
UIFont *font = textAttributes[NSFontAttributeName];
369+
if (paragraphStyle && font && paragraphStyle.maximumLineHeight > font.lineHeight) {
370+
textAttributes[NSBaselineOffsetAttributeName] = @((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0);
371+
}
372+
367373
return textAttributes;
368374
}
369375

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,45 @@ - (void)setDefaultTextAttributes:(NSDictionary<NSAttributedStringKey, id> *)defa
9797
[self _updatePlaceholder];
9898
}
9999

100+
// UITextField / UIFieldEditor does not honor NSBaselineOffsetAttributeName when drawing typed
101+
// text, and it draws at the baseline of a paragraphStyle-sized line box — so when
102+
// `lineHeight > font.lineHeight` the glyphs sit at the bottom of that line box. Strip the
103+
// paragraphStyle line-height for the super-class rendering path so UITextField uses its
104+
// default intrinsic line height and its built-in vertical centering positions the glyph in the
105+
// bounds. `_defaultTextAttributes` (local) keeps the unmodified paragraphStyle so the
106+
// placeholder path (`_placeholderTextAttributes`) still sees the real lineHeight.
107+
- (void)setAttributedText:(NSAttributedString *)attributedText
108+
{
109+
if (attributedText.length == 0) {
110+
[super setAttributedText:attributedText];
111+
return;
112+
}
113+
NSMutableAttributedString *mutableStr = [attributedText mutableCopy];
114+
[mutableStr enumerateAttribute:NSParagraphStyleAttributeName
115+
inRange:NSMakeRange(0, mutableStr.length)
116+
options:0
117+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
118+
if (!style || style.maximumLineHeight == 0) {
119+
return;
120+
}
121+
UIFont *font = [mutableStr attribute:NSFontAttributeName
122+
atIndex:range.location
123+
effectiveRange:NULL];
124+
if (!font || style.maximumLineHeight <= font.lineHeight) {
125+
return;
126+
}
127+
NSMutableParagraphStyle *stripped = [style mutableCopy];
128+
stripped.minimumLineHeight = 0;
129+
stripped.maximumLineHeight = 0;
130+
[mutableStr addAttribute:NSParagraphStyleAttributeName value:stripped range:range];
131+
// Drop any NSBaselineOffsetAttributeName applied by the Fabric baseline-offset
132+
// helper in the same range: UITextField does not honor it for typed text
133+
// rendering, but a non-zero value still inflates the caret rect.
134+
[mutableStr removeAttribute:NSBaselineOffsetAttributeName range:range];
135+
}];
136+
[super setAttributedText:mutableStr];
137+
}
138+
100139
- (NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
101140
{
102141
return _defaultTextAttributes;
@@ -169,6 +208,12 @@ - (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts
169208
[textAttributes removeObjectForKey:NSForegroundColorAttributeName];
170209
}
171210

211+
NSParagraphStyle *paragraphStyle = textAttributes[NSParagraphStyleAttributeName];
212+
UIFont *font = textAttributes[NSFontAttributeName];
213+
if (paragraphStyle && font && paragraphStyle.maximumLineHeight > font.lineHeight) {
214+
textAttributes[NSBaselineOffsetAttributeName] = @((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0);
215+
}
216+
172217
return textAttributes;
173218
}
174219

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

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

769769
- (void)_setAttributedString:(NSAttributedString *)attributedString
770770
{
771+
// When the user types, UIKit's typingAttributes drop NSParagraphStyleAttributeName, so the
772+
// attributedText round-tripping back through state lacks the paragraph style that
773+
// RCTApplyBaselineOffset needs. Re-seed paragraph style from defaultTextAttributes on ranges
774+
// that are missing it or carry a zero-lineHeight stub, so the helper can compute the offset.
775+
NSMutableAttributedString *mutableString = [attributedString mutableCopy];
776+
NSParagraphStyle *defaultParagraphStyle =
777+
_backedTextInputView.defaultTextAttributes[NSParagraphStyleAttributeName];
778+
if (defaultParagraphStyle && mutableString.length > 0) {
779+
[mutableString
780+
enumerateAttribute:NSParagraphStyleAttributeName
781+
inRange:NSMakeRange(0, mutableString.length)
782+
options:0
783+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
784+
if (!style || style.maximumLineHeight == 0) {
785+
[mutableString addAttribute:NSParagraphStyleAttributeName
786+
value:defaultParagraphStyle
787+
range:range];
788+
}
789+
}];
790+
}
791+
792+
RCTApplyBaselineOffset(mutableString);
793+
attributedString = mutableString;
794+
771795
if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) {
772796
return;
773797
}

0 commit comments

Comments
 (0)