Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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!
*/
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -829,6 +835,40 @@ - (void)_setMultiline:(BOOL)multiline
RCTCopyBackedTextInput(_backedTextInputView, backedTextInputView);
_backedTextInputView = backedTextInputView;
[self addSubview:_backedTextInputView];

if (multiline) {
const auto &textInputProps = static_cast<const TextInputProps &>(*_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<facebook::react::TextAlignmentVertical> &)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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &paragraphAttributes,
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
Expand Down Expand Up @@ -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);
Expand All @@ -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];
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -2351,6 +2351,7 @@ interface RCTUITextView : public UITextView <RCTBackedTextInputViewProtocol> {
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;
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -2351,6 +2351,7 @@ interface RCTUITextView : public UITextView <RCTBackedTextInputViewProtocol> {
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;
Expand Down