diff --git a/packages/react-native/Libraries/Text/Text.d.ts b/packages/react-native/Libraries/Text/Text.d.ts index 58dc18a239bb..0ae2466474b9 100644 --- a/packages/react-native/Libraries/Text/Text.d.ts +++ b/packages/react-native/Libraries/Text/Text.d.ts @@ -11,7 +11,7 @@ import type * as React from 'react'; import {Constructor} from '../../types/private/Utilities'; import {AccessibilityProps} from '../Components/View/ViewAccessibility'; import {HostInstance} from '../../types/public/ReactNativeTypes'; -import {ColorValue, StyleProp} from '../StyleSheet/StyleSheet'; +import {ColorValue, OpaqueColorValue, StyleProp} from '../StyleSheet/StyleSheet'; import {TextStyle, ViewStyle} from '../StyleSheet/StyleSheetTypes'; import { GestureResponderEvent, @@ -222,7 +222,7 @@ export interface TextProps /** * Adds a horizontal gradient using the int based color values. */ - gradientColors?: number[] | undefined; + gradientColors?: Array | undefined; /** * Gradient angle in degrees. Default is 0 (horizontal). diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 07fb9c4e7b67..328f33e2b584 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -409,6 +409,7 @@ - (void)drawRect:(CGRect)rect [nativeTextLayoutManager drawAttributedString:stateData.attributedString paragraphAttributes:_paragraphAttributes frame:frame + viewBounds:self.bounds drawHighlightPath:^(UIBezierPath *highlightPath) { if (highlightPath) { if (!self->_highlightLayer) { diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h index 9363a3d36529..49095da95c87 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h @@ -146,6 +146,7 @@ struct hash { textAttributes.textShadowColor, textAttributes.textStrokeWidth, textAttributes.textStrokeColor, + textAttributes.gradientColors, textAttributes.gradientAngle, textAttributes.gradientWidth, textAttributes.gradientMode, diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/Color.h b/packages/react-native/ReactCommon/react/renderer/graphics/Color.h index 16905f62a3d2..33619572f5cf 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/Color.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/Color.h @@ -12,6 +12,9 @@ #include #include +#include + +#include #ifdef RN_SERIALIZABLE_STATE #include @@ -94,3 +97,16 @@ struct std::hash { return std::hash{}(*color); } }; + +// This is only used in Discord specific code, in Text gradient feature +template <> +struct std::hash> { + size_t operator()( + const std::vector& colors) const { + size_t seed = 0; + for (const auto& color : colors) { + facebook::react::hash_combine(seed, color); + } + return seed; + } +}; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index f96a0494000b..4d1bd444d42b 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -145,6 +145,55 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex { UIColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [UIColor blackColor]; + if (textAttributes.gradientColors.has_value() && !textAttributes.gradientColors->empty()) { + NSMutableArray *cgColors = [NSMutableArray array]; + for (const auto &sharedColor : *textAttributes.gradientColors) { + UIColor *color = RCTUIColorFromSharedColor(sharedColor); + if (color != nil) { + [cgColors addObject:(id)color.CGColor]; + } + } + + if ([cgColors count] > 0) { + BOOL isClampMode = textAttributes.gradientMode.value_or("") == "clamp"; + if (!isClampMode) { + // Mirror mode (default) duplicates the first color at the end. + [cgColors addObject:cgColors[0]]; + } + + CAGradientLayer *gradient = [CAGradientLayer layer]; + CGFloat patternWidth = + (!isnan(textAttributes.gradientWidth) && textAttributes.gradientWidth > 0) ? textAttributes.gradientWidth : 100; + CGFloat lineHeight = !isnan(textAttributes.lineHeight) + ? textAttributes.lineHeight + : (!isnan(textAttributes.fontSize) ? textAttributes.fontSize : 14); + CGFloat height = lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); + gradient.frame = CGRectMake(0, 0, patternWidth, height); + gradient.colors = cgColors; + + CGFloat angle = !isnan(textAttributes.gradientAngle) ? textAttributes.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); + + UIGraphicsImageRenderer *renderer = + [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; + UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + [gradient renderInContext:rendererContext.CGContext]; + }]; + + if (gradientImage) { + effectiveForegroundColor = [UIColor colorWithPatternImage:gradientImage]; + } + } + } + if (!isnan(textAttributes.opacity)) { effectiveForegroundColor = [effectiveForegroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveForegroundColor.CGColor) * textAttributes.opacity]; @@ -179,7 +228,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex // Colors UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes); - if (textAttributes.foregroundColor || !isnan(textAttributes.opacity)) { + if (textAttributes.foregroundColor || !isnan(textAttributes.opacity) || + textAttributes.gradientColors.has_value()) { attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; } @@ -281,6 +331,14 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex 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(textAttributes.textStrokeWidth) && textAttributes.textStrokeWidth > 0) { + UIColor *strokeColorToUse = RCTUIColorFromSharedColor(textAttributes.textStrokeColor) ?: effectiveForegroundColor; + attributes[@"RCTTextStrokeWidth"] = @(textAttributes.textStrokeWidth); + attributes[@"RCTTextStrokeColor"] = strokeColorToUse; + } + // Special if (textAttributes.isHighlighted.value_or(false)) { attributes[RCTAttributedStringIsHighlightedAttributeName] = @YES; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index a4b9be72c453..350a4a236b2e 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -40,6 +40,7 @@ using RCTTextLayoutFragmentEnumerationBlock = - (void)drawAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes frame:(CGRect)frame + viewBounds:(CGRect)viewBounds drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block; - (facebook::react::LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedString)attributedString 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..9e6c0b4449d5 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 @@ -8,6 +8,7 @@ #import "RCTTextLayoutManager.h" #import "RCTAttributedTextUtils.h" +#import #import #import @@ -35,6 +36,26 @@ static NSLineBreakMode RCTNSLineBreakModeFromEllipsizeMode(EllipsizeMode ellipsi } } +static CGFloat getStrokeWidth(NSAttributedString *attributedString) +{ + if (attributedString.length == 0) { + return 0; + } + __block CGFloat strokeWidth = 0; + [attributedString enumerateAttribute:@"RCTTextStrokeWidth" + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (value && [value isKindOfClass:[NSNumber class]]) { + CGFloat width = [value floatValue]; + if (width > strokeWidth) { + strokeWidth = width; + } + } + }]; + return strokeWidth; +} + - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext @@ -69,6 +90,7 @@ - (TextMeasurement)measureAttributedString:(AttributedString)attributedString - (void)drawAttributedString:(AttributedString)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes frame:(CGRect)frame + viewBounds:(CGRect)viewBounds drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block { NSTextStorage *textStorage = [self @@ -79,9 +101,9 @@ - (void)drawAttributedString:(AttributedString)attributedString NSTextContainer *textContainer = layoutManager.textContainers.firstObject; #if TARGET_OS_MACCATALYST - CGContextRef context = UIGraphicsGetCurrentContext(); - CGContextSaveGState(context); - CGContextSetShouldSmoothFonts(context, NO); + CGContextRef macCatalystContext = UIGraphicsGetCurrentContext(); + CGContextSaveGState(macCatalystContext); + CGContextSetShouldSmoothFonts(macCatalystContext, NO); #endif NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; @@ -89,16 +111,86 @@ - (void)drawAttributedString:(AttributedString)attributedString [self processTruncatedAttributedText:textStorage textContainer:textContainer layoutManager:layoutManager]; [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:frame.origin]; - [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:frame.origin]; + NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; + CGFloat strokeWidth = getStrokeWidth(textStorage); + __block UIColor *strokeColor = nil; + if (strokeWidth > 0) { + [textStorage enumerateAttribute:@"RCTTextStrokeColor" + inRange:characterRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value isKindOfClass:[UIColor class]]) { + strokeColor = value; + *stop = YES; + } + }]; + } + if (strokeWidth > 0 && strokeColor) { + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetLineWidth(context, strokeWidth); + CGContextSetLineJoin(context, kCGLineJoinRound); + CGContextSetLineCap(context, kCGLineCapRound); + + // Shift glyphs by strokeWidth/2 when there's room so the outward stroke fits; otherwise + // center whatever slack is left. `_measureTextStorage:` grew the returned size by + // strokeWidth, so `frame.size` is already inflated - `frame.size - strokeWidth` recovers + // the natural text size and `viewBounds - frame + strokeWidth` is the slack around it. + CGFloat slackX = viewBounds.size.width - frame.size.width + strokeWidth; + CGFloat slackY = viewBounds.size.height - frame.size.height + strokeWidth; + CGFloat strokeShiftX = + (slackX <= 0) ? 0 : (slackX >= strokeWidth ? strokeWidth / 2.0 : slackX / 2.0); + CGFloat strokeShiftY = + (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); + + // 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, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); + CGContextScaleCTM(context, 1.0, -1.0); + + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText); + CGMutablePathRef path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); + CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(ctFrame, context); + CFRelease(ctFrame); + CFRelease(path); + CFRelease(framesetter); + CGContextRestoreGState(context); + + // PASS 2: Draw fill on top + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, kCGTextFill); + + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM( + context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); + CGContextScaleCTM(context, 1.0, -1.0); + + framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage); + path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); + ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(ctFrame, context); + CFRelease(ctFrame); + CFRelease(path); + CFRelease(framesetter); + CGContextRestoreGState(context); + } else { + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:frame.origin]; + } #if TARGET_OS_MACCATALYST - CGContextRestoreGState(context); + CGContextRestoreGState(macCatalystContext); #endif - if (block != nil) { __block UIBezierPath *highlightPath = nil; - NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; - [textStorage enumerateAttribute:RCTAttributedStringIsHighlightedAttributeName inRange:characterRange @@ -389,6 +481,13 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage size.height = enumeratedLinesHeight; } + // Reserve space for the outward stroke on both axes so it isn't clipped + CGFloat strokeWidth = getStrokeWidth(textStorage); + if (strokeWidth > 0) { + size.width += strokeWidth; + size.height += strokeWidth; + } + size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor, ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};