Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1930640
Move the text stroke effect to a separate class and fix baseline
insacc Dec 17, 2025
3e2b0d6
Convert the text stroke effect to a Swift class
insacc Dec 17, 2025
64e9e3e
Match mobile implementation to web
insacc Dec 17, 2025
5f84a84
Fix build issues
insacc Dec 17, 2025
d001dfb
Update the text stroke effect
insacc Dec 17, 2025
f877549
Attempt to fix shadow with character style span
insacc Dec 17, 2025
f19505e
Make sure the stroke is rendered correctly on ios
insacc Dec 17, 2025
b40ad48
Make sure the stroke uses the correct color
insacc Dec 17, 2025
e2a59a6
Add some logs
insacc Dec 18, 2025
51c4654
Add more logs
insacc Dec 18, 2025
897ab54
Fix the build
insacc Dec 18, 2025
c37cce1
Update implementation of text stroke effect
insacc Dec 18, 2025
2a21b84
Attempt to fix stroke effect
insacc Dec 18, 2025
abde196
Another attempt at fixing the text stroke effect
insacc Dec 18, 2025
152f173
Simplify the text stroke effect
insacc Dec 18, 2025
b070bb4
Attempt to fix the stroke effect v3
insacc Dec 18, 2025
6ece2a3
Fix the build issues
insacc Dec 18, 2025
51fc7a0
Fix imports
insacc Dec 18, 2025
76094c9
Attempt to fix text stroke effect by expanding clip bounds
insacc Dec 18, 2025
9872743
Attempt to fix text stroke effect by disabling clipping mechanisms
insacc Dec 18, 2025
b8b307f
Add test logs
insacc Dec 18, 2025
c2039b5
Fix the logs
insacc Dec 18, 2025
75444c6
Fix logs
insacc Dec 18, 2025
fc70ebc
Add more logs
insacc Dec 19, 2025
d1168f2
Attempt to fix text stroke effect by expanding clip bounds
insacc Dec 19, 2025
712c508
Attempt to fix text stroke effect by moving canvas
insacc Dec 19, 2025
cec54bd
Attempt once more to fix text stroke effect
insacc Dec 19, 2025
ed73a46
Make the layout wider to fit the stroke
insacc Dec 19, 2025
859a0c6
Use leading margin span to handle the stroke
insacc Dec 19, 2025
391c0d0
Fix the leading margin span
insacc Dec 19, 2025
6c38b41
Fix the stroke style span
insacc Dec 19, 2025
c0d8f18
Add more logs#
insacc Dec 20, 2025
d6a479e
Update the stroke style span order
insacc Dec 21, 2025
53cc3bd
Ignore ReactForegroundColorSpan when stroke is present
insacc Dec 21, 2025
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
56 changes: 10 additions & 46 deletions packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -398,35 +398,20 @@ - (void)layoutSubviewsWithContext:(RCTLayoutContext)layoutContext

- (CGFloat)lastBaselineForSize:(CGSize)size
{
NSAttributedString *attributedText = [self textStorageAndLayoutManagerThatFitsSize:size exclusiveOwnership:NO];
NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:size exclusiveOwnership:NO];

__block CGFloat maximumDescender = 0.0;

[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;
}
}];

// 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;
}
}
}];
[textStorage enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, textStorage.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (maximumDescender > font.descender) {
maximumDescender = font.descender;
}
}];

return size.height + maximumDescender + strokeWidth;
return size.height + maximumDescender;
}

static YGSize RCTTextShadowViewMeasure(
Expand Down Expand Up @@ -456,27 +441,6 @@ 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)};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#import <UIKit/UIKit.h>

@interface RCTTextStrokeRenderer : NSObject

+ (BOOL)drawStrokedText:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame;

@end
88 changes: 88 additions & 0 deletions packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#import "RCTTextStrokeRenderer.h"

@implementation RCTTextStrokeRenderer

+ (BOOL)drawStrokedText:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
{
CGContextRef context = UIGraphicsGetCurrentContext();
if (!textStorage || textStorage.length == 0 || !context) {
return NO;
}

NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
if (!layoutManager || !textContainer) {
return NO;
}

NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];

__block CGFloat strokeWidth = 0;
__block UIColor *strokeColor = nil;

[textStorage enumerateAttribute:@"RCTTextStrokeWidth"
inRange:characterRange
options:0
usingBlock:^(NSNumber *value, NSRange range, BOOL *stop) {
if (value) {
CGFloat width = value.floatValue;
if (width > 0) {
strokeWidth = width;
strokeColor = [textStorage attribute:@"RCTTextStrokeColor"
atIndex:range.location
effectiveRange:nil];
*stop = YES;
}
}
}];

if (strokeWidth <= 0 || !strokeColor) {
return NO;
}

// Save original foreground colors
NSMutableArray *originalColors = [NSMutableArray array];
[textStorage enumerateAttribute:NSForegroundColorAttributeName
inRange:characterRange
options:0
usingBlock:^(UIColor *color, NSRange range, BOOL *stop) {
[originalColors addObject:@{
@"color": color ?: [UIColor blackColor],
@"range": [NSValue valueWithRange:range]
}];
}];

// PASS 1: Draw stroke - temporarily set foreground to stroke color
[textStorage beginEditing];
[textStorage addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange];
[textStorage endEditing];

