Skip to content

Commit 3fbf76f

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 3fbf76f

5 files changed

Lines changed: 178 additions & 4 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#import <React/UIView+React.h>
1212

1313
#import <React/RCTBackedTextInputDelegateAdapter.h>
14+
#import <React/RCTBackedTextInputViewLineHeightUtils.h>
1415
#import <React/RCTTextAttributes.h>
1516

1617
@implementation RCTUITextView {
@@ -364,6 +365,7 @@ - (void)_updatePlaceholder
364365
[textAttributes setValue:defaultPlaceholderFont() forKey:NSFontAttributeName];
365366
}
366367

368+
RCTApplyPlaceholderBaselineOffset(textAttributes);
367369
return textAttributes;
368370
}
369371

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import <UIKit/UIKit.h>
9+
10+
NS_ASSUME_NONNULL_BEGIN
11+
12+
/*
13+
* When the configured `lineHeight` (encoded in `paragraphStyle.maximumLineHeight`)
14+
* exceeds the font's natural line height, UIKit's draw paths anchor glyphs to the
15+
* bottom of the paragraph line box. These helpers contain the small set of
16+
* transformations needed to re-center the glyph and the caret across the various
17+
* UIKit rendering paths used by `<TextInput>`.
18+
*/
19+
20+
/*
21+
* Returns a copy of `defaultTextAttributes` with `paragraphStyle.{min,max}LineHeight`
22+
* zeroed when greater than the font's line height. UITextField / UITextView feed
23+
* `defaultTextAttributes` into `typingAttributes`, so handing them a stripped copy
24+
* makes the typed-text path render at the font's natural metrics — vertical centering
25+
* then positions the glyph in the bounds and the caret rect (sized from the same line
26+
* box) shrinks to match. Returns the input dictionary unchanged if no strip is needed.
27+
*/
28+
NSDictionary<NSAttributedStringKey, id> *RCTStripDefaultTextAttributesLineHeight(
29+
NSDictionary<NSAttributedStringKey, id> *defaultTextAttributes);
30+
31+
/*
32+
* Per-range zero of `paragraphStyle.{min,max}LineHeight` on `attributedString`,
33+
* preserving any other paragraph-style fields (alignment, indent) the user may have
34+
* set on nested <Text>. No-ops on ranges that already have a zero line-height stub.
35+
*/
36+
void RCTStripAttributedStringLineHeights(NSMutableAttributedString *attributedString);
37+
38+
/*
39+
* Adds `NSBaselineOffsetAttributeName` to `placeholderAttributes` when
40+
* `paragraphStyle.maximumLineHeight > font.lineHeight`. The placeholder UILabel
41+
* draw path used by both UITextField.attributedPlaceholder and
42+
* RCTUITextView._placeholderView honors baseline offset, so a single uniform offset
43+
* is all that's needed to re-center the placeholder glyph in the line box.
44+
*/
45+
void RCTApplyPlaceholderBaselineOffset(NSMutableDictionary<NSAttributedStringKey, id> *placeholderAttributes);
46+
47+
NS_ASSUME_NONNULL_END
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import "RCTBackedTextInputViewLineHeightUtils.h"
9+
10+
static BOOL RCTNeedsLineHeightStrip(NSDictionary<NSAttributedStringKey, id> *attributes)
11+
{
12+
UIFont *font = attributes[NSFontAttributeName];
13+
NSParagraphStyle *paragraphStyle = attributes[NSParagraphStyleAttributeName];
14+
return font && paragraphStyle && paragraphStyle.maximumLineHeight > font.lineHeight;
15+
}
16+
17+
NSDictionary<NSAttributedStringKey, id> *RCTStripDefaultTextAttributesLineHeight(
18+
NSDictionary<NSAttributedStringKey, id> *defaultTextAttributes)
19+
{
20+
if (!RCTNeedsLineHeightStrip(defaultTextAttributes)) {
21+
return defaultTextAttributes;
22+
}
23+
NSMutableDictionary<NSAttributedStringKey, id> *stripped = [defaultTextAttributes mutableCopy];
24+
NSMutableParagraphStyle *strippedStyle = [defaultTextAttributes[NSParagraphStyleAttributeName] mutableCopy];
25+
strippedStyle.minimumLineHeight = 0;
26+
strippedStyle.maximumLineHeight = 0;
27+
stripped[NSParagraphStyleAttributeName] = strippedStyle;
28+
return stripped;
29+
}
30+
31+
void RCTStripAttributedStringLineHeights(NSMutableAttributedString *attributedString)
32+
{
33+
[attributedString
34+
enumerateAttribute:NSParagraphStyleAttributeName
35+
inRange:NSMakeRange(0, attributedString.length)
36+
options:0
37+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
38+
if (!style || (style.maximumLineHeight == 0 && style.minimumLineHeight == 0)) {
39+
return;
40+
}
41+
NSMutableParagraphStyle *stripped = [style mutableCopy];
42+
stripped.minimumLineHeight = 0;
43+
stripped.maximumLineHeight = 0;
44+
[attributedString addAttribute:NSParagraphStyleAttributeName value:stripped range:range];
45+
}];
46+
}
47+
48+
void RCTApplyPlaceholderBaselineOffset(NSMutableDictionary<NSAttributedStringKey, id> *placeholderAttributes)
49+
{
50+
if (!RCTNeedsLineHeightStrip(placeholderAttributes)) {
51+
return;
52+
}
53+
NSParagraphStyle *paragraphStyle = placeholderAttributes[NSParagraphStyleAttributeName];
54+
UIFont *font = placeholderAttributes[NSFontAttributeName];
55+
placeholderAttributes[NSBaselineOffsetAttributeName] =
56+
@((paragraphStyle.maximumLineHeight - font.lineHeight) / 2.0);
57+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#import <React/RCTUITextField.h>
99

1010
#import <React/RCTBackedTextInputDelegateAdapter.h>
11+
#import <React/RCTBackedTextInputViewLineHeightUtils.h>
1112
#import <React/RCTTextAttributes.h>
1213
#import <React/RCTUtils.h>
1314
#import <React/UIView+React.h>
@@ -92,8 +93,13 @@ - (void)setDefaultTextAttributes:(NSDictionary<NSAttributedStringKey, id> *)defa
9293
return;
9394
}
9495

