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
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,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 @@ -547,6 +547,8 @@ export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle {
textShadowOffset?: {width: number; height: number} | undefined;
textShadowRadius?: number | undefined;
textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase' | undefined;
textStrokeWidth?: number | undefined;
textStrokeColor?: ColorValue | undefined;
userSelect?: 'auto' | 'none' | 'text' | 'contain' | 'all' | undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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
38 changes: 35 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,16 @@ - (NSParagraphStyle *)effectiveParagraphStyle
attributes[NSShadowAttributeName] = shadow;
}

// Stroke - Store as custom attributes for manual rendering in RCTTextView
// 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;
NSLog(@"[RCTTextAttributes] Setting stroke - width: %f, color: %@", _textStrokeWidth, strokeColorToUse);
attributes[@"RCTTextStrokeWidth"] = @(_textStrokeWidth);
attributes[@"RCTTextStrokeColor"] = strokeColorToUse;
}

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

if([cgColors count] > 0) {
[cgColors addObject:cgColors[0]];
CAGradientLayer *gradient = [CAGradientLayer layer];
Expand All @@ -312,8 +329,20 @@ - (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);

// Convert angle to radians and calculate start/end points
// 0 degrees = horizontal (left to right), 90 degrees = vertical (top to bottom)
CGFloat angle = !isnan(_gradientAngle) ? _gradientAngle : 0.0;
CGFloat radians = angle * M_PI / 180.0;

// Calculate start and end points based on angle
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 +426,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 +444,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
5 changes: 5 additions & 0 deletions packages/react-native/Libraries/Text/Text.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ 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;
}

/**
Expand Down
77 changes: 75 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,82 @@ - (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) {
NSLog(@"[RCTTextView] Checking for stroke attribute, value: %@", value);
if (value && [value isKindOfClass:[NSNumber class]]) {
CGFloat width = [value floatValue];
NSLog(@"[RCTTextView] Found width value: %f", width);
if (width > 0) {
hasStroke = YES;
strokeWidth = width;
strokeColor = [_textStorage attribute:@"RCTTextStrokeColor" atIndex:range.location effectiveRange:NULL];
NSLog(@"[RCTTextView] Stroke enabled - width: %f, color: %@", strokeWidth, strokeColor);
if (strokeColor) {
CGFloat r, g, b, a;
[strokeColor getRed:&r green:&g blue:&b alpha:&a];
NSLog(@"[RCTTextView] Stroke color components - R:%.2f G:%.2f B:%.2f A:%.2f", r, g, b, a);
} else {
NSLog(@"[RCTTextView] WARNING: strokeColor is nil!");
}
*stop = YES;
}
}
}];

if (hasStroke && strokeColor) {
NSLog(@"[RCTTextView] Drawing with Core Text stroke - width: %f, color: %@", strokeWidth, strokeColor);

// Use Core Text to draw with stroke
// Core Text respects kCTStrokeColorAttributeName and kCTStrokeWidthAttributeName
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);

// Create a mutable copy of the attributed string with proper stroke attributes
NSMutableAttributedString *strokedText = [_textStorage mutableCopy];

// Add Core Text stroke attributes
// Core Text uses positive values for stroke-only, negative for fill-and-stroke
// We want outer stroke, so we'll use positive width for stroke, then draw fill separately
[strokedText addAttribute:(NSString *)kCTStrokeColorAttributeName
value:(id)strokeColor.CGColor
range:characterRange];
[strokedText addAttribute:(NSString *)kCTStrokeWidthAttributeName
value:@(strokeWidth * 2.0) // Double for outer stroke effect
range:characterRange];

// Create CTLine from attributed string
CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)strokedText);

// Set text position
CGContextSetTextPosition(context, _contentFrame.origin.x, _contentFrame.origin.y);

// Draw the line with stroke
CTLineDraw(line, context);

CFRelease(line);
CGContextRestoreGState(context);
NSLog(@"[RCTTextView] Core Text stroke drawing completed");
} else {
// No stroke, normal rendering
if (!hasStroke) {
NSLog(@"[RCTTextView] No stroke attribute found, rendering normally");
} else if (!strokeColor) {
NSLog(@"[RCTTextView] Stroke width found but color is nil, rendering normally");
}
[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 @@ -49,6 +49,9 @@ const textViewConfig = {
android_hyphenationFrequency: true,
lineBreakStrategyIOS: true,
gradientColors: true,
gradientAngle: true,
textStrokeWidth: true,
textStrokeColor: true,
},
directEventTypes: {
topTextLayout: {
Expand All @@ -67,6 +70,9 @@ const virtualTextViewConfig = {
isPressable: true,
maxFontSizeMultiplier: true,
gradientColors: true,
gradientAngle: true,
textStrokeWidth: true,
textStrokeColor: true,
},
uiViewClassName: 'RCTVirtualText',
};
Expand Down
10 changes: 10 additions & 0 deletions packages/react-native/Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,14 @@ export type TextProps = $ReadOnly<{
* See https://reactnative.dev/docs/text.html#linebreakstrategyios
*/
lineBreakStrategyIOS?: ?('none' | 'standard' | 'hangul-word' | 'push-out'),

/**
* Width of the text stroke.
*/
textStrokeWidth?: ?number,

/**
* Color of the text stroke.
*/
textStrokeColor?: ?ColorValue,
}>;
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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;
Expand Down Expand Up @@ -167,7 +168,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(
Expand Down Expand Up @@ -230,6 +232,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
Expand Down Expand Up @@ -325,6 +338,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;
Expand All @@ -341,6 +355,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;
Expand Down Expand Up @@ -510,6 +528,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
Expand Down Expand Up @@ -649,6 +673,29 @@ public void setTextShadowColor(int textShadowColor) {
}
}

@ReactProp(name = "textStrokeWidth", defaultFloat = Float.NaN)
public void setTextStrokeWidth(float textStrokeWidth) {
System.out.println("========================================");
System.out.println("[STROKE] setTextStrokeWidth: " + textStrokeWidth);
System.out.println("========================================");
if (textStrokeWidth != mTextStrokeWidth) {
mTextStrokeWidth = textStrokeWidth;
markUpdated();
}
}

@ReactProp(name = "textStrokeColor", customType = "Color")
public void setTextStrokeColor(int textStrokeColor) {
System.out.println("========================================");
System.out.println("[STROKE] setTextStrokeColor: " + Integer.toHexString(textStrokeColor));
System.out.println("========================================");
if (textStrokeColor != mTextStrokeColor) {
mTextStrokeColor = textStrokeColor;
mIsTextStrokeColorSet = true;
markUpdated();
}
}

@ReactProp(name = PROP_TEXT_TRANSFORM)
public void setTextTransform(@Nullable String textTransform) {
if (textTransform == null) {
Expand Down
Loading