diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index 4b1763143fc86f..ad5fe5ccb65e92 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -206,6 +206,8 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { textShadowColor: colorAttributes, textShadowOffset: true, textShadowRadius: true, + textStrokeColor: colorAttributes, + textStrokeWidth: true, textTransform: true, userSelect: true, verticalAlign: true, diff --git a/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm b/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm index 6e88d6f922799a..67c4d674396e8a 100644 --- a/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm +++ b/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm @@ -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) @@ -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) diff --git a/packages/react-native/Libraries/Text/RCTTextAttributes.h b/packages/react-native/Libraries/Text/RCTTextAttributes.h index ad48554f36f31d..b087336ebd91d8 100644 --- a/packages/react-native/Libraries/Text/RCTTextAttributes.h +++ b/packages/react-native/Libraries/Text/RCTTextAttributes.h @@ -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; @@ -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; diff --git a/packages/react-native/Libraries/Text/RCTTextAttributes.mm b/packages/react-native/Libraries/Text/RCTTextAttributes.mm index acd157939436b2..5494e15c374100 100644 --- a/packages/react-native/Libraries/Text/RCTTextAttributes.mm +++ b/packages/react-native/Libraries/Text/RCTTextAttributes.mm @@ -32,6 +32,8 @@ - (instancetype)init _textShadowRadius = NAN; _opacity = NAN; _textTransform = RCTTextTransformUndefined; + _textStrokeWidth = NAN; + _gradientAngle = NAN; } return self; @@ -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; @@ -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; @@ -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; @@ -303,7 +319,7 @@ - (UIColor *)effectiveForegroundColor [cgColors addObject:(id)color.CGColor]; } } - + if([cgColors count] > 0) { [cgColors addObject:cgColors[0]]; CAGradientLayer *gradient = [CAGradientLayer layer]; @@ -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()]; @@ -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) && @@ -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); diff --git a/packages/react-native/Libraries/Text/Text.d.ts b/packages/react-native/Libraries/Text/Text.d.ts index c979aa8728fd76..06d0eef5e40f3c 100644 --- a/packages/react-native/Libraries/Text/Text.d.ts +++ b/packages/react-native/Libraries/Text/Text.d.ts @@ -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; } /** diff --git a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm index 6c815d267eefcd..0b34a8629bd257 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm @@ -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( @@ -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)}; diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index 47632aa885cdb3..0d92ec3dee0ff1 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -7,6 +7,7 @@ #import +#import #import #import @@ -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 diff --git a/packages/react-native/Libraries/Text/TextNativeComponent.js b/packages/react-native/Libraries/Text/TextNativeComponent.js index 2cab7c40a26cdc..57f12efd7d7354 100644 --- a/packages/react-native/Libraries/Text/TextNativeComponent.js +++ b/packages/react-native/Libraries/Text/TextNativeComponent.js @@ -48,6 +48,9 @@ const textViewConfig = { android_hyphenationFrequency: true, lineBreakStrategyIOS: true, gradientColors: true, + gradientAngle: true, + textStrokeWidth: true, + textStrokeColor: true, }, directEventTypes: { topTextLayout: { @@ -63,6 +66,9 @@ const virtualTextViewConfig = { isPressable: true, maxFontSizeMultiplier: true, gradientColors: true, + gradientAngle: true, + textStrokeWidth: true, + textStrokeColor: true, }, uiViewClassName: 'RCTVirtualText', }; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index 19fb9374527c0e..bf75eb4ecbd284 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -49,6 +49,7 @@ import com.facebook.react.views.text.internal.span.ReactUnderlineSpan; import com.facebook.react.views.text.internal.span.SetSpanOperation; import com.facebook.react.views.text.internal.span.ShadowStyleSpan; +import com.facebook.react.views.text.internal.span.StrokeStyleSpan; import com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaDirection; @@ -175,7 +176,8 @@ private static void buildSpannedFromShadowNode( } if (textShadowNode.mGradientColors != null && textShadowNode.mGradientColors.length >= 2) { int effectiveFontSize = textAttributes.getEffectiveFontSize(); - ops.add(new SetSpanOperation(start, end, new LinearGradientSpan(start * effectiveFontSize, textShadowNode.mGradientColors))); + float gradientAngle = Float.isNaN(textShadowNode.mGradientAngle) ? 0f : textShadowNode.mGradientAngle; + ops.add(new SetSpanOperation(start, end, new LinearGradientSpan(start * effectiveFontSize, textShadowNode.mGradientColors, gradientAngle))); } if (textShadowNode.mIsBackgroundColorSet) { ops.add( @@ -238,6 +240,17 @@ private static void buildSpannedFromShadowNode( textShadowNode.mTextShadowRadius, textShadowNode.mTextShadowColor))); } + if (!Float.isNaN(textShadowNode.mTextStrokeWidth) + && textShadowNode.mTextStrokeWidth > 0 + && textShadowNode.mIsTextStrokeColorSet) { + ops.add( + new SetSpanOperation( + start, + end, + new StrokeStyleSpan( + textShadowNode.mTextStrokeWidth, + textShadowNode.mTextStrokeColor))); + } float effectiveLineHeight = textAttributes.getEffectiveLineHeight(); if (!Float.isNaN(effectiveLineHeight) && (parentTextAttributes == null @@ -337,6 +350,7 @@ protected Spannable spannedFromShadowNode( protected int mBackgroundColor; protected @Nullable int[] mGradientColors = null; + protected float mGradientAngle = Float.NaN; protected @Nullable AccessibilityRole mAccessibilityRole = null; protected @Nullable Role mRole = null; @@ -353,6 +367,10 @@ protected Spannable spannedFromShadowNode( protected float mTextShadowRadius = 0; protected int mTextShadowColor = DEFAULT_TEXT_SHADOW_COLOR; + protected float mTextStrokeWidth = Float.NaN; + protected boolean mIsTextStrokeColorSet = false; + protected int mTextStrokeColor; + protected boolean mIsUnderlineTextDecorationSet = false; protected boolean mIsLineThroughTextDecorationSet = false; protected boolean mIncludeFontPadding = true; @@ -523,6 +541,12 @@ public void setGradientColors(@Nullable ReadableArray gradientColors) { } } + @ReactProp(name = "gradientAngle", defaultFloat = Float.NaN) + public void setGradientAngle(float gradientAngle) { + mGradientAngle = gradientAngle; + markUpdated(); + } + @ReactProp(name = ViewProps.BACKGROUND_COLOR, customType = "Color") public void setBackgroundColor(@Nullable Integer color) { // Background color needs to be handled here for virtual nodes so it can be incorporated into @@ -662,6 +686,23 @@ public void setTextShadowColor(int textShadowColor) { } } + @ReactProp(name = "textStrokeWidth", defaultFloat = Float.NaN) + public void setTextStrokeWidth(float textStrokeWidth) { + if (textStrokeWidth != mTextStrokeWidth) { + mTextStrokeWidth = textStrokeWidth; + markUpdated(); + } + } + + @ReactProp(name = "textStrokeColor", customType = "Color") + public void setTextStrokeColor(int textStrokeColor) { + if (textStrokeColor != mTextStrokeColor) { + mTextStrokeColor = textStrokeColor; + mIsTextStrokeColorSet = true; + markUpdated(); + } + } + @ReactProp(name = PROP_TEXT_TRANSFORM) public void setTextTransform(@Nullable String textTransform) { TextTransform textTransformEnum = TextTransform.UNSET; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java index bacc96b5d0b757..fee5ecaff10661 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java @@ -66,6 +66,9 @@ public class TextAttributeProps { public static final short TA_KEY_TEXT_TRANSFORM = 27; public static final short TA_KEY_MAX_FONT_SIZE_MULTIPLIER = 29; public static final short TA_KEY_GRADIENT_COLORS = 30; + public static final short TA_KEY_TEXT_STROKE_WIDTH = 31; + public static final short TA_KEY_TEXT_STROKE_COLOR = 32; + public static final short TA_KEY_GRADIENT_ANGLE = 33; public static final int UNSET = -1; @@ -109,6 +112,10 @@ public class TextAttributeProps { protected float mTextShadowRadius = 0; protected int mTextShadowColor = DEFAULT_TEXT_SHADOW_COLOR; + protected float mTextStrokeWidth = Float.NaN; + protected boolean mIsTextStrokeColorSet = false; + protected int mTextStrokeColor; + protected boolean mIsUnderlineTextDecorationSet = false; protected boolean mIsLineThroughTextDecorationSet = false; protected boolean mIncludeFontPadding = true; @@ -149,8 +156,9 @@ public class TextAttributeProps { protected boolean mContainsImages = false; protected float mHeightOfTallestInlineImage = Float.NaN; - + protected @Nullable int[] mGradientColors = null; + protected float mGradientAngle = Float.NaN; private TextAttributeProps() {} @@ -221,6 +229,12 @@ public static TextAttributeProps fromMapBuffer(MapBuffer props) { case TA_KEY_TEXT_SHADOW_OFFSET_DY: result.setTextShadowOffsetDy((float) entry.getDoubleValue()); break; + case TA_KEY_TEXT_STROKE_WIDTH: + result.setTextStrokeWidth((float) entry.getDoubleValue()); + break; + case TA_KEY_TEXT_STROKE_COLOR: + result.setTextStrokeColor(entry.getIntValue()); + break; case TA_KEY_IS_HIGHLIGHTED: break; case TA_KEY_LAYOUT_DIRECTION: @@ -238,6 +252,9 @@ public static TextAttributeProps fromMapBuffer(MapBuffer props) { case TA_KEY_GRADIENT_COLORS: result.setGradientColors(entry.getMapBufferValue()); break; + case TA_KEY_GRADIENT_ANGLE: + result.setGradientAngle((float) entry.getDoubleValue()); + break; case TA_KEY_MAX_FONT_SIZE_MULTIPLIER: result.setMaxFontSizeMultiplier((float) entry.getDoubleValue()); break; @@ -285,6 +302,11 @@ public static TextAttributeProps fromReadableMap(ReactStylesDiffMap props) { result.setAccessibilityRole(getStringProp(props, ViewProps.ACCESSIBILITY_ROLE)); result.setRole(getStringProp(props, ViewProps.ROLE)); result.setGradientColors(getArrayProp(props, "gradientColors")); + result.setGradientAngle(getFloatProp(props, "gradientAngle", Float.NaN)); + result.setTextStrokeWidth(getFloatProp(props, "textStrokeWidth", Float.NaN)); + if (props.hasKey("textStrokeColor")) { + result.setTextStrokeColor(props.getInt("textStrokeColor", 0)); + } return result; } @@ -794,6 +816,14 @@ private void setGradientColorsFromList(ArrayList colors) { return mGradientColors; } + public float getGradientAngle() { + return mGradientAngle; + } + + private void setGradientAngle(float gradientAngle) { + mGradientAngle = gradientAngle; + } + public static int getTextBreakStrategy(@Nullable String textBreakStrategy) { int androidTextBreakStrategy = DEFAULT_BREAK_STRATEGY; if (textBreakStrategy != null) { @@ -849,4 +879,27 @@ public static int getHyphenationFrequency(@Nullable String hyphenationFrequency) } return truncateAt; } + + public float getTextStrokeWidth() { + return mTextStrokeWidth; + } + + private void setTextStrokeWidth(float textStrokeWidth) { + mTextStrokeWidth = textStrokeWidth; + } + + public int getTextStrokeColor() { + return mTextStrokeColor; + } + + public boolean isTextStrokeColorSet() { + return mIsTextStrokeColorSet; + } + + private void setTextStrokeColor(int textStrokeColor) { + if (textStrokeColor != mTextStrokeColor) { + mTextStrokeColor = textStrokeColor; + mIsTextStrokeColorSet = true; + } + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 1855a613fa47f8..d2df5400b9a4be 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -50,6 +50,7 @@ import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan import com.facebook.react.views.text.internal.span.ReactUnderlineSpan import com.facebook.react.views.text.internal.span.SetSpanOperation import com.facebook.react.views.text.internal.span.ShadowStyleSpan +import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput @@ -257,11 +258,12 @@ internal object TextLayoutManager { } if (textAttributes.gradientColors != null && textAttributes.gradientColors!!.size >= 2) { val effectiveFontSize = textAttributes.effectiveFontSize + val gradientAngle = if (textAttributes.gradientAngle.isNaN()) 0f else textAttributes.gradientAngle ops.add( SetSpanOperation( start, end, - LinearGradientSpan(start * effectiveFontSize.toFloat(), textAttributes.gradientColors!!))) + LinearGradientSpan(start * effectiveFontSize.toFloat(), textAttributes.gradientColors!!, gradientAngle))) } if (textAttributes.mIsBackgroundColorSet) { ops.add( @@ -310,6 +312,17 @@ internal object TextLayoutManager { textAttributes.mTextShadowRadius, textAttributes.mTextShadowColor))) } + if (!textAttributes.textStrokeWidth.isNaN() && + textAttributes.textStrokeWidth > 0 && + textAttributes.isTextStrokeColorSet) { + val strokeWidth = textAttributes.textStrokeWidth + val strokeColor = textAttributes.textStrokeColor + ops.add( + SetSpanOperation( + start, + end, + StrokeStyleSpan(strokeWidth, strokeColor))) + } if (!textAttributes.effectiveLineHeight.isNaN()) { ops.add( SetSpanOperation( @@ -404,8 +417,9 @@ internal object TextLayoutManager { if (fragment.props.gradientColors != null && fragment.props.gradientColors!!.size >= 2) { val effectiveFontSize = fragment.props.effectiveFontSize + val gradientAngle = if (fragment.props.gradientAngle.isNaN()) 0f else fragment.props.gradientAngle spannable.setSpan( - LinearGradientSpan(start * effectiveFontSize.toFloat(), fragment.props.gradientColors!!), + LinearGradientSpan(start * effectiveFontSize.toFloat(), fragment.props.gradientColors!!, gradientAngle), start, end, spanFlags) @@ -466,6 +480,17 @@ internal object TextLayoutManager { spanFlags) } + if (!fragment.props.textStrokeWidth.isNaN() && + fragment.props.textStrokeWidth > 0 && + fragment.props.isTextStrokeColorSet) { + System.out.println("[TextLayoutManager] NEW ARCH - Adding StrokeStyleSpan: width=${fragment.props.textStrokeWidth}, color=${Integer.toHexString(fragment.props.textStrokeColor)}, start=$start, end=$end") + spannable.setSpan( + StrokeStyleSpan(fragment.props.textStrokeWidth, fragment.props.textStrokeColor), + start, + end, + spanFlags) + } + if (!fragment.props.effectiveLineHeight.isNaN()) { spannable.setSpan( CustomLineHeightSpan(fragment.props.effectiveLineHeight), start, end, spanFlags) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt index 8f4a4753572c54..dc2477d49d5129 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt @@ -9,18 +9,33 @@ import android.text.style.UpdateAppearance public class LinearGradientSpan( private val start: Float, private val colors: IntArray, + private val angle: Float = 0f, ) : CharacterStyle(), ReactSpan, UpdateAppearance { public override fun updateDrawState(tp: TextPaint) { // without setting the paint color, the gradient appears "faded" if no foreground color span is also applied // https://stackoverflow.com/a/52289927 tp.setColor(colors[0]) + + val radians = Math.toRadians(angle.toDouble()) + val width = 150.0f + val height = tp.textSize + + val centerX = start + width / 2 + val centerY = height / 2 + val length = Math.sqrt((width * width + height * height).toDouble()).toFloat() / 2 + + val startX = centerX - length * Math.cos(radians).toFloat() + val startY = centerY - length * Math.sin(radians).toFloat() + val endX = centerX + length * Math.cos(radians).toFloat() + val endY = centerY + length * Math.sin(radians).toFloat() + val textShader: Shader = LinearGradient( - start, - 0f, - start + 150.0f, - 0f, + startX, + startY, + endX, + endY, colors, null, Shader.TileMode.MIRROR, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt new file mode 100644 index 00000000000000..574038a1728d41 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -0,0 +1,86 @@ +package com.facebook.react.views.text.internal.span + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan + +/** + * A span that applies text stroke styling with two-pass rendering. + * First draws stroke, then draws fill on top to create outer stroke effect. + */ +public class StrokeStyleSpan( + private val strokeWidth: Float, + private val strokeColor: Int +) : ReplacementSpan(), ReactSpan { + + public override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + val width = paint.measureText(text, start, end) + + if (fm != null) { + paint.getFontMetricsInt(fm) + val halfStroke = (strokeWidth / 2).toInt() + fm.top -= halfStroke + fm.ascent -= halfStroke + fm.descent += halfStroke + fm.bottom += halfStroke + } + + return width.toInt() + } + + public override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + if (text == null) return + + val textToDraw = text.subSequence(start, end).toString() + val strokeInset = strokeWidth / 2 + + // Store original paint settings + val originalStyle = paint.style + val originalColor = paint.color + val originalStrokeWidth = paint.strokeWidth + val originalStrokeJoin = paint.strokeJoin + val originalStrokeCap = paint.strokeCap + + // First pass: Draw stroke only (solid color) + paint.style = Paint.Style.STROKE + paint.strokeWidth = strokeWidth + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + paint.color = strokeColor + canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) + + // Second pass: Draw fill on top + paint.style = Paint.Style.FILL + paint.color = originalColor + if (text is android.text.Spanned && paint is android.text.TextPaint) { + val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) + for (span in spans) { + span.updateDrawState(paint) + } + } + canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) + + // Restore original paint settings + paint.style = originalStyle + paint.color = originalColor + paint.strokeWidth = originalStrokeWidth + paint.strokeJoin = originalStrokeJoin + paint.strokeCap = originalStrokeCap + } +} diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp index 47d5a25a1fdf17..b2b39a4275ea0a 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp @@ -30,6 +30,9 @@ void TextAttributes::apply(TextAttributes textAttributes) { gradientColors = textAttributes.gradientColors.has_value() ? textAttributes.gradientColors : gradientColors; + gradientAngle = !std::isnan(textAttributes.gradientAngle) + ? textAttributes.gradientAngle + : gradientAngle; // Font fontFamily = !textAttributes.fontFamily.empty() ? textAttributes.fontFamily @@ -100,6 +103,14 @@ void TextAttributes::apply(TextAttributes textAttributes) { ? textAttributes.textShadowColor : textShadowColor; + // Stroke + textStrokeWidth = !std::isnan(textAttributes.textStrokeWidth) + ? textAttributes.textStrokeWidth + : textStrokeWidth; + textStrokeColor = textAttributes.textStrokeColor + ? textAttributes.textStrokeColor + : textStrokeColor; + // Special isHighlighted = textAttributes.isHighlighted.has_value() ? textAttributes.isHighlighted @@ -139,6 +150,7 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { textDecorationStyle, textShadowOffset, textShadowColor, + textStrokeColor, isHighlighted, isPressable, layoutDirection, @@ -162,6 +174,7 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { rhs.textDecorationStyle, rhs.textShadowOffset, rhs.textShadowColor, + rhs.textStrokeColor, rhs.isHighlighted, rhs.isPressable, rhs.layoutDirection, @@ -175,6 +188,8 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { floatEquality(letterSpacing, rhs.letterSpacing) && floatEquality(lineHeight, rhs.lineHeight) && floatEquality(textShadowRadius, rhs.textShadowRadius) && + floatEquality(textStrokeWidth, rhs.textStrokeWidth) && + floatEquality(gradientAngle, rhs.gradientAngle) && gradientColors == rhs.gradientColors; } diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h index 1dad21f636ad1e..18be16ee734d1d 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h @@ -43,6 +43,7 @@ class TextAttributes : public DebugStringConvertible { SharedColor backgroundColor{}; Float opacity{std::numeric_limits::quiet_NaN()}; std::optional> gradientColors{}; + Float gradientAngle{std::numeric_limits::quiet_NaN()}; // Font std::string fontFamily{""}; @@ -75,6 +76,10 @@ class TextAttributes : public DebugStringConvertible { Float textShadowRadius{std::numeric_limits::quiet_NaN()}; SharedColor textShadowColor{}; + // Stroke + Float textStrokeWidth{std::numeric_limits::quiet_NaN()}; + SharedColor textStrokeColor{}; + // Special std::optional isHighlighted{}; std::optional isPressable{}; @@ -135,6 +140,9 @@ struct hash { textAttributes.textShadowOffset, textAttributes.textShadowRadius, textAttributes.textShadowColor, + textAttributes.textStrokeWidth, + textAttributes.textStrokeColor, + textAttributes.gradientAngle, textAttributes.isHighlighted, textAttributes.isPressable, textAttributes.layoutDirection, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h index de0d4d977d0807..87715523dd7700 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1056,6 +1056,9 @@ constexpr static MapBuffer::Key TA_KEY_TEXT_TRANSFORM = 27; constexpr static MapBuffer::Key TA_KEY_ALIGNMENT_VERTICAL = 28; constexpr static MapBuffer::Key TA_KEY_MAX_FONT_SIZE_MULTIPLIER = 29; constexpr static MapBuffer::Key TA_KEY_GRADIENT_COLORS = 30; +constexpr static MapBuffer::Key TA_KEY_TEXT_STROKE_WIDTH = 31; +constexpr static MapBuffer::Key TA_KEY_TEXT_STROKE_COLOR = 32; +constexpr static MapBuffer::Key TA_KEY_GRADIENT_ANGLE = 33; // constants for ParagraphAttributes serialization constexpr static MapBuffer::Key PA_KEY_MAX_NUMBER_OF_LINES = 0; @@ -1136,6 +1139,9 @@ inline MapBuffer toMapBuffer(const TextAttributes& textAttributes) { } builder.putMapBuffer(TA_KEY_GRADIENT_COLORS, gradientColorsBuilder.build()); } + if (!std::isnan(textAttributes.gradientAngle)) { + builder.putDouble(TA_KEY_GRADIENT_ANGLE, textAttributes.gradientAngle); + } if (!std::isnan(textAttributes.opacity)) { builder.putDouble(TA_KEY_OPACITY, textAttributes.opacity); } @@ -1224,6 +1230,16 @@ inline MapBuffer toMapBuffer(const TextAttributes& textAttributes) { builder.putDouble( TA_KEY_TEXT_SHADOW_OFFSET_DY, textAttributes.textShadowOffset->height); } + // Stroke + if (!std::isnan(textAttributes.textStrokeWidth)) { + builder.putDouble( + TA_KEY_TEXT_STROKE_WIDTH, textAttributes.textStrokeWidth); + } + if (textAttributes.textStrokeColor) { + builder.putInt( + TA_KEY_TEXT_STROKE_COLOR, + toAndroidRepr(textAttributes.textStrokeColor)); + } // Special if (textAttributes.isHighlighted.has_value()) { builder.putBool(TA_KEY_IS_HIGHLIGHTED, *textAttributes.isHighlighted); diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp index 4dc23abc4280e4..11e6fb44bf8ef7 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp @@ -36,6 +36,12 @@ static TextAttributes convertRawProp( "gradientColors", sourceTextAttributes.gradientColors, defaultTextAttributes.gradientColors); + textAttributes.gradientAngle = convertRawProp( + context, + rawProps, + "gradientAngle", + sourceTextAttributes.gradientAngle, + defaultTextAttributes.gradientAngle); // Font textAttributes.fontFamily = convertRawProp( @@ -177,6 +183,20 @@ static TextAttributes convertRawProp( sourceTextAttributes.textShadowColor, defaultTextAttributes.textShadowColor); + // Stroke + textAttributes.textStrokeWidth = convertRawProp( + context, + rawProps, + "textStrokeWidth", + sourceTextAttributes.textStrokeWidth, + defaultTextAttributes.textStrokeWidth); + textAttributes.textStrokeColor = convertRawProp( + context, + rawProps, + "textStrokeColor", + sourceTextAttributes.textStrokeColor, + defaultTextAttributes.textStrokeColor); + // Special textAttributes.isHighlighted = convertRawProp( context, @@ -257,6 +277,8 @@ void BaseTextProps::setProp( defaults, value, textAttributes, foregroundColor, "color"); REBUILD_FIELD_SWITCH_CASE( defaults, value, textAttributes, gradientColors, "gradientColors"); + REBUILD_FIELD_SWITCH_CASE( + defaults, value, textAttributes, gradientAngle, "gradientAngle"); REBUILD_FIELD_SWITCH_CASE( defaults, value, textAttributes, fontFamily, "fontFamily"); REBUILD_FIELD_SWITCH_CASE(