Skip to content
Merged
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 @@ -206,6 +206,8 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
textShadowColor: colorAttributes,
textShadowOffset: true,
textShadowRadius: true,
textStrokeColor: colorAttributes,
textStrokeWidth: true,
textTransform: true,
userSelect: true,
verticalAlign: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ - (RCTShadowView *)shadowView
RCT_REMAP_SHADOW_PROPERTY(color, textAttributes.foregroundColor, UIColor)
RCT_REMAP_SHADOW_PROPERTY(backgroundColor, textAttributes.backgroundColor, UIColor)
RCT_REMAP_SHADOW_PROPERTY(gradientColors, textAttributes.gradientColors, NSArray)
RCT_REMAP_SHADOW_PROPERTY(gradientAngle, textAttributes.gradientAngle, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(opacity, textAttributes.opacity, CGFloat)
// Font
RCT_REMAP_SHADOW_PROPERTY(fontFamily, textAttributes.fontFamily, NSString)
Expand All @@ -54,6 +55,9 @@ - (RCTShadowView *)shadowView
RCT_REMAP_SHADOW_PROPERTY(textShadowOffset, textAttributes.textShadowOffset, CGSize)
RCT_REMAP_SHADOW_PROPERTY(textShadowRadius, textAttributes.textShadowRadius, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(textShadowColor, textAttributes.textShadowColor, UIColor)
// Stroke
RCT_REMAP_SHADOW_PROPERTY(textStrokeWidth, textAttributes.textStrokeWidth, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(textStrokeColor, textAttributes.textStrokeColor, UIColor)
// Special
RCT_REMAP_SHADOW_PROPERTY(isHighlighted, textAttributes.isHighlighted, BOOL)
RCT_REMAP_SHADOW_PROPERTY(textTransform, textAttributes.textTransform, RCTTextTransform)
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/Libraries/Text/RCTTextAttributes.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ extern NSString *const RCTTextAttributesTagAttributeName;
@property (nonatomic, strong, nullable) UIColor *foregroundColor;
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
@property (nonatomic, copy, nullable) NSArray *gradientColors;
@property (nonatomic, assign) CGFloat gradientAngle;
@property (nonatomic, assign) CGFloat opacity;
// Font
@property (nonatomic, copy, nullable) NSString *fontFamily;
Expand All @@ -53,6 +54,9 @@ extern NSString *const RCTTextAttributesTagAttributeName;
@property (nonatomic, assign) CGSize textShadowOffset;
@property (nonatomic, assign) CGFloat textShadowRadius;
@property (nonatomic, strong, nullable) UIColor *textShadowColor;
// Stroke
@property (nonatomic, assign) CGFloat textStrokeWidth;
@property (nonatomic, strong, nullable) UIColor *textStrokeColor;
// Special
@property (nonatomic, assign) BOOL isHighlighted;
@property (nonatomic, strong, nullable) NSNumber *tag;
Expand Down
34 changes: 31 additions & 3 deletions packages/react-native/Libraries/Text/RCTTextAttributes.mm
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ - (instancetype)init
_textShadowRadius = NAN;
_opacity = NAN;
_textTransform = RCTTextTransformUndefined;
_textStrokeWidth = NAN;
_gradientAngle = NAN;
}

return self;
Expand All @@ -47,6 +49,7 @@ - (void)applyTextAttributes:(RCTTextAttributes *)textAttributes
_foregroundColor = textAttributes->_foregroundColor ?: _foregroundColor;
_backgroundColor = textAttributes->_backgroundColor ?: _backgroundColor;
_gradientColors = textAttributes->_gradientColors ?: _gradientColors;
_gradientAngle = !isnan(textAttributes->_gradientAngle) ? textAttributes->_gradientAngle : _gradientAngle;
_opacity =
!isnan(textAttributes->_opacity) ? (isnan(_opacity) ? 1.0 : _opacity) * textAttributes->_opacity : _opacity;

Expand Down Expand Up @@ -90,6 +93,10 @@ - (void)applyTextAttributes:(RCTTextAttributes *)textAttributes
_textShadowRadius = !isnan(textAttributes->_textShadowRadius) ? textAttributes->_textShadowRadius : _textShadowRadius;
_textShadowColor = textAttributes->_textShadowColor ?: _textShadowColor;

// Stroke
_textStrokeWidth = !isnan(textAttributes->_textStrokeWidth) ? textAttributes->_textStrokeWidth : _textStrokeWidth;
_textStrokeColor = textAttributes->_textStrokeColor ?: _textStrokeColor;

// Special
_isHighlighted = textAttributes->_isHighlighted || _isHighlighted; // *
_tag = textAttributes->_tag ?: _tag;
Expand Down Expand Up @@ -210,6 +217,15 @@ - (NSParagraphStyle *)effectiveParagraphStyle
attributes[NSShadowAttributeName] = shadow;
}

// We don't use NSStrokeWidthAttributeName because it centers the stroke on the text path
// Instead, we do custom two-pass rendering to get true outer stroke
if (!isnan(_textStrokeWidth) && _textStrokeWidth > 0) {
UIColor *strokeColorToUse = _textStrokeColor ?: effectiveForegroundColor;
attributes[@"RCTTextStrokeWidth"] = @(_textStrokeWidth);
attributes[@"RCTTextStrokeColor"] = strokeColorToUse;
}


// Special
if (_isHighlighted) {
attributes[RCTTextAttributesIsHighlightedAttributeName] = @YES;
Expand Down Expand Up @@ -303,7 +319,7 @@ - (UIColor *)effectiveForegroundColor
[cgColors addObject:(id)color.CGColor];
}
}

