Skip to content

Commit e517688

Browse files
authored
[task] Setup the stroke effect (#122)
* Setup the text stroke effect commit 6dd0e95 Author: Can Undeger <[email protected]> Date: Wed Nov 19 16:26:35 2025 -0500 Add some logs to android commit 36d4caf Author: Can Undeger <[email protected]> Date: Tue Nov 18 13:03:23 2025 -0500 Upgrade to 0.81.4 commit c5c665d Author: Can Undeger <[email protected]> Date: Tue Nov 18 12:35:50 2025 -0500 Make it so that the stroke as well as gradient are applied commit 62fb685 Author: Can Undeger <[email protected]> Date: Mon Nov 17 11:32:45 2025 -0500 Fix stroke fill color commit da021f8 Author: Can Undeger <[email protected]> Date: Wed Nov 12 18:33:19 2025 -0500 Fix the gradient angle on ios commit 5d32321 Author: Can Undeger <[email protected]> Date: Wed Nov 12 18:00:34 2025 -0500 Add logging for the gradient angle commit ccf1a88 Author: Can Undeger <[email protected]> Date: Wed Nov 12 15:37:18 2025 -0500 Fix ios text stroke once more commit 10d126e Author: Can Undeger <[email protected]> Date: Wed Nov 12 12:42:18 2025 -0500 Attempt to fix stroke on ios commit c5306e6 Author: Can Undeger <[email protected]> Date: Wed Nov 12 12:25:40 2025 -0500 Fix the upside down text issue commit 08b7142 Author: Can Undeger <[email protected]> Date: Mon Nov 10 16:21:04 2025 -0500 Add the gradient angle prop commit cf0d9e2 Author: Can Undeger <[email protected]> Date: Sun Nov 9 15:21:01 2025 -0500 Fix the ios build commit 2638b95 Author: Can Undeger <[email protected]> Date: Sun Nov 9 15:08:04 2025 -0500 FIX COLOR NOT WORKING commit e5e7ec9 Author: Can Undeger <[email protected]> Date: Sun Nov 9 14:30:43 2025 -0500 Update the ios stroke implementation commit e7ade51 Author: Can Undeger <[email protected]> Date: Sun Nov 9 13:25:55 2025 -0500 Add logging for text stroke commit 8a330ba Author: Can Undeger <[email protected]> Date: Sun Nov 9 13:23:11 2025 -0500 Update the text stroke on ios commit 4fa7fec Author: Can Undeger <[email protected]> Date: Sun Nov 9 13:15:22 2025 -0500 Fix stroke color conversion commit 0986441 Author: Can Undeger <[email protected]> Date: Fri Nov 7 11:33:32 2025 -0500 Try to fix the android build commit d88e464 Author: Can Undeger <[email protected]> Date: Wed Nov 5 16:47:27 2025 -0500 Have a proper outer stroke on android commit bb912df Author: Can Undeger <[email protected]> Date: Wed Nov 5 15:54:43 2025 -0500 Another attempt to fix the ios stroke commit ff262dd Author: Can Undeger <[email protected]> Date: Wed Nov 5 15:22:39 2025 -0500 Fix the stroke on ios commit cc05034 Author: Can Undeger <[email protected]> Date: Wed Nov 5 14:26:17 2025 -0500 Fix android and ios commit 95ffe8d Author: Can Undeger <[email protected]> Date: Wed Nov 5 13:22:21 2025 -0500 Fix the android build commit a89930e Author: Can Undeger <[email protected]> Date: Wed Nov 5 13:13:59 2025 -0500 Optimize the stroke effect commit 6cbb176 Author: Can Undeger <[email protected]> Date: Wed Nov 5 12:54:04 2025 -0500 Fix android logging issue commit a67cea7 Author: Can Undeger <[email protected]> Date: Wed Nov 5 12:30:39 2025 -0500 Add some logging for the stroke effect commit e00e481 Author: Can Undeger <[email protected]> Date: Wed Nov 5 11:26:54 2025 -0500 Dont eat into the text with the stroke effect commit 10c3f78 Author: Can Undeger <[email protected]> Date: Tue Nov 4 16:17:07 2025 -0500 LwqCreate the stroke effect for the text component * Fix the text ellipsis issue on android * Clean up the text stroke effect * Fix text container size * Fix double stroke effect accounting * Fix ios text stroke layout size calculation * Fix the container size calculation for text with stroke effect
1 parent b585c65 commit e517688

File tree

17 files changed

+474
-19
lines changed

17 files changed

+474
-19
lines changed

packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
206206
textShadowColor: colorAttributes,
207207
textShadowOffset: true,
208208
textShadowRadius: true,
209+
textStrokeColor: colorAttributes,
210+
textStrokeWidth: true,
209211
textTransform: true,
210212
userSelect: true,
211213
verticalAlign: true,

packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ - (RCTShadowView *)shadowView
2929
RCT_REMAP_SHADOW_PROPERTY(color, textAttributes.foregroundColor, UIColor)
3030
RCT_REMAP_SHADOW_PROPERTY(backgroundColor, textAttributes.backgroundColor, UIColor)
3131
RCT_REMAP_SHADOW_PROPERTY(gradientColors, textAttributes.gradientColors, NSArray)
32+
RCT_REMAP_SHADOW_PROPERTY(gradientAngle, textAttributes.gradientAngle, CGFloat)
3233
RCT_REMAP_SHADOW_PROPERTY(opacity, textAttributes.opacity, CGFloat)
3334
// Font
3435
RCT_REMAP_SHADOW_PROPERTY(fontFamily, textAttributes.fontFamily, NSString)
@@ -54,6 +55,9 @@ - (RCTShadowView *)shadowView
5455
RCT_REMAP_SHADOW_PROPERTY(textShadowOffset, textAttributes.textShadowOffset, CGSize)
5556
RCT_REMAP_SHADOW_PROPERTY(textShadowRadius, textAttributes.textShadowRadius, CGFloat)
5657
RCT_REMAP_SHADOW_PROPERTY(textShadowColor, textAttributes.textShadowColor, UIColor)
58+
// Stroke
59+
RCT_REMAP_SHADOW_PROPERTY(textStrokeWidth, textAttributes.textStrokeWidth, CGFloat)
60+
RCT_REMAP_SHADOW_PROPERTY(textStrokeColor, textAttributes.textStrokeColor, UIColor)
5761
// Special
5862
RCT_REMAP_SHADOW_PROPERTY(isHighlighted, textAttributes.isHighlighted, BOOL)
5963
RCT_REMAP_SHADOW_PROPERTY(textTransform, textAttributes.textTransform, RCTTextTransform)

packages/react-native/Libraries/Text/RCTTextAttributes.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ extern NSString *const RCTTextAttributesTagAttributeName;
2727
@property (nonatomic, strong, nullable) UIColor *foregroundColor;
2828
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
2929
@property (nonatomic, copy, nullable) NSArray *gradientColors;
30+
@property (nonatomic, assign) CGFloat gradientAngle;
3031
@property (nonatomic, assign) CGFloat opacity;
3132
// Font
3233
@property (nonatomic, copy, nullable) NSString *fontFamily;
@@ -53,6 +54,9 @@ extern NSString *const RCTTextAttributesTagAttributeName;
5354
@property (nonatomic, assign) CGSize textShadowOffset;
5455
@property (nonatomic, assign) CGFloat textShadowRadius;
5556
@property (nonatomic, strong, nullable) UIColor *textShadowColor;
57+
// Stroke
58+
@property (nonatomic, assign) CGFloat textStrokeWidth;
59+
@property (nonatomic, strong, nullable) UIColor *textStrokeColor;
5660
// Special
5761
@property (nonatomic, assign) BOOL isHighlighted;
5862
@property (nonatomic, strong, nullable) NSNumber *tag;

packages/react-native/Libraries/Text/RCTTextAttributes.mm

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ - (instancetype)init
3232
_textShadowRadius = NAN;
3333
_opacity = NAN;
3434
_textTransform = RCTTextTransformUndefined;
35+
_textStrokeWidth = NAN;
36+
_gradientAngle = NAN;
3537
}
3638

