diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h index be946f87bbe3..64974a4cf530 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 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 cbda3771e97c..4f9db1918ff1 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)setTextAlignVertical:(RCTUITextViewTextAlignmentVertical)textAlignVertical +{ + if (_textAlignVertical == textAlignVertical) { + return; + } + _textAlignVertical = textAlignVertical; + [self setNeedsLayout]; +} + - (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts { _disableKeyboardShortcuts = disableKeyboardShortcuts; @@ -280,12 +289,50 @@ - (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 +{ + // 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) { + if (_textAlignVertical == RCTUITextViewTextAlignmentVerticalCenter) { + topInset = (boundsHeight - contentHeight) / 2; + } else if (_textAlignVertical == RCTUITextViewTextAlignmentVerticalBottom) { + topInset = boundsHeight - contentHeight; + } + } + if (self.contentInset.top != topInset) { + UIEdgeInsets contentInset = self.contentInset; + 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..750432c01a26 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 _applyTextAlignVerticalToMultilineView: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 _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)_applyTextAlignVerticalToMultilineView: + (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).textAlignVertical = mapped; } - (void)_setShowSoftInputOnFocus:(BOOL)showSoftInputOnFocus 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; 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;