if([cgColors count] > 0) {
[cgColors addObject:cgColors[0]];
CAGradientLayer *gradient = [CAGradientLayer layer];
Expand All @@ -312,8 +328,17 @@ - (UIColor *)effectiveForegroundColor
CGFloat height = _lineHeight * self.effectiveFontSizeMultiplier;
gradient.frame = CGRectMake(0, 0, patternWidth, height);
gradient.colors = cgColors;
gradient.startPoint = CGPointMake(0.0, 0.5);
gradient.endPoint = CGPointMake(1.0, 0.5);

CGFloat angle = !isnan(_gradientAngle) ? _gradientAngle : 0.0;
CGFloat radians = angle * M_PI / 180.0;

CGFloat startX = 0.5 - 0.5 * cos(radians);
CGFloat startY = 0.5 - 0.5 * sin(radians);
CGFloat endX = 0.5 + 0.5 * cos(radians);
CGFloat endY = 0.5 + 0.5 * sin(radians);

gradient.startPoint = CGPointMake(startX, startY);
gradient.endPoint = CGPointMake(endX, endY);

UIGraphicsBeginImageContextWithOptions(gradient.frame.size, NO, 0.0);
[gradient renderInContext:UIGraphicsGetCurrentContext()];
Expand Down Expand Up @@ -397,6 +422,7 @@ - (BOOL)isEqual:(RCTTextAttributes *)textAttributes
#define RCTTextAttributesCompareOthers(a) (a == textAttributes->a)

return RCTTextAttributesCompareObjects(_foregroundColor) && RCTTextAttributesCompareObjects(_backgroundColor) &&
RCTTextAttributesCompareObjects(_gradientColors) && RCTTextAttributesCompareFloats(_gradientAngle) &&
RCTTextAttributesCompareFloats(_opacity) &&
// Font
RCTTextAttributesCompareObjects(_fontFamily) && RCTTextAttributesCompareFloats(_fontSize) &&
Expand All @@ -414,6 +440,8 @@ - (BOOL)isEqual:(RCTTextAttributes *)textAttributes
// Shadow
RCTTextAttributesCompareSize(_textShadowOffset) && RCTTextAttributesCompareFloats(_textShadowRadius) &&
RCTTextAttributesCompareObjects(_textShadowColor) &&
// Stroke
RCTTextAttributesCompareFloats(_textStrokeWidth) && RCTTextAttributesCompareObjects(_textStrokeColor) &&
// Special
RCTTextAttributesCompareOthers(_isHighlighted) && RCTTextAttributesCompareObjects(_tag) &&
RCTTextAttributesCompareOthers(_layoutDirection) && RCTTextAttributesCompareOthers(_textTransform);
Expand Down
15 changes: 15 additions & 0 deletions packages/react-native/Libraries/Text/Text.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,21 @@ export interface TextProps
* Adds a horizontal gradient using the int based color values.
*/
gradientColors?: number[] | undefined;

/**
* Gradient angle in degrees. Default is 0 (horizontal).
*/
gradientAngle?: number | undefined;

/**
* Width of the text stroke (outline). Creates an outer stroke effect.
*/
textStrokeWidth?: number | undefined;

/**
* Color of the text stroke (outline).
*/
textStrokeColor?: ColorValue | undefined;
}