96+
// Keep the unstripped attributes available for `_placeholderTextAttributes`, which
97+
// needs the configured `lineHeight` to compute the placeholder's baseline offset.
98+
// Hand UIKit a copy with the paragraphStyle line-height zeroed so the typed-text
99+
// path renders at the font's natural metrics — vertical centering then positions
100+
// the glyph in the bounds and the caret rect shrinks to match.
95101
_defaultTextAttributes = defaultTextAttributes;
96-
[super setDefaultTextAttributes:defaultTextAttributes];
102+
[super setDefaultTextAttributes:RCTStripDefaultTextAttributesLineHeight(defaultTextAttributes)];
97103
[self _updatePlaceholder];
98104
}
99105

@@ -169,6 +175,7 @@ - (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts
169175
[textAttributes removeObjectForKey:NSForegroundColorAttributeName];
170176
}
171177

178+
RCTApplyPlaceholderBaselineOffset(textAttributes);
172179
return textAttributes;
173180
}
174181

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

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
1313
#import <react/renderer/textlayoutmanager/TextLayoutManager.h>
1414

15+
#import <React/RCTBackedTextInputViewLineHeightUtils.h>
1516
#import <React/RCTBackedTextInputViewProtocol.h>
1617
#import <React/RCTScrollViewComponentView.h>
1718
#import <React/RCTUITextField.h>
@@ -47,6 +48,12 @@ @implementation RCTTextInputComponentView {
4748
UIView<RCTBackedTextInputViewProtocol> *_backedTextInputView;
4849
NSUInteger _mostRecentEventCount;
4950
NSAttributedString *_lastStringStateWasUpdatedWith;
51+
// The most-recent attributedString JS pushed via `_setAttributedString:`, kept
52+
// unstripped (with the configured `lineHeight`). Used as the source of truth
53+
// for state pushes — the displayed `attributedText` may have its paragraph-style
54+
// line-height zeroed for single-line rendering, but the shadow node measures
55+
// from state and needs the real line-height to keep the cell stable.
56+
NSAttributedString *_sourceAttributedString;
5057

5158
/*
5259
* UIKit uses either UITextField or UITextView as its UIKit element for <TextInput>. UITextField is for single line
@@ -712,10 +719,10 @@ - (void)handleInputAccessoryDoneButton
712719
return {
713720
.text = RCTStringFromNSString(_backedTextInputView.attributedText.string),
714721
.selectionRange = [self _selectionRange],
715-
.eventCount = static_cast<int>(_mostRecentEventCount),
722+
.contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize),
716723
.contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset),
717724
.contentInset = RCTEdgeInsetsFromUIEdgeInsets(_backedTextInputView.contentInset),
718-
.contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize),
725+
.eventCount = static_cast<int>(_mostRecentEventCount),
719726
.layoutMeasurement = RCTSizeFromCGSize(_backedTextInputView.bounds.size),
720727
.zoomScale = _backedTextInputView.zoomScale,
721728
};
@@ -729,7 +736,21 @@ - (void)_updateState
729736
NSAttributedString *attributedString = _backedTextInputView.attributedText;
730737
auto data = _state->getData();
731738
_lastStringStateWasUpdatedWith = attributedString;
732-
data.attributedStringBox = RCTAttributedStringBoxFromNSAttributedString(attributedString);
739+
740+
// Single-line rendering may have stripped the paragraph-style line-height from
741+
// the displayed `attributedText`, but the shadow node measures from state and
742+
// needs the configured `lineHeight`. Use the saved unstripped source string
743+
// when its content still matches the field; if the user has typed since the
744+
// last push, fall back to building a fresh string from the field's text and
745+
// the (unstripped) `defaultTextAttributes`.
746+
NSAttributedString *sourceString = _sourceAttributedString;
747+
if (![sourceString.string isEqualToString:attributedString.string]) {
748+
sourceString = [[NSAttributedString alloc] initWithString:attributedString.string
749+
attributes:_backedTextInputView.defaultTextAttributes];
750+
_sourceAttributedString = sourceString;
751+
}
752+
753+
data.attributedStringBox = RCTAttributedStringBoxFromNSAttributedString(sourceString);
733754
_mostRecentEventCount += _comingFromJS ? 0 : 1;
734755
data.mostRecentEventCount = _mostRecentEventCount;
735756
_state->updateState(std::move(data));
@@ -768,6 +789,46 @@ - (void)_restoreTextSelectionAndIgnoreCaretChange:(BOOL)ignore
768789

769790
- (void)_setAttributedString:(NSAttributedString *)attributedString
770791
{
792+
// Save the unstripped source for layout / state push before any display
793+
// transformation runs. `_updateState` reads this back so the shadow node
794+
// measures from the configured `lineHeight`, not the render-time strip.
795+
_sourceAttributedString = [attributedString copy];
796+
797+
// When `lineHeight > font.lineHeight`, UIKit's draw paths anchor glyphs to the
798+
// bottom of the paragraph line box. UITextView honors NSBaselineOffsetAttributeName
799+
// to re-center; UITextField does not, so for single-line we zero the paragraph-style
800+
// line-height (UITextField then renders at the font's natural metrics, and its
801+
// built-in vertical centering positions the glyph in the bounds).
802+
NSDictionary<NSAttributedStringKey, id> *defaults = _backedTextInputView.defaultTextAttributes;
803+
NSParagraphStyle *defaultParagraphStyle = defaults[NSParagraphStyleAttributeName];
804+
UIFont *defaultFont = defaults[NSFontAttributeName];
805+
if (attributedString.length > 0 && defaultParagraphStyle && defaultFont &&
806+
defaultParagraphStyle.maximumLineHeight > defaultFont.lineHeight) {
807+
NSMutableAttributedString *mutableString = [attributedString mutableCopy];
808+
if ([_backedTextInputView isKindOfClass:[RCTUITextView class]]) {
809+
// UIKit's typingAttributes drop NSParagraphStyle on the round-trip, so chars typed
810+
// since the last state push arrive without a paragraph style. Re-seed those ranges
811+
// (and ranges with a zero-line-height stub) from the default so UITextView resolves a
812+
// consistent line-box height across the whole string. RCTApplyBaselineOffset then
813+
// computes the centering offset using the max font.lineHeight present on each line —
814+
// correct for mixed-font input from nested <Text> children.
815+
[mutableString enumerateAttribute:NSParagraphStyleAttributeName
816+
inRange:NSMakeRange(0, mutableString.length)
817+
options:0
818+
usingBlock:^(NSParagraphStyle *style, NSRange range, __unused BOOL *stop) {
819+
if (!style || style.maximumLineHeight == 0) {
820+
[mutableString addAttribute:NSParagraphStyleAttributeName
821+
value:defaultParagraphStyle
822+
range:range];
823+
}
824+
}];
825+
RCTApplyBaselineOffset(mutableString);
826+
} else {
827+
RCTStripAttributedStringLineHeights(mutableString);
828+
}
829+
attributedString = mutableString;
830+
}
831+
771832
if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) {
772833
return;
773834
}

0 commit comments

Comments
 (0)