Skip to content
Draft
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
13 changes: 10 additions & 3 deletions packages/react-native/Libraries/Text/Text/RCTTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#import <CoreText/CoreText.h>
#import <MobileCoreServices/UTCoreTypes.h>

#import <React/RCTTextStroke.h>
#import <React/RCTUtils.h>
#import <React/UIView+React.h>

Expand Down Expand Up @@ -158,14 +159,20 @@ - (void)drawRect:(CGRect)rect

CGFloat strokeInset = strokeWidth / 2;

// Core Text ignores the container's line limit / truncation, so feed it the same visible,
// truncated text NSLayoutManager laid out; otherwise an overflowing last word would vanish.
NSAttributedString *visibleText =
RCTTruncatedAttributedStringForStroke(_textStorage, layoutManager, textContainer);
NSRange visibleRange = NSMakeRange(0, visibleText.length);

// PASS 1: Draw stroke outline
CGContextSaveGState(context);
CGContextSetTextDrawingMode(context, kCGTextStroke);

NSMutableAttributedString *strokeText = [_textStorage mutableCopy];
NSMutableAttributedString *strokeText = [visibleText mutableCopy];
[strokeText addAttribute:NSForegroundColorAttributeName
value:strokeColor
range:characterRange];
range:visibleRange];

CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset);
Expand All @@ -189,7 +196,7 @@ - (void)drawRect:(CGRect)rect
CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset);
CGContextScaleCTM(context, 1.0, -1.0);

framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)_textStorage);
framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)visibleText);
path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height));
frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
Expand Down
33 changes: 33 additions & 0 deletions packages/react-native/React/Base/RCTTextStroke.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

/**
* Core Text (used for the two-pass stroke rendering of text with a stroke effect) performs its own
* word wrapping and ignores the NSTextContainer's `maximumNumberOfLines` and truncating line break
* mode. If we hand it the full attributed string, an overflowing last word gets wrapped to a line
* that does not fit the (clamped) frame and disappears entirely - with no ellipsis. The non-stroke
* path avoids this because it draws the glyphs NSLayoutManager already laid out and truncated. This
* helper reconstructs that same visible, truncated text (visible characters + an ellipsis) so the
* stroke passes render identically to the non-stroke path.
*
* NOTE: This helper is written for TAIL truncation (`NSLineBreakByTruncatingTail`), which is the
* only mode used by stroke effects. `truncatedGlyphRangeInLineFragmentForGlyphAtIndex:` only
* reports a range for truncating line break modes, so for non-truncating modes (e.g. clipping) the
* full string is returned unchanged. Head/middle truncation would report a range but place the
* ellipsis at the start/middle; the reconstruction here always appends it at the end, so those
* modes would need mode-specific handling before they could be used with a stroke effect.
*/
NSAttributedString *RCTTruncatedAttributedStringForStroke(
NSTextStorage *textStorage,
NSLayoutManager *layoutManager,
NSTextContainer *textContainer);

NS_ASSUME_NONNULL_END
82 changes: 82 additions & 0 deletions packages/react-native/React/Base/RCTTextStroke.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <React/RCTTextStroke.h>

NSAttributedString *RCTTruncatedAttributedStringForStroke(
NSTextStorage *textStorage,
NSLayoutManager *layoutManager,
NSTextContainer *textContainer)
{
// No line limit means nothing is truncated and the frame was measured to fit every wrapped line,
// so Core Text can safely lay out the full string.
if (textContainer.maximumNumberOfLines == 0) {
return textStorage;
}

[layoutManager ensureLayoutForTextContainer:textContainer];
NSRange fullGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
if (fullGlyphRange.length == 0) {
return textStorage;
}

NSMutableAttributedString *truncated = [NSMutableAttributedString new];
[layoutManager
enumerateLineFragmentsForGlyphRange:fullGlyphRange
usingBlock:^(
CGRect rect,
CGRect usedRect,
NSTextContainer *_Nonnull _,
NSRange lineGlyphRange,
BOOL *_Nonnull stop) {
NSRange lineCharRange =
[layoutManager characterRangeForGlyphRange:lineGlyphRange
actualGlyphRange:NULL];
NSRange truncatedGlyphRange = [layoutManager
truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location];

if (truncatedGlyphRange.location != NSNotFound) {
// This line is truncated. Keep the characters before the
// truncation point and append an ellipsis carrying the attributes
// of the last visible character (matching what TextKit renders).
NSRange truncatedCharRange =
[layoutManager characterRangeForGlyphRange:truncatedGlyphRange
actualGlyphRange:NULL];
NSInteger visibleLength = truncatedCharRange.location - lineCharRange.location;
if (visibleLength > 0) {
NSRange visibleRange = NSMakeRange(lineCharRange.location, visibleLength);
[truncated
appendAttributedString:[textStorage attributedSubstringFromRange:visibleRange]];
NSDictionary<NSAttributedStringKey, id> *ellipsisAttributes =
[textStorage attributesAtIndex:NSMaxRange(visibleRange) - 1 effectiveRange:NULL];
[truncated appendAttributedString:[[NSAttributedString alloc]
initWithString:@"\u2026"
attributes:ellipsisAttributes]];
}
*stop = YES;
return;
}

// Fully visible line. Append it verbatim; if it was soft-wrapped
// (does not already end in a newline) insert one so Core Text
// reproduces the same line break.
NSAttributedString *lineString =
[textStorage attributedSubstringFromRange:lineCharRange];
[truncated appendAttributedString:lineString];
NSString *lineText = lineString.string;
if (lineText.length > 0 && [lineText characterAtIndex:lineText.length - 1] != '\n' &&
NSMaxRange(lineCharRange) < textStorage.length) {
NSDictionary<NSAttributedStringKey, id> *newlineAttributes =
[textStorage attributesAtIndex:NSMaxRange(lineCharRange) - 1 effectiveRange:NULL];
[truncated appendAttributedString:[[NSAttributedString alloc]
initWithString:@"\n"
attributes:newlineAttributes]];
}
}];

return truncated.length > 0 ? truncated : textStorage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#import <CoreText/CoreText.h>

#import <React/NSTextStorage+FontScaling.h>
#import <React/RCTTextStroke.h>
#import <React/RCTUtils.h>
#import <react/featureflags/ReactNativeFeatureFlags.h>
#import <react/utils/ManagedObjectWrapper.h>
Expand Down Expand Up @@ -143,12 +144,18 @@ - (void)drawAttributedString:(AttributedString)attributedString
CGFloat strokeShiftY =
(slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0);

// Core Text ignores the container's line limit / truncation, so feed it the same visible,
// truncated text NSLayoutManager laid out; otherwise an overflowing last word would vanish.
NSAttributedString *visibleText =
RCTTruncatedAttributedStringForStroke(textStorage, layoutManager, textContainer);
NSRange visibleRange = NSMakeRange(0, visibleText.length);

// PASS 1: Draw stroke outline
CGContextSaveGState(context);
CGContextSetTextDrawingMode(context, kCGTextStroke);

NSMutableAttributedString *strokeText = [textStorage mutableCopy];
[strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange];
NSMutableAttributedString *strokeText = [visibleText mutableCopy];
[strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:visibleRange];

CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(
Expand All @@ -174,7 +181,7 @@ - (void)drawAttributedString:(AttributedString)attributedString
context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY);
CGContextScaleCTM(context, 1.0, -1.0);

framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage);
framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)visibleText);
path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height));
ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
Expand Down