From 6a109660ac80777f4afcae86c01eea031ce1a544 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 11 May 2026 13:24:54 -0400 Subject: [PATCH 1/4] feat(ios): support textAlignVertical on Paragraph Honor ParagraphAttributes.textAlignVertical when drawing the Fabric Paragraph view on iOS. Mirrors Android's existing offset computation: center the line-box stack vertically when 'center' is requested, push to the bottom when 'bottom' is requested, fall back to top when the text overflows. Shifts paint origin, highlight rects, hit-test point, and link/button accessibility rects in lockstep. Equivalent to CSS Box Alignment Level 3 align-content: {start,center,end} with safe overflow on a block container. --- .../textlayoutmanager/RCTTextLayoutManager.mm | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index ac553045a9c0..71d65081e9b0 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -35,6 +35,41 @@ static NSLineBreakMode RCTNSLineBreakModeFromEllipsizeMode(EllipsizeMode ellipsi } } +// Block-axis offset to apply to the line-box stack when painting / hit-testing. +// Mirrors the CSS Box Alignment Level 3 `align-content` algorithm on a block +// container (https://drafts.csswg.org/css-align-3/#align-content-property): +// start/center/end positioning of the content within the box, with "safe" +// overflow handling (when content exceeds the box, fall back to start). +// Matches Android's `TextLayoutManager.getVerticalOffset` exactly so Paragraph +// renders identically across platforms. +static CGFloat RCTVerticalOffsetForTextAlignment( + NSLayoutManager *layoutManager, + NSTextContainer *textContainer, + const ParagraphAttributes ¶graphAttributes, + CGFloat boxHeight) +{ + if (!paragraphAttributes.textAlignVertical.has_value()) { + return 0; + } + TextAlignmentVertical align = *paragraphAttributes.textAlignVertical; + if (align == TextAlignmentVertical::Auto || align == TextAlignmentVertical::Top) { + return 0; + } + CGFloat textHeight = [layoutManager usedRectForTextContainer:textContainer].size.height; + if (textHeight >= boxHeight) { + return 0; + } + switch (align) { + case TextAlignmentVertical::Center: + return (boxHeight - textHeight) / 2; + case TextAlignmentVertical::Bottom: + return boxHeight - textHeight; + case TextAlignmentVertical::Auto: + case TextAlignmentVertical::Top: + return 0; + } +} + - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext @@ -88,8 +123,12 @@ - (void)drawAttributedString:(AttributedString)attributedString [self processTruncatedAttributedText:textStorage textContainer:textContainer layoutManager:layoutManager]; - [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:frame.origin]; - [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:frame.origin]; + CGFloat verticalOffset = + RCTVerticalOffsetForTextAlignment(layoutManager, textContainer, paragraphAttributes, frame.size.height); + CGPoint origin = CGPointMake(frame.origin.x, frame.origin.y + verticalOffset); + + [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:origin]; + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:origin]; #if TARGET_OS_MACCATALYST CGContextRestoreGState(context); @@ -113,8 +152,9 @@ - (void)drawAttributedString:(AttributedString)attributedString withinSelectedGlyphRange:range inTextContainer:textContainer usingBlock:^(CGRect enclosingRect, __unused BOOL *anotherStop) { + CGRect shiftedRect = CGRectOffset(enclosingRect, 0, verticalOffset); UIBezierPath *path = [UIBezierPath - bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2) + bezierPathWithRoundedRect:CGRectInset(shiftedRect, -2, -2) cornerRadius:2]; if (highlightPath != nullptr) { [highlightPath appendPath:path]; @@ -264,8 +304,12 @@ - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttribute NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + CGFloat verticalOffset = + RCTVerticalOffsetForTextAlignment(layoutManager, textContainer, paragraphAttributes, frame.size.height); + CGPoint adjustedPoint = CGPointMake(point.x, point.y - verticalOffset); + CGFloat fraction; - NSUInteger characterIndex = [layoutManager characterIndexForPoint:point + NSUInteger characterIndex = [layoutManager characterIndexForPoint:adjustedPoint inTextContainer:textContainer fractionOfDistanceBetweenInsertionPoints:&fraction]; @@ -300,6 +344,9 @@ - (void)getRectWithAttributedString:(AttributedString)attributedString NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; + CGFloat verticalOffset = + RCTVerticalOffsetForTextAlignment(layoutManager, textContainer, paragraphAttributes, frame.size.height); + [textStorage enumerateAttribute:enumerateAttribute inRange:characterRange options:0 @@ -314,7 +361,7 @@ - (void)getRectWithAttributedString:(AttributedString)attributedString inTextContainer:textContainer usingBlock:^(CGRect enclosingRect, BOOL *_Nonnull stop) { block( - enclosingRect, + CGRectOffset(enclosingRect, 0, verticalOffset), [textStorage attributedSubstringFromRange:range].string, value); *stop = YES; From 1e41147dda9904dac1dfe607899a222e3c8b549e Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 11 May 2026 14:15:12 -0400 Subject: [PATCH 2/4] feat(ios): support textAlignVertical on multiline TextInput Extend the textAlignVertical fix from Paragraph to multiline TextInput so a tall fixed-height multiline field can sit centered or bottom-aligned on iOS the same way it already does on Android. Single-line TextInput (UITextField) centers natively, so this only routes when the backed view is the multiline UITextView. Offset is applied via UIScrollView's contentInset.top inside RCTUITextView.layoutSubviews, which is the spec-correct surface: it shifts the default scroll origin without interfering with text layout or user scrolling. When content exceeds bounds the inset falls back to 0 (safe overflow per CSS Box Alignment L3 align-content). --- .../Text/TextInput/Multiline/RCTUITextView.h | 21 +++++++++ .../Text/TextInput/Multiline/RCTUITextView.mm | 44 +++++++++++++++++++ .../TextInput/RCTTextInputComponentView.mm | 40 +++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h index be946f87bbe3..285b6635042e 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h @@ -12,6 +12,19 @@ NS_ASSUME_NONNULL_BEGIN +/* + * Mirrors `facebook::react::TextAlignmentVertical` (kept private to avoid + * exposing C++ types from this ObjC header). Keep the integer values in + * sync with `enum class TextAlignmentVertical` in + * `ReactCommon/react/renderer/attributedstring/primitives.h`. + */ +typedef NS_ENUM(NSInteger, RCTUITextViewTextAlignmentVertical) { + RCTUITextViewTextAlignmentVerticalAuto = 0, + RCTUITextViewTextAlignmentVerticalTop, + RCTUITextViewTextAlignmentVerticalBottom, + RCTUITextViewTextAlignmentVerticalCenter, +}; + /* * Just regular UITextView... but much better! */ @@ -36,6 +49,14 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL caretHidden; +/* + * Block-axis alignment of the text within the host view's bounds, mirroring + * Android's `textAlignVertical`. Applied via `contentInset.top` so a short + * multiline value can sit centered or pushed to the bottom of a tall fixed + * frame. Defaults to `Auto` (top-aligned, current behavior). + */ +@property (nonatomic, assign) RCTUITextViewTextAlignmentVertical textAlignmentVertical; + @property (nonatomic, strong, nullable) NSString *inputAccessoryViewID; @property (nonatomic, strong, nullable) NSString *inputAccessoryViewButtonLabel; diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index cbda3771e97c..a8899b9e4476 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -150,6 +150,15 @@ - (void)textDidChange [self _invalidatePlaceholderVisibility]; } +- (void)setTextAlignmentVertical:(RCTUITextViewTextAlignmentVertical)textAlignmentVertical +{ + if (_textAlignmentVertical == textAlignmentVertical) { + return; + } + _textAlignmentVertical = textAlignmentVertical; + [self setNeedsLayout]; +} + - (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts { _disableKeyboardShortcuts = disableKeyboardShortcuts; @@ -280,12 +289,47 @@ - (void)layoutSubviews { [super layoutSubviews]; + [self _applyVerticalAlignmentInset]; + CGRect textFrame = UIEdgeInsetsInsetRect(self.bounds, self.textContainerInset); CGFloat placeholderHeight = [_placeholderView sizeThatFits:textFrame.size].height; textFrame.size.height = MIN(placeholderHeight, textFrame.size.height); _placeholderView.frame = textFrame; } +// Mirrors CSS Box Alignment Level 3 `align-content` on a block container +// (https://drafts.csswg.org/css-align-3/#align-content-property): top is the +// existing default, center vertically centers the content within the bounds, +// bottom flushes it to the bottom. Overflow (content taller than bounds) falls +// back to top so nothing gets clipped. UIScrollView's `contentInset.top` is +// the right hook: it shifts the default scroll origin and is left untouched +// by RN's existing wiring (only `textContainerInset` is set externally). +- (void)_applyVerticalAlignmentInset +{ + CGFloat boundsHeight = CGRectGetHeight(self.bounds); + CGFloat contentHeight = self.contentSize.height; + CGFloat topInset = 0; + if (boundsHeight > contentHeight) { + switch (_textAlignmentVertical) { + case RCTUITextViewTextAlignmentVerticalCenter: + topInset = (boundsHeight - contentHeight) / 2; + break; + case RCTUITextViewTextAlignmentVerticalBottom: + topInset = boundsHeight - contentHeight; + break; + case RCTUITextViewTextAlignmentVerticalAuto: + case RCTUITextViewTextAlignmentVerticalTop: + topInset = 0; + break; + } + } + UIEdgeInsets contentInset = self.contentInset; + if (contentInset.top != topInset) { + contentInset.top = topInset; + self.contentInset = contentInset; + } +} + - (CGSize)intrinsicContentSize { // Returning size DOES contain `textContainerInset` (aka `padding`). 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 c0e56acd2a0f..9391ac30789d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -197,6 +197,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & [self _setMultiline:newTextInputProps.multiline]; } + if (newTextInputProps.multiline && + newTextInputProps.paragraphAttributes.textAlignVertical != + oldTextInputProps.paragraphAttributes.textAlignVertical) { + [self _applyTextAlignmentVerticalToMultilineView:newTextInputProps.paragraphAttributes.textAlignVertical]; + } + if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) { _backedTextInputView.autocapitalizationType = RCTUITextAutocapitalizationTypeFromAutocapitalizationType(newTextInputProps.traits.autocapitalizationType); @@ -829,6 +835,40 @@ - (void)_setMultiline:(BOOL)multiline RCTCopyBackedTextInput(_backedTextInputView, backedTextInputView); _backedTextInputView = backedTextInputView; [self addSubview:_backedTextInputView]; + + if (multiline) { + const auto &textInputProps = static_cast(*_props); + [self _applyTextAlignmentVerticalToMultilineView:textInputProps.paragraphAttributes.textAlignVertical]; + } +} + +// Single-line `UITextField` centers its content vertically inside the field +// frame natively, so this routing is only meaningful when the backed view is +// the multiline `RCTUITextView`. UITextField callers no-op. +- (void)_applyTextAlignmentVerticalToMultilineView: + (const std::optional &)textAlignVertical +{ + if (![_backedTextInputView isKindOfClass:[RCTUITextView class]]) { + return; + } + RCTUITextViewTextAlignmentVertical mapped = RCTUITextViewTextAlignmentVerticalAuto; + if (textAlignVertical.has_value()) { + switch (*textAlignVertical) { + case facebook::react::TextAlignmentVertical::Auto: + mapped = RCTUITextViewTextAlignmentVerticalAuto; + break; + case facebook::react::TextAlignmentVertical::Top: + mapped = RCTUITextViewTextAlignmentVerticalTop; + break; + case facebook::react::TextAlignmentVertical::Bottom: + mapped = RCTUITextViewTextAlignmentVerticalBottom; + break; + case facebook::react::TextAlignmentVertical::Center: + mapped = RCTUITextViewTextAlignmentVerticalCenter; + break; + } + } + ((RCTUITextView *)_backedTextInputView).textAlignmentVertical = mapped; } - (void)_setShowSoftInputOnFocus:(BOOL)showSoftInputOnFocus From a1db2dc720397d1b315c24122ff251a25b2cddf7 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 11 May 2026 14:21:18 -0400 Subject: [PATCH 3/4] refactor(ios): rename textAlignVertical bridge and add Auto fast-path Rename the ObjC property to `textAlignVertical` so the bridge matches the JS prop and the C++ paragraph attribute exactly. The enum type stays `RCTUITextViewTextAlignmentVertical` since it mirrors the C++ enum class name. Skip the contentSize read for the default Auto / Top case so the new code adds zero per-layout work for apps that don't opt in. --- .../Text/TextInput/Multiline/RCTUITextView.h | 2 +- .../Text/TextInput/Multiline/RCTUITextView.mm | 35 ++++++++++--------- .../TextInput/RCTTextInputComponentView.mm | 8 ++--- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h index 285b6635042e..64974a4cf530 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h @@ -55,7 +55,7 @@ typedef NS_ENUM(NSInteger, RCTUITextViewTextAlignmentVertical) { * multiline value can sit centered or pushed to the bottom of a tall fixed * frame. Defaults to `Auto` (top-aligned, current behavior). */ -@property (nonatomic, assign) RCTUITextViewTextAlignmentVertical textAlignmentVertical; +@property (nonatomic, assign) RCTUITextViewTextAlignmentVertical textAlignVertical; @property (nonatomic, strong, nullable) NSString *inputAccessoryViewID; @property (nonatomic, strong, nullable) NSString *inputAccessoryViewButtonLabel; diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index a8899b9e4476..4f9db1918ff1 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -150,12 +150,12 @@ - (void)textDidChange [self _invalidatePlaceholderVisibility]; } -- (void)setTextAlignmentVertical:(RCTUITextViewTextAlignmentVertical)textAlignmentVertical +- (void)setTextAlignVertical:(RCTUITextViewTextAlignmentVertical)textAlignVertical { - if (_textAlignmentVertical == textAlignmentVertical) { + if (_textAlignVertical == textAlignVertical) { return; } - _textAlignmentVertical = textAlignmentVertical; + _textAlignVertical = textAlignVertical; [self setNeedsLayout]; } @@ -306,25 +306,28 @@ - (void)layoutSubviews // by RN's existing wiring (only `textContainerInset` is set externally). - (void)_applyVerticalAlignmentInset { + // Fast path for apps that don't use the feature: skip the contentSize read. + if (_textAlignVertical == RCTUITextViewTextAlignmentVerticalAuto || + _textAlignVertical == RCTUITextViewTextAlignmentVerticalTop) { + if (self.contentInset.top != 0) { + UIEdgeInsets contentInset = self.contentInset; + contentInset.top = 0; + self.contentInset = contentInset; + } + return; + } CGFloat boundsHeight = CGRectGetHeight(self.bounds); CGFloat contentHeight = self.contentSize.height; CGFloat topInset = 0; if (boundsHeight > contentHeight) { - switch (_textAlignmentVertical) { - case RCTUITextViewTextAlignmentVerticalCenter: - topInset = (boundsHeight - contentHeight) / 2; - break; - case RCTUITextViewTextAlignmentVerticalBottom: - topInset = boundsHeight - contentHeight; - break; - case RCTUITextViewTextAlignmentVerticalAuto: - case RCTUITextViewTextAlignmentVerticalTop: - topInset = 0; - break; + if (_textAlignVertical == RCTUITextViewTextAlignmentVerticalCenter) { + topInset = (boundsHeight - contentHeight) / 2; + } else if (_textAlignVertical == RCTUITextViewTextAlignmentVerticalBottom) { + topInset = boundsHeight - contentHeight; } } - UIEdgeInsets contentInset = self.contentInset; - if (contentInset.top != topInset) { + if (self.contentInset.top != topInset) { + UIEdgeInsets contentInset = self.contentInset; contentInset.top = topInset; self.contentInset = contentInset; } 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 9391ac30789d..750432c01a26 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -200,7 +200,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (newTextInputProps.multiline && newTextInputProps.paragraphAttributes.textAlignVertical != oldTextInputProps.paragraphAttributes.textAlignVertical) { - [self _applyTextAlignmentVerticalToMultilineView:newTextInputProps.paragraphAttributes.textAlignVertical]; + [self _applyTextAlignVerticalToMultilineView:newTextInputProps.paragraphAttributes.textAlignVertical]; } if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) { @@ -838,14 +838,14 @@ - (void)_setMultiline:(BOOL)multiline if (multiline) { const auto &textInputProps = static_cast(*_props); - [self _applyTextAlignmentVerticalToMultilineView:textInputProps.paragraphAttributes.textAlignVertical]; + [self _applyTextAlignVerticalToMultilineView:textInputProps.paragraphAttributes.textAlignVertical]; } } // Single-line `UITextField` centers its content vertically inside the field // frame natively, so this routing is only meaningful when the backed view is // the multiline `RCTUITextView`. UITextField callers no-op. -- (void)_applyTextAlignmentVerticalToMultilineView: +- (void)_applyTextAlignVerticalToMultilineView: (const std::optional &)textAlignVertical { if (![_backedTextInputView isKindOfClass:[RCTUITextView class]]) { @@ -868,7 +868,7 @@ - (void)_applyTextAlignmentVerticalToMultilineView: break; } } - ((RCTUITextView *)_backedTextInputView).textAlignmentVertical = mapped; + ((RCTUITextView *)_backedTextInputView).textAlignVertical = mapped; } - (void)_setShowSoftInputOnFocus:(BOOL)showSoftInputOnFocus From eff822679b958316b2adedec8cbf3c2ac15cce3e Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Tue, 12 May 2026 16:19:36 -0400 Subject: [PATCH 4/4] chore(ios): regenerate C++ API snapshots for textAlignVertical Adds RCTUITextView.textAlignVertical to the committed Apple C++ API snapshots so validate_cxx_api_snapshots passes. --- scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index bb3a74b3522e..d46111ef16f9 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2351,6 +2351,7 @@ interface RCTUITextView : public UITextView { public @property (assign) BOOL contextMenuHidden; public @property (assign) BOOL disableKeyboardShortcuts; public @property (assign) CGFloat preferredMaxLayoutWidth; + public @property (assign) RCTUITextViewTextAlignmentVertical textAlignVertical; public @property (assign) UITextFieldViewMode clearButtonMode; public @property (assign, readonly) BOOL dictationRecognizing; public @property (assign, readonly) BOOL textWasPasted; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 53f03430fc4b..26524ff1370f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -2351,6 +2351,7 @@ interface RCTUITextView : public UITextView { public @property (assign) BOOL contextMenuHidden; public @property (assign) BOOL disableKeyboardShortcuts; public @property (assign) CGFloat preferredMaxLayoutWidth; + public @property (assign) RCTUITextViewTextAlignmentVertical textAlignVertical; public @property (assign) UITextFieldViewMode clearButtonMode; public @property (assign, readonly) BOOL dictationRecognizing; public @property (assign, readonly) BOOL textWasPasted;