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
4 changes: 2 additions & 2 deletions packages/react-native/Libraries/Text/Text.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -222,7 +222,7 @@ export interface TextProps
/**
* Adds a horizontal gradient using the int based color values.
*/
gradientColors?: number[] | undefined;
gradientColors?: Array<number | OpaqueColorValue> | undefined;

/**
* Gradient angle in degrees. Default is 0 (horizontal).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ struct hash<facebook::react::TextAttributes> {
textAttributes.textShadowColor,
textAttributes.textStrokeWidth,
textAttributes.textStrokeColor,
textAttributes.gradientColors,
textAttributes.gradientAngle,
textAttributes.gradientWidth,
textAttributes.gradientMode,
Expand Down
16 changes: 16 additions & 0 deletions packages/react-native/ReactCommon/react/renderer/graphics/Color.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

#include <functional>
#include <string>
#include <vector>

#include <react/utils/hash_combine.h>

#ifdef RN_SERIALIZABLE_STATE
#include <folly/dynamic.h>
Expand Down Expand Up @@ -94,3 +97,16 @@ struct std::hash<facebook::react::SharedColor> {
return std::hash<facebook::react::Color>{}(*color);
}
};

// This is only used in Discord specific code, in Text gradient feature
template <>
struct std::hash<std::vector<facebook::react::SharedColor>> {
size_t operator()(
const std::vector<facebook::react::SharedColor>& colors) const {
size_t seed = 0;
for (const auto& color : colors) {
facebook::react::hash_combine(seed, color);
}
return seed;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#import "RCTTextLayoutManager.h"

#import "RCTAttributedTextUtils.h"
#import <CoreText/CoreText.h>

#import <React/NSTextStorage+FontScaling.h>
#import <React/RCTUtils.h>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -79,26 +101,96 @@ - (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];

[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
Expand Down Expand Up @@ -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};

Expand Down