From 4464ab09a5ae9731e753aea0dda1c21edc470f2d Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sat, 18 Apr 2026 14:12:24 -0400 Subject: [PATCH 1/2] Add lineHeight baseline example to RN Tester --- .../TextInput/TextInputSharedExamples.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js index 3aa30acc36ac..5c0b35d2e823 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js @@ -1188,6 +1188,33 @@ module.exports = [ name: 'textStyles', render: () => , }, + { + title: 'lineHeight baseline', + name: 'lineHeightBaseline', + render: function (): React.Node { + const inputStyle = { + fontSize: 16, + lineHeight: 32, + borderColor: '#ccc', + borderWidth: 1, + padding: 8, + }; + return ( + + + + + + + + + ); + }, + }, { title: 'showSoftInputOnFocus', render: function (): React.Node { From bf72714e97b476015833ae1370655473fc0c3364 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sat, 18 Apr 2026 14:12:45 -0400 Subject: [PATCH 2/2] fix(ios): center TextInput text, placeholder, and caret when lineHeight > fontSize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Text/TextInput/Multiline/RCTUITextView.mm | 6 +++ .../TextInput/Singleline/RCTUITextField.mm | 45 +++++++++++++++++++ .../TextInput/RCTTextInputComponentView.mm | 24 ++++++++++ 3 files changed, 75 insertions(+) diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index cbda3771e97c..58ef9dd2d78f 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -364,6 +364,12 @@ - (void)_updatePlaceholder [textAttributes setValue:defaultPlaceholderFont() forKey:NSFontAttributeName]; } + NSParagraphStyle *paragraphStyle = textAttributes[NSParagraphStyleAttributeName]; + UIFont *font = textAttributes[NSFontAttributeName]; + if (paragraphStyle && font && paragraphStyle.maximumLineHeight > font.lineHeight) { + textAttributes[NSBaselineOffsetAttributeName] = @((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0); + } + return textAttributes; } diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 052c003476d1..9c4d7505afd6 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -97,6 +97,45 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa [self _updatePlaceholder]; } +// UITextField / UIFieldEditor does not honor NSBaselineOffsetAttributeName when drawing typed +// text, and it draws at the baseline of a paragraphStyle-sized line box — so when +// `lineHeight > font.lineHeight` the glyphs sit at the bottom of that line box. Strip the +// paragraphStyle line-height for the super-class rendering path so UITextField uses its +// default intrinsic line height and its built-in vertical centering positions the glyph in the +// bounds. `_defaultTextAttributes` (local) keeps the unmodified paragraphStyle so the +// placeholder path (`_placeholderTextAttributes`) still sees the real lineHeight. +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + if (attributedText.length == 0) { + [super setAttributedText:attributedText]; + return; + } + NSMutableAttributedString *mutableStr = [attributedText mutableCopy]; + [mutableStr enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, mutableStr.length) + options:0 + usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) { + if (!style || style.maximumLineHeight == 0) { + return; + } + UIFont *font = [mutableStr attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:NULL]; + if (!font || style.maximumLineHeight <= font.lineHeight) { + return; + } + NSMutableParagraphStyle *stripped = [style mutableCopy]; + stripped.minimumLineHeight = 0; + stripped.maximumLineHeight = 0; + [mutableStr addAttribute:NSParagraphStyleAttributeName value:stripped range:range]; + // Drop any NSBaselineOffsetAttributeName applied by the Fabric baseline-offset + // helper in the same range: UITextField does not honor it for typed text + // rendering, but a non-zero value still inflates the caret rect. + [mutableStr removeAttribute:NSBaselineOffsetAttributeName range:range]; + }]; + [super setAttributedText:mutableStr]; +} + - (NSDictionary *)defaultTextAttributes { return _defaultTextAttributes; @@ -169,6 +208,12 @@ - (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts [textAttributes removeObjectForKey:NSForegroundColorAttributeName]; } + NSParagraphStyle *paragraphStyle = textAttributes[NSParagraphStyleAttributeName]; + UIFont *font = textAttributes[NSFontAttributeName]; + if (paragraphStyle && font && paragraphStyle.maximumLineHeight > font.lineHeight) { + textAttributes[NSBaselineOffsetAttributeName] = @((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0); + } + return textAttributes; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index cbbc402de42f..2ffb7e02a230 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -768,6 +768,30 @@ - (void)_restoreTextSelectionAndIgnoreCaretChange:(BOOL)ignore - (void)_setAttributedString:(NSAttributedString *)attributedString { + // When the user types, UIKit's typingAttributes drop NSParagraphStyleAttributeName, so the + // attributedText round-tripping back through state lacks the paragraph style that + // RCTApplyBaselineOffset needs. Re-seed paragraph style from defaultTextAttributes on ranges + // that are missing it or carry a zero-lineHeight stub, so the helper can compute the offset. + NSMutableAttributedString *mutableString = [attributedString mutableCopy]; + NSParagraphStyle *defaultParagraphStyle = + _backedTextInputView.defaultTextAttributes[NSParagraphStyleAttributeName]; + if (defaultParagraphStyle && mutableString.length > 0) { + [mutableString + enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, mutableString.length) + options:0 + usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) { + if (!style || style.maximumLineHeight == 0) { + [mutableString addAttribute:NSParagraphStyleAttributeName + value:defaultParagraphStyle + range:range]; + } + }]; + } + + RCTApplyBaselineOffset(mutableString); + attributedString = mutableString; + if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) { return; }