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; } 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 {