3739
return self;
@@ -47,6 +49,7 @@ - (void)applyTextAttributes:(RCTTextAttributes *)textAttributes
4749
_foregroundColor = textAttributes->_foregroundColor ?: _foregroundColor;
4850
_backgroundColor = textAttributes->_backgroundColor ?: _backgroundColor;
4951
_gradientColors = textAttributes->_gradientColors ?: _gradientColors;
52+
_gradientAngle = !isnan(textAttributes->_gradientAngle) ? textAttributes->_gradientAngle : _gradientAngle;
5053
_opacity =
5154
!isnan(textAttributes->_opacity) ? (isnan(_opacity) ? 1.0 : _opacity) * textAttributes->_opacity : _opacity;
5255

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

96+
// Stroke
97+
_textStrokeWidth = !isnan(textAttributes->_textStrokeWidth) ? textAttributes->_textStrokeWidth : _textStrokeWidth;
98+
_textStrokeColor = textAttributes->_textStrokeColor ?: _textStrokeColor;
99+
93100
// Special
94101
_isHighlighted = textAttributes->_isHighlighted || _isHighlighted; // *
95102
_tag = textAttributes->_tag ?: _tag;
@@ -210,6 +217,15 @@ - (NSParagraphStyle *)effectiveParagraphStyle
210217
attributes[NSShadowAttributeName] = shadow;
211218
}
212219