CGContextSaveGState(context);
CGContextSetTextDrawingMode(context, kCGTextStroke);
CGContextSetLineWidth(context, strokeWidth);
CGContextSetLineJoin(context, kCGLineJoinRound);
CGContextSetLineCap(context, kCGLineCapRound);
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:contentFrame.origin];
CGContextRestoreGState(context);

// Restore original foreground colors
[textStorage beginEditing];
for (NSDictionary *colorInfo in originalColors) {
UIColor *color = colorInfo[@"color"];
NSRange range = [colorInfo[@"range"] rangeValue];
[textStorage addAttribute:NSForegroundColorAttributeName value:color range:range];
}
[textStorage endEditing];

// PASS 2: Draw fill on top using NSLayoutManager
CGContextSaveGState(context);
CGContextSetTextDrawingMode(context, kCGTextFill);
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:contentFrame.origin];
CGContextRestoreGState(context);

return YES;
}

@end
84 changes: 7 additions & 77 deletions packages/react-native/Libraries/Text/Text/RCTTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#import <React/UIView+React.h>

#import <React/RCTTextShadowView.h>
#import "RCTTextStrokeRenderer.h"

#import <QuartzCore/QuartzCore.h>

Expand Down Expand Up @@ -108,9 +109,9 @@ - (void)drawRect:(CGRect)rect

NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
CGContextRef context = UIGraphicsGetCurrentContext();

#if TARGET_OS_MACCATALYST
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);
// NSLayoutManager tries to draw text with sub-pixel anti-aliasing by default on
// macOS, but rendering SPAA onto a transparent background produces poor results.
Expand All @@ -121,86 +122,15 @@ - (void)drawRect:(CGRect)rect
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin];

// 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);
// Use stroke renderer if stroke attributes are present, otherwise use standard glyph drawing
BOOL didDrawStroke = [RCTTextStrokeRenderer drawStrokedText:_textStorage
contentFrame:_contentFrame];

} else {
if (!didDrawStroke) {
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];
}

NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
__block UIBezierPath *highlightPath = nil;
[_textStorage
enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.uimanager.BackgroundStyleApplicator
import com.facebook.react.uimanager.ReactCompoundView
import com.facebook.react.uimanager.style.Overflow
import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan
import com.facebook.react.views.text.internal.span.ReactTagSpan
import com.facebook.react.views.text.internal.span.StrokeStyleSpan
import kotlin.collections.ArrayList
import kotlin.math.roundToInt

Expand All @@ -43,6 +43,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re

private var clickableSpans: List<ClickableSpan> = emptyList()
private var selection: TextSelection? = null
private var strokeWidth: Float = 0f

var preparedLayout: PreparedLayout? = null
set(value) {
Expand All @@ -59,6 +60,17 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re

clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList()

// Check if text has stroke spans and disable clipping to prevent stroke from being clipped
val stroke = (value?.layout?.text as? Spanned)?.let { StrokeStyleSpan.getStrokeSpan(it) }
strokeWidth = stroke?.strokeWidth ?: 0f
clipToPadding = stroke == null

val textPreview = value?.layout?.text?.toString()?.take(20) ?: "null"
android.util.Log.d("TextDebug", "=== PreparedLayout setter ===")
android.util.Log.d("TextDebug", "Text: $textPreview")
android.util.Log.d("TextDebug", "StrokeSpan found: ${stroke != null}")
android.util.Log.d("TextDebug", "StrokeWidth set to: $strokeWidth")

field = value
invalidate()
}
Expand Down Expand Up @@ -91,29 +103,37 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
clickableSpans = emptyList()
selection = null
preparedLayout = null
strokeWidth = 0f
}

fun recycleView(): Unit {
initView()
BackgroundStyleApplicator.reset(this)
overflow = Overflow.HIDDEN
clipToPadding = true
}

override fun onDraw(canvas: Canvas) {
val layout = preparedLayout?.layout
val hasStroke = strokeWidth > 0f

// Get shadow adjustment from custom span if configured
val spanned = layout?.text as? Spanned
val shadowAdj = DiscordShadowStyleSpan.getShadowAdjustment(spanned)
android.util.Log.d("TextDebug", "=== PreparedLayout onDraw ===")
android.util.Log.d("TextDebug", "View: ${width}x${height}")
android.util.Log.d("TextDebug", "Clip: ${canvas.clipBounds}")
android.util.Log.d("TextDebug", "StrokeWidth: $strokeWidth")
android.util.Log.d("TextDebug", "Padding L/T/R/B: $paddingLeft/$paddingTop/$paddingRight/$paddingBottom")

if (overflow != Overflow.VISIBLE && !shadowAdj.hasShadow) {
// Skip clipping when stroke spans are present to prevent stroke from being clipped
if (overflow != Overflow.VISIBLE && !hasStroke) {
BackgroundStyleApplicator.clipToPaddingBox(this, canvas)
}

super.onDraw(canvas)

// paddingLeft/paddingTop: standard offset for text content to respect view padding
// When stroke is present, LeadingMarginSpan handles the left margin inside the Layout
canvas.translate(
paddingLeft.toFloat() + shadowAdj.leftOffset,
paddingLeft.toFloat(),
paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f))

if (layout != null) {
Expand All @@ -122,6 +142,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context))
}

// Draw shadow pass first (underneath everything)
//DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f)

// Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
Api34Utils.draw(layout, canvas, selection?.path, selectionPaint)
} else {
Expand All @@ -130,6 +154,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
}
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// No-op
}
Expand Down
Loading
Loading