Skip to content

Commit e4b8473

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 (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.
1 parent 4464ab0 commit e4b8473

3 files changed

Lines changed: 105 additions & 3 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: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -712,10 +712,10 @@ - (void)handleInputAccessoryDoneButton
712712
return {
713713
.text = RCTStringFromNSString(_backedTextInputView.attributedText.string),
714714
.selectionRange = [self _selectionRange],
715-
.eventCount = static_cast<int>(_mostRecentEventCount),
715+
.contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize),
716716
.contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset),
717717
.contentInset = RCTEdgeInsetsFromUIEdgeInsets(_backedTextInputView.contentInset),
718-
.contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize),
718+
.eventCount = static_cast<int>(_mostRecentEventCount),
719719
.layoutMeasurement = RCTSizeFromCGSize(_backedTextInputView.bounds.size),
720720
.zoomScale = _backedTextInputView.zoomScale,
721721
};
@@ -729,12 +729,47 @@ - (void)_updateState
729729
NSAttributedString *attributedString = _backedTextInputView.attributedText;
730730
auto data = _state->getData();
731731
_lastStringStateWasUpdatedWith = attributedString;
732-
data.attributedStringBox = RCTAttributedStringBoxFromNSAttributedString(attributedString);
732+
data.attributedStringBox = RCTAttributedStringBoxFromNSAttributedString([self _stateAttributedString:attributedString]);
733733
_mostRecentEventCount += _comingFromJS ? 0 : 1;
734734
data.mostRecentEventCount = _mostRecentEventCount;
735735
_state->updateState(std::move(data));
736736
}
737737

738+
// For single-line inputs, `_setAttributedString:` zeros the paragraph-style line
739+
// heights on the displayed text so UITextField centers the glyph correctly when
740+
// `lineHeight > font.lineHeight`. The shadow node measures from state, so undo
741+
// that strip here — restore stripped line heights from the default paragraph
742+
// style so layout sizing reflects the configured `lineHeight`, not the render-
743+
// time strip.
744+
- (NSAttributedString *)_stateAttributedString:(NSAttributedString *)attributedString
745+
{
746+
if ([_backedTextInputView isKindOfClass:[RCTUITextView class]] || attributedString.length == 0) {
747+
return attributedString;
748+
}
749+
NSDictionary<NSAttributedStringKey, id> *defaults = _backedTextInputView.defaultTextAttributes;
750+
NSParagraphStyle *defaultParagraphStyle = defaults[NSParagraphStyleAttributeName];
751+
UIFont *defaultFont = defaults[NSFontAttributeName];
752+
if (!defaultParagraphStyle || !defaultFont ||
753+
defaultParagraphStyle.maximumLineHeight <= defaultFont.lineHeight) {
754+
return attributedString;
755+
}
756+
NSMutableAttributedString *mutableString = [attributedString mutableCopy];
757+
[mutableString enumerateAttribute:NSParagraphStyleAttributeName
758+
inRange:NSMakeRange(0, mutableString.length)
759+
options:0
760+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
761+
if (style && style.maximumLineHeight == 0 && style.minimumLineHeight == 0) {
762+
NSMutableParagraphStyle *restored = [style mutableCopy];
763+
restored.minimumLineHeight = defaultParagraphStyle.minimumLineHeight;
764+
restored.maximumLineHeight = defaultParagraphStyle.maximumLineHeight;
765+
[mutableString addAttribute:NSParagraphStyleAttributeName
766+
value:restored
767+
range:range];
768+
}
769+
}];
770+
return mutableString;
771+
}
772+
738773
- (AttributedString::Range)_selectionRange
739774
{
740775
UITextRange *selectedTextRange = _backedTextInputView.selectedTextRange;
@@ -768,6 +803,56 @@ - (void)_restoreTextSelectionAndIgnoreCaretChange:(BOOL)ignore
768803

769804
- (void)_setAttributedString:(NSAttributedString *)attributedString
770805
{
806+
// When `lineHeight > font.lineHeight`, UIKit's draw paths anchor glyphs to the bottom of the
807+
// paragraph line box. UITextView honors NSBaselineOffsetAttributeName to re-center; UITextField
808+
// does not, so for single-line we instead zero the paragraph-style line height (UITextField
809+
// then renders at the font's natural line height and its built-in vertical centering positions
810+
// the glyph in the bounds; the caret rect, sized from the same line box, shrinks to match).
811+
NSDictionary<NSAttributedStringKey, id> *defaults = _backedTextInputView.defaultTextAttributes;
812+
NSParagraphStyle *defaultParagraphStyle = defaults[NSParagraphStyleAttributeName];
813+
UIFont *defaultFont = defaults[NSFontAttributeName];
814+
if (attributedString.length > 0 && defaultParagraphStyle && defaultFont &&
815+
defaultParagraphStyle.maximumLineHeight > defaultFont.lineHeight) {
816+
NSMutableAttributedString *mutableString = [attributedString mutableCopy];
817+
NSRange fullRange = NSMakeRange(0, mutableString.length);
818+
if ([_backedTextInputView isKindOfClass:[RCTUITextView class]]) {
819+
// UIKit's typingAttributes drop NSParagraphStyle on the round-trip, so chars typed
820+
// since the last state push arrive without a paragraph style. Re-seed those ranges
821+
// (and ranges with a zero-line-height stub) from the default so UITextView resolves a
822+
// consistent line-box height across the whole string. RCTApplyBaselineOffset then
823+
// computes the centering offset using the max font.lineHeight present on each line —
824+
// correct for mixed-font input from nested <Text> children.
825+
[mutableString enumerateAttribute:NSParagraphStyleAttributeName
826+
inRange:fullRange
827+
options:0
828+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
829+
if (!style || style.maximumLineHeight == 0) {
830+
[mutableString addAttribute:NSParagraphStyleAttributeName
831+
value:defaultParagraphStyle
832+
range:range];
833+
}
834+
}];
835+
RCTApplyBaselineOffset(mutableString);
836+
} else {
837+
// Single-line: per-range zero out the paragraph-style line height while preserving any
838+
// other paragraph-style fields (alignment, indent) the user set on nested <Text>.
839+
[mutableString enumerateAttribute:NSParagraphStyleAttributeName
840+
inRange:fullRange
841+
options:0
842+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
843+
NSParagraphStyle *source = style ?: defaultParagraphStyle;
844+
if (source.maximumLineHeight == 0 && source.minimumLineHeight == 0) {
845+
return;
846+
}
847+
NSMutableParagraphStyle *stripped = [source mutableCopy];
848+
stripped.minimumLineHeight = 0;
849+
stripped.maximumLineHeight = 0;
850+
[mutableString addAttribute:NSParagraphStyleAttributeName value:stripped range:range];
851+
}];
852+
}
853+
attributedString = mutableString;
854+
}
855+
771856
if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) {
772857
return;
773858
}

0 commit comments

Comments
 (0)