220+
// We don't use NSStrokeWidthAttributeName because it centers the stroke on the text path
221+
// Instead, we do custom two-pass rendering to get true outer stroke
222+
if (!isnan(_textStrokeWidth) && _textStrokeWidth > 0) {
223+
UIColor *strokeColorToUse = _textStrokeColor ?: effectiveForegroundColor;
224+
attributes[@"RCTTextStrokeWidth"] = @(_textStrokeWidth);
225+
attributes[@"RCTTextStrokeColor"] = strokeColorToUse;
226+
}
227+
228+
213229
// Special
214230
if (_isHighlighted) {
215231
attributes[RCTTextAttributesIsHighlightedAttributeName] = @YES;
@@ -303,7 +319,7 @@ - (UIColor *)effectiveForegroundColor
303319
[cgColors addObject:(id)color.CGColor];
304320
}
305321
}
306-
322+
307323
if([cgColors count] > 0) {
308324
[cgColors addObject:cgColors[0]];
309325
CAGradientLayer *gradient = [CAGradientLayer layer];
@@ -312,8 +328,17 @@ - (UIColor *)effectiveForegroundColor
312328
CGFloat height = _lineHeight * self.effectiveFontSizeMultiplier;
313329
gradient.frame = CGRectMake(0, 0, patternWidth, height);
314330
gradient.colors = cgColors;
315-
gradient.startPoint = CGPointMake(0.0, 0.5);
316-
gradient.endPoint = CGPointMake(1.0, 0.5);
331+
332+
CGFloat angle = !isnan(_gradientAngle) ? _gradientAngle : 0.0;
333+
CGFloat radians = angle * M_PI / 180.0;
334+
335+
CGFloat startX = 0.5 - 0.5 * cos(radians);
336+
CGFloat startY = 0.5 - 0.5 * sin(radians);
337+
CGFloat endX = 0.5 + 0.5 * cos(radians);
338+
CGFloat endY = 0.5 + 0.5 * sin(radians);
339+
340+
gradient.startPoint = CGPointMake(startX, startY);
341+
gradient.endPoint = CGPointMake(endX, endY);
317342

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

399424
return RCTTextAttributesCompareObjects(_foregroundColor) && RCTTextAttributesCompareObjects(_backgroundColor) &&
425+
RCTTextAttributesCompareObjects(_gradientColors) && RCTTextAttributesCompareFloats(_gradientAngle) &&
400426
RCTTextAttributesCompareFloats(_opacity) &&
401427
// Font
402428
RCTTextAttributesCompareObjects(_fontFamily) && RCTTextAttributesCompareFloats(_fontSize) &&
@@ -414,6 +440,8 @@ - (BOOL)isEqual:(RCTTextAttributes *)textAttributes
414440
// Shadow
415441
RCTTextAttributesCompareSize(_textShadowOffset) && RCTTextAttributesCompareFloats(_textShadowRadius) &&
416442
RCTTextAttributesCompareObjects(_textShadowColor) &&
443+
// Stroke
444+
RCTTextAttributesCompareFloats(_textStrokeWidth) && RCTTextAttributesCompareObjects(_textStrokeColor) &&
417445
// Special
418446
RCTTextAttributesCompareOthers(_isHighlighted) && RCTTextAttributesCompareObjects(_tag) &&
419447
RCTTextAttributesCompareOthers(_layoutDirection) && RCTTextAttributesCompareOthers(_textTransform);

packages/react-native/Libraries/Text/Text.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,21 @@ export interface TextProps
223223
* Adds a horizontal gradient using the int based color values.
224224
*/
225225
gradientColors?: number[] | undefined;
226+
227+
/**
228+
* Gradient angle in degrees. Default is 0 (horizontal).
229+
*/
230+
gradientAngle?: number | undefined;
231+
232+
/**
233+
* Width of the text stroke (outline). Creates an outer stroke effect.
234+
*/
235+
textStrokeWidth?: number | undefined;
236+
237+
/**
238+
* Color of the text stroke (outline).
239+
*/
240+
textStrokeColor?: ColorValue | undefined;
226241
}
227242