/**
Expand Down
48 changes: 42 additions & 6 deletions packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,28 @@ - (CGFloat)lastBaselineForSize:(CGSize)size
[attributedText enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (maximumDescender > font.descender) {
maximumDescender = font.descender;
}
}];
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (maximumDescender > font.descender) {
maximumDescender = font.descender;
}
}];

// Account for stroke width in baseline calculation
__block CGFloat strokeWidth = 0;
[attributedText enumerateAttribute:@"RCTTextStrokeWidth"
inRange:NSMakeRange(0, attributedText.length)
options:0
usingBlock:^(id value, NSRange range, BOOL *stop) {
if (value && [value isKindOfClass:[NSNumber class]]) {
CGFloat width = [value floatValue];
if (width > 0) {
strokeWidth = MAX(strokeWidth, width);
*stop = YES;
}
}
}];

return size.height + maximumDescender;
return size.height + maximumDescender + strokeWidth;
}

static YGSize RCTTextShadowViewMeasure(
Expand Down Expand Up @@ -441,6 +456,27 @@ static YGSize RCTTextShadowViewMeasure(
size.width -= letterSpacing;
}

// Account for text stroke width (similar to Android implementation)
// Check if text has custom stroke attribute and add extra space
__block CGFloat strokeWidth = 0;
[textStorage enumerateAttribute:@"RCTTextStrokeWidth"
inRange:NSMakeRange(0, textStorage.length)
options:0
usingBlock:^(id value, NSRange range, BOOL *stop) {
if (value && [value isKindOfClass:[NSNumber class]]) {
CGFloat width = [value floatValue];
if (width > 0) {
strokeWidth = MAX(strokeWidth, width);
*stop = YES;
}
}
}];

if (strokeWidth > 0) {
size.width += strokeWidth;
size.height += strokeWidth;
}

size = (CGSize){
MIN(RCTCeilPixelValue(size.width), maximumSize.width), MIN(RCTCeilPixelValue(size.height), maximumSize.height)};

Expand Down
83 changes: 81 additions & 2 deletions packages/react-native/Libraries/Text/Text/RCTTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#import <React/RCTTextView.h>

#import <CoreText/CoreText.h>
#import <MobileCoreServices/UTCoreTypes.h>

#import <React/RCTUtils.h>
Expand Down Expand Up @@ -119,10 +120,88 @@ - (void)drawRect:(CGRect)rect

NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin];
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];

__block UIBezierPath *highlightPath = nil;
// Check if text has custom stroke attribute
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
__block BOOL hasStroke = NO;
__block CGFloat strokeWidth = 0;
__block UIColor *strokeColor = nil;

[_textStorage enumerateAttribute:@"RCTTextStrokeWidth"
inRange:characterRange
options:0
usingBlock:^(id value, NSRange range, BOOL *stop) {
if (value && [value isKindOfClass:[NSNumber class]]) {
CGFloat width = [value floatValue];
if (width > 0) {
hasStroke = YES;
strokeWidth = width;
strokeColor = [_textStorage attribute:@"RCTTextStrokeColor" atIndex:range.location effectiveRange:NULL];

if (strokeColor) {
CGFloat r, g, b, a;
[strokeColor getRed:&r green:&g blue:&b alpha:&a];
}
*stop = YES;
}
}
}];

if (hasStroke && strokeColor) {
CGContextRef context = UIGraphicsGetCurrentContext();

CGContextSetLineWidth(context, strokeWidth);
CGContextSetLineJoin(context, kCGLineJoinRound);
CGContextSetLineCap(context, kCGLineCapRound);

CGFloat strokeInset = strokeWidth / 2;

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

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

CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset);
CGContextScaleCTM(context, 1.0, -1.0);

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height));
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
CTFrameDraw(frame, context);
CFRelease(frame);
CFRelease(path);
CFRelease(framesetter);
CGContextRestoreGState(context);

// PASS 2: Draw fill on top
CGContextSaveGState(context);
CGContextSetTextDrawingMode(context, kCGTextFill);

CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset);
CGContextScaleCTM(context, 1.0, -1.0);

framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)_textStorage);
path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height));
frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
CTFrameDraw(frame, context);
CFRelease(frame);
CFRelease(path);
CFRelease(framesetter);
CGContextRestoreGState(context);

} else {
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];
}

__block UIBezierPath *highlightPath = nil;
[_textStorage
enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName
inRange:characterRange
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native/Libraries/Text/TextNativeComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const textViewConfig = {
android_hyphenationFrequency: true,
lineBreakStrategyIOS: true,
gradientColors: true,
gradientAngle: true,
textStrokeWidth: true,
textStrokeColor: true,
},
directEventTypes: {
topTextLayout: {
Expand All @@ -63,6 +66,9 @@ const virtualTextViewConfig = {
isPressable: true,
maxFontSizeMultiplier: true,
gradientColors: true,
gradientAngle: true,
textStrokeWidth: true,
textStrokeColor: true,
},
uiViewClassName: 'RCTVirtualText',
};
Expand Down
Loading
Loading