228243
/**

packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -405,13 +405,28 @@ - (CGFloat)lastBaselineForSize:(CGSize)size
405405
[attributedText enumerateAttribute:NSFontAttributeName
406406
inRange:NSMakeRange(0, attributedText.length)
407407
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
408-
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
409-
if (maximumDescender > font.descender) {
410-
maximumDescender = font.descender;
411-
}
412-
}];
408+
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
409+
if (maximumDescender > font.descender) {
410+
maximumDescender = font.descender;
411+
}
412+
}];
413+
414+
// Account for stroke width in baseline calculation
415+
__block CGFloat strokeWidth = 0;
416+
[attributedText enumerateAttribute:@"RCTTextStrokeWidth"
417+
inRange:NSMakeRange(0, attributedText.length)
418+
options:0
419+
usingBlock:^(id value, NSRange range, BOOL *stop) {
420+
if (value && [value isKindOfClass:[NSNumber class]]) {
421+
CGFloat width = [value floatValue];
422+
if (width > 0) {
423+
strokeWidth = MAX(strokeWidth, width);
424+
*stop = YES;
425+
}
426+
}
427+
}];
413428

414-
return size.height + maximumDescender;
429+
return size.height + maximumDescender + strokeWidth;
415430
}
416431

417432
static YGSize RCTTextShadowViewMeasure(
@@ -441,6 +456,27 @@ static YGSize RCTTextShadowViewMeasure(
441456
size.width -= letterSpacing;
442457
}
443458

459+
// Account for text stroke width (similar to Android implementation)
460+
// Check if text has custom stroke attribute and add extra space
461+
__block CGFloat strokeWidth = 0;
462+
[textStorage enumerateAttribute:@"RCTTextStrokeWidth"
463+
inRange:NSMakeRange(0, textStorage.length)
464+
options:0
465+
usingBlock:^(id value, NSRange range, BOOL *stop) {
466+
if (value && [value isKindOfClass:[NSNumber class]]) {
467+
CGFloat width = [value floatValue];
468+
if (width > 0) {
469+
strokeWidth = MAX(strokeWidth, width);
470+
*stop = YES;
471+
}
472+
}
473+
}];
474+
475+
if (strokeWidth > 0) {
476+
size.width += strokeWidth;
477+
size.height += strokeWidth;
478+
}
479+
444480
size = (CGSize){
445481
MIN(RCTCeilPixelValue(size.width), maximumSize.width), MIN(RCTCeilPixelValue(size.height), maximumSize.height)};
446482

packages/react-native/Libraries/Text/Text/RCTTextView.mm

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#import <React/RCTTextView.h>
99

10+
#import <CoreText/CoreText.h>
1011
#import <MobileCoreServices/UTCoreTypes.h>
1112

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

120121
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
121122
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin];
122-
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];
123123

124-
__block UIBezierPath *highlightPath = nil;
124+
// Check if text has custom stroke attribute
125125
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
126+
__block BOOL hasStroke = NO;
127+
__block CGFloat strokeWidth = 0;
128+
__block UIColor *strokeColor = nil;
129+
130+
[_textStorage enumerateAttribute:@"RCTTextStrokeWidth"
131+
inRange:characterRange
132+
options:0
133+
usingBlock:^(id value, NSRange range, BOOL *stop) {
134+
if (value && [value isKindOfClass:[NSNumber class]]) {
135+
CGFloat width = [value floatValue];
136+
if (width > 0) {
137+
hasStroke = YES;
138+
strokeWidth = width;
139+
strokeColor = [_textStorage attribute:@"RCTTextStrokeColor" atIndex:range.location effectiveRange:NULL];
140+
141+
if (strokeColor) {
142+
CGFloat r, g, b, a;
143+
[strokeColor getRed:&r green:&g blue:&b alpha:&a];
144+
}
145+
*stop = YES;
146+
}
147+
}
148+
}];
149+
150+
if (hasStroke && strokeColor) {
151+
CGContextRef context = UIGraphicsGetCurrentContext();
152+
153+
CGContextSetLineWidth(context, strokeWidth);
154+
CGContextSetLineJoin(context, kCGLineJoinRound);
155+
CGContextSetLineCap(context, kCGLineCapRound);
156+
157+
CGFloat strokeInset = strokeWidth / 2;
158+
159+
// PASS 1: Draw stroke outline
160+
CGContextSaveGState(context);
161+
CGContextSetTextDrawingMode(context, kCGTextStroke);
162+
163+
NSMutableAttributedString *strokeText = [_textStorage mutableCopy];
164+
[strokeText addAttribute:NSForegroundColorAttributeName
165+
value:strokeColor
166+
range:characterRange];
167+
168+
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
169+
CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset);
170+
CGContextScaleCTM(context, 1.0, -1.0);
171+
172+
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText);
173+
CGMutablePathRef path = CGPathCreateMutable();
174+
CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height));
175+
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
176+
CTFrameDraw(frame, context);
177+
CFRelease(frame);
178+
CFRelease(path);
179+
CFRelease(framesetter);
180+
CGContextRestoreGState(context);
181+
182+
// PASS 2: Draw fill on top
183+
CGContextSaveGState(context);
184+
CGContextSetTextDrawingMode(context, kCGTextFill);
185+
186+
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
187+
CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset);
188+
CGContextScaleCTM(context, 1.0, -1.0);
189+
190+
framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)_textStorage);
191+
path = CGPathCreateMutable();
192+
CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height));
193+
frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
194+
CTFrameDraw(frame, context);
195+
CFRelease(frame);
196+
CFRelease(path);
197+
CFRelease(framesetter);
198+
CGContextRestoreGState(context);
199+
200+
} else {
201+
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];
202+
}
203+
204+
__block UIBezierPath *highlightPath = nil;
126205
[_textStorage
127206
enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName
128207
inRange:characterRange

packages/react-native/Libraries/Text/TextNativeComponent.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ const textViewConfig = {
4848
android_hyphenationFrequency: true,
4949
lineBreakStrategyIOS: true,
5050
gradientColors: true,
51+
gradientAngle: true,
52+
textStrokeWidth: true,
53+
textStrokeColor: true,
5154
},
5255
directEventTypes: {
5356
topTextLayout: {
@@ -63,6 +66,9 @@ const virtualTextViewConfig = {
6366
isPressable: true,
6467
maxFontSizeMultiplier: true,
6568
gradientColors: true,
69+
gradientAngle: true,
70+
textStrokeWidth: true,
71+
textStrokeColor: true,
6672
},
6773
uiViewClassName: 'RCTVirtualText',
6874
};

0 commit comments

Comments
 (0)