From 19306401589d4c0900510e8381dfb7e8e0c68a5f Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 14:29:09 -0500 Subject: [PATCH 01/34] Move the text stroke effect to a separate class and fix baseline --- .../Libraries/Text/Text/RCTTextShadowView.mm | 64 +++----- .../Text/Text/RCTTextStrokeRenderer.h | 51 ++++++ .../Text/Text/RCTTextStrokeRenderer.mm | 153 ++++++++++++++++++ .../Libraries/Text/Text/RCTTextView.mm | 86 ++-------- .../text/internal/span/StrokeStyleSpan.kt | 2 +- 5 files changed, 238 insertions(+), 118 deletions(-) create mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h create mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm diff --git a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm index 0b34a8629bd257..f0573f51855157 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm @@ -14,6 +14,7 @@ #import #import "NSTextStorage+FontScaling.h" +#import "RCTTextStrokeRenderer.h" @implementation RCTTextShadowView { __weak RCTBridge *_bridge; @@ -83,6 +84,18 @@ - (void)uiManagerWillPerformMounting NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:self.contentFrame.size exclusiveOwnership:YES]; + // Adjust contentFrame origin to account for stroke padding + // This matches Android's behavior where font metrics expand the line box + // but keep the text baseline at the same relative position + CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFromTextStorage:textStorage]; + if (strokeWidth > 0) { + // Offset the contentFrame origin by half stroke (measurement added strokeWidth total) + // This centers the text within the expanded bounds + CGFloat strokeInset = strokeWidth / 2; + contentFrame.origin.x += strokeInset; + contentFrame.origin.y += strokeInset; + } + NSNumber *tag = self.reactTag; NSMutableArray *descendantViewTags = [NSMutableArray new]; [textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName @@ -398,34 +411,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; + } + }]; + CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFromTextStorage:textStorage]; return size.height + maximumDescender + strokeWidth; } @@ -456,22 +455,7 @@ 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; - } - } - }]; - + CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFromTextStorage:textStorage]; if (strokeWidth > 0) { size.width += strokeWidth; size.height += strokeWidth; diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h new file mode 100644 index 00000000000000..ddf065cf7e97f0 --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h @@ -0,0 +1,51 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * A renderer that applies text stroke styling with two-pass rendering. + * First draws stroke, then draws fill on top to create outer stroke effect. + * Similar to Android's StrokeStyleSpan implementation. + */ +@interface RCTTextStrokeRenderer : NSObject + +/** + * Draws text with stroke effect using two-pass rendering. + * + * @param textStorage The attributed text storage containing stroke attributes + * @param contentFrame The frame within which to draw the text (should be the original text frame, not expanded) + * @param bounds The bounds of the view (needed for coordinate transformation) + * @param context The graphics context to draw into + * @return YES if stroke was drawn, NO if no stroke attributes were found + */ ++ (BOOL)drawStrokedText:(NSTextStorage *)textStorage + contentFrame:(CGRect)contentFrame + bounds:(CGRect)bounds + context:(CGContextRef)context; + +/** + * Checks if the text storage has stroke attributes. + * + * @param textStorage The attributed text storage to check + * @return YES if stroke attributes are present, NO otherwise + */ ++ (BOOL)hasStrokeAttributes:(NSTextStorage *)textStorage; + +/** + * Gets the stroke width from the text storage. + * + * @param textStorage The attributed text storage + * @return The stroke width, or 0 if no stroke is defined + */ ++ (CGFloat)strokeWidthFromTextStorage:(NSTextStorage *)textStorage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm new file mode 100644 index 00000000000000..809299b1279e82 --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm @@ -0,0 +1,153 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTTextStrokeRenderer.h" +#import + +@implementation RCTTextStrokeRenderer + +#pragma mark - Private Helpers + ++ (void)drawAttributedString:(NSAttributedString *)attributedString + context:(CGContextRef)context + contentFrame:(CGRect)contentFrame + bounds:(CGRect)bounds + path:(CGPathRef)path +{ + CGContextSaveGState(context); + + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM(context, contentFrame.origin.x, bounds.size.height - contentFrame.origin.y); + CGContextScaleCTM(context, 1.0, -1.0); + + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString); + CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(frame, context); + + CFRelease(frame); + CFRelease(framesetter); + + CGContextRestoreGState(context); +} + +#pragma mark - Public Methods + ++ (BOOL)hasStrokeAttributes:(NSTextStorage *)textStorage +{ + if (!textStorage || textStorage.length == 0) { + return NO; + } + + __block BOOL hasStroke = NO; + [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) { + hasStroke = YES; + *stop = YES; + } + } + }]; + + return hasStroke; +} + ++ (CGFloat)strokeWidthFromTextStorage:(NSTextStorage *)textStorage +{ + if (!textStorage || textStorage.length == 0) { + return 0; + } + + __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; + } + } + }]; + + return strokeWidth; +} + ++ (BOOL)drawStrokedText:(NSTextStorage *)textStorage + contentFrame:(CGRect)contentFrame + bounds:(CGRect)bounds + context:(CGContextRef)context +{ + if (!textStorage || textStorage.length == 0 || !context) { + return NO; + } + + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; + + // Extract stroke attributes + __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) { + strokeWidth = width; + strokeColor = [textStorage attribute:@"RCTTextStrokeColor" atIndex:range.location effectiveRange:NULL]; + *stop = YES; + } + } + }]; + + if (strokeWidth <= 0 || !strokeColor) { + return NO; + } + + CGContextSetLineWidth(context, strokeWidth); + CGContextSetLineJoin(context, kCGLineJoinRound); + CGContextSetLineCap(context, kCGLineCapRound); + CGMutablePathRef path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, contentFrame.size.width, contentFrame.size.height)); + + // PASS 1: Draw stroke outline + NSMutableAttributedString *strokeText = [textStorage mutableCopy]; + [strokeText addAttribute:NSForegroundColorAttributeName + value:strokeColor + range:characterRange]; + + CGContextSetTextDrawingMode(context, kCGTextStroke); + [self drawAttributedString:strokeText + context:context + contentFrame:contentFrame + bounds:bounds + path:path]; + + // PASS 2: Draw fill on top + CGContextSetTextDrawingMode(context, kCGTextFill); + [self drawAttributedString:textStorage + context:context + contentFrame:contentFrame + bounds:bounds + path:path]; + + CGPathRelease(path); + + return YES; +} + +@end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index 0d92ec3dee0ff1..9028cb7bdc377d 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -14,6 +14,7 @@ #import #import +#import "RCTTextStrokeRenderer.h" #import @@ -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. @@ -121,86 +122,17 @@ - (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 + bounds:self.bounds + context:context]; - } else { + if (!didDrawStroke) { [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin]; } + NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; __block UIBezierPath *highlightPath = nil; [_textStorage enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 574038a1728d41..1b878df39a4498 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -31,7 +31,7 @@ public class StrokeStyleSpan( fm.bottom += halfStroke } - return width.toInt() + return (width + strokeWidth).toInt() } public override fun draw( From 3e2b0d69b0ec3fba9578db8aca31e570d7d11a49 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 15:20:24 -0500 Subject: [PATCH 02/34] Convert the text stroke effect to a Swift class --- .../Libraries/Text/Text/RCTTextShadowView.mm | 8 +- .../Text/Text/RCTTextStrokeRenderer.h | 51 ------ .../Text/Text/RCTTextStrokeRenderer.mm | 153 ------------------ .../Text/Text/RCTTextStrokeRenderer.swift | 136 ++++++++++++++++ .../Libraries/Text/Text/RCTTextView.mm | 2 +- 5 files changed, 141 insertions(+), 209 deletions(-) delete mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h delete mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm create mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift diff --git a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm index f0573f51855157..c8c66da2980ce5 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm @@ -14,7 +14,7 @@ #import #import "NSTextStorage+FontScaling.h" -#import "RCTTextStrokeRenderer.h" +#import @implementation RCTTextShadowView { __weak RCTBridge *_bridge; @@ -87,7 +87,7 @@ - (void)uiManagerWillPerformMounting // Adjust contentFrame origin to account for stroke padding // This matches Android's behavior where font metrics expand the line box // but keep the text baseline at the same relative position - CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFromTextStorage:textStorage]; + CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFrom:textStorage]; if (strokeWidth > 0) { // Offset the contentFrame origin by half stroke (measurement added strokeWidth total) // This centers the text within the expanded bounds @@ -424,7 +424,7 @@ - (CGFloat)lastBaselineForSize:(CGSize)size } }]; - CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFromTextStorage:textStorage]; + CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFrom:textStorage]; return size.height + maximumDescender + strokeWidth; } @@ -455,7 +455,7 @@ static YGSize RCTTextShadowViewMeasure( size.width -= letterSpacing; } - CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFromTextStorage:textStorage]; + CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFrom:textStorage]; if (strokeWidth > 0) { size.width += strokeWidth; size.height += strokeWidth; diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h deleted file mode 100644 index ddf065cf7e97f0..00000000000000 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#import - -NS_ASSUME_NONNULL_BEGIN - -/** - * A renderer that applies text stroke styling with two-pass rendering. - * First draws stroke, then draws fill on top to create outer stroke effect. - * Similar to Android's StrokeStyleSpan implementation. - */ -@interface RCTTextStrokeRenderer : NSObject - -/** - * Draws text with stroke effect using two-pass rendering. - * - * @param textStorage The attributed text storage containing stroke attributes - * @param contentFrame The frame within which to draw the text (should be the original text frame, not expanded) - * @param bounds The bounds of the view (needed for coordinate transformation) - * @param context The graphics context to draw into - * @return YES if stroke was drawn, NO if no stroke attributes were found - */ -+ (BOOL)drawStrokedText:(NSTextStorage *)textStorage - contentFrame:(CGRect)contentFrame - bounds:(CGRect)bounds - context:(CGContextRef)context; - -/** - * Checks if the text storage has stroke attributes. - * - * @param textStorage The attributed text storage to check - * @return YES if stroke attributes are present, NO otherwise - */ -+ (BOOL)hasStrokeAttributes:(NSTextStorage *)textStorage; - -/** - * Gets the stroke width from the text storage. - * - * @param textStorage The attributed text storage - * @return The stroke width, or 0 if no stroke is defined - */ -+ (CGFloat)strokeWidthFromTextStorage:(NSTextStorage *)textStorage; - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm deleted file mode 100644 index 809299b1279e82..00000000000000 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#import "RCTTextStrokeRenderer.h" -#import - -@implementation RCTTextStrokeRenderer - -#pragma mark - Private Helpers - -+ (void)drawAttributedString:(NSAttributedString *)attributedString - context:(CGContextRef)context - contentFrame:(CGRect)contentFrame - bounds:(CGRect)bounds - path:(CGPathRef)path -{ - CGContextSaveGState(context); - - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM(context, contentFrame.origin.x, bounds.size.height - contentFrame.origin.y); - CGContextScaleCTM(context, 1.0, -1.0); - - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString); - CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - CTFrameDraw(frame, context); - - CFRelease(frame); - CFRelease(framesetter); - - CGContextRestoreGState(context); -} - -#pragma mark - Public Methods - -+ (BOOL)hasStrokeAttributes:(NSTextStorage *)textStorage -{ - if (!textStorage || textStorage.length == 0) { - return NO; - } - - __block BOOL hasStroke = NO; - [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) { - hasStroke = YES; - *stop = YES; - } - } - }]; - - return hasStroke; -} - -+ (CGFloat)strokeWidthFromTextStorage:(NSTextStorage *)textStorage -{ - if (!textStorage || textStorage.length == 0) { - return 0; - } - - __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; - } - } - }]; - - return strokeWidth; -} - -+ (BOOL)drawStrokedText:(NSTextStorage *)textStorage - contentFrame:(CGRect)contentFrame - bounds:(CGRect)bounds - context:(CGContextRef)context -{ - if (!textStorage || textStorage.length == 0 || !context) { - return NO; - } - - NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; - NSTextContainer *textContainer = layoutManager.textContainers.firstObject; - NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; - NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; - - // Extract stroke attributes - __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) { - strokeWidth = width; - strokeColor = [textStorage attribute:@"RCTTextStrokeColor" atIndex:range.location effectiveRange:NULL]; - *stop = YES; - } - } - }]; - - if (strokeWidth <= 0 || !strokeColor) { - return NO; - } - - CGContextSetLineWidth(context, strokeWidth); - CGContextSetLineJoin(context, kCGLineJoinRound); - CGContextSetLineCap(context, kCGLineCapRound); - CGMutablePathRef path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, contentFrame.size.width, contentFrame.size.height)); - - // PASS 1: Draw stroke outline - NSMutableAttributedString *strokeText = [textStorage mutableCopy]; - [strokeText addAttribute:NSForegroundColorAttributeName - value:strokeColor - range:characterRange]; - - CGContextSetTextDrawingMode(context, kCGTextStroke); - [self drawAttributedString:strokeText - context:context - contentFrame:contentFrame - bounds:bounds - path:path]; - - // PASS 2: Draw fill on top - CGContextSetTextDrawingMode(context, kCGTextFill); - [self drawAttributedString:textStorage - context:context - contentFrame:contentFrame - bounds:bounds - path:path]; - - CGPathRelease(path); - - return YES; -} - -@end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift new file mode 100644 index 00000000000000..c0857e16d980d8 --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift @@ -0,0 +1,136 @@ +import CoreText +import UIKit + +@objcMembers +@objc public class RCTTextStrokeRenderer: NSObject { + + private class func drawAttributedString( + _ attributedString: NSAttributedString, + context: CGContext, + contentFrame: CGRect, + bounds: CGRect, + path: CGPath + ) { + context.saveGState() + + context.textMatrix = .identity + context.translateBy(x: contentFrame.origin.x, y: bounds.size.height - contentFrame.origin.y) + context.scaleBy(x: 1.0, y: -1.0) + + let framesetter = CTFramesetterCreateWithAttributedString(attributedString as CFAttributedString) + let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil) + CTFrameDraw(frame, context) + + context.restoreGState() + } + + @objc public class func hasStrokeAttributes(_ textStorage: NSTextStorage?) -> Bool { + guard let textStorage = textStorage, textStorage.length > 0 else { + return false + } + + var hasStroke = false + textStorage.enumerateAttribute( + NSAttributedString.Key(rawValue: "RCTTextStrokeWidth"), + in: NSRange(location: 0, length: textStorage.length), + options: [] + ) { value, _, stop in + if let number = value as? NSNumber, number.floatValue > 0 { + hasStroke = true + stop.pointee = true + } + } + + return hasStroke + } + + @objc public class func strokeWidth(from textStorage: NSTextStorage?) -> CGFloat { + guard let textStorage = textStorage, textStorage.length > 0 else { + return 0 + } + + var strokeWidth: CGFloat = 0 + textStorage.enumerateAttribute( + NSAttributedString.Key(rawValue: "RCTTextStrokeWidth"), + in: NSRange(location: 0, length: textStorage.length), + options: [] + ) { value, _, stop in + if let number = value as? NSNumber { + let width = CGFloat(number.floatValue) + if width > 0 { + strokeWidth = max(strokeWidth, width) + stop.pointee = true + } + } + } + + return strokeWidth + } + + @objc public class func drawStrokedText( + _ textStorage: NSTextStorage?, + contentFrame: CGRect, + bounds: CGRect, + context: CGContext? + ) -> Bool { + guard let textStorage = textStorage, + textStorage.length > 0, + let context = context else { + return false + } + + guard let layoutManager = textStorage.layoutManagers.firstObject as? NSLayoutManager, + let textContainer = layoutManager.textContainers.first else { + return false + } + + let glyphRange = layoutManager.glyphRange(for: textContainer) + let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + var strokeWidth: CGFloat = 0 + var strokeColor: UIColor? + + textStorage.enumerateAttribute( + NSAttributedString.Key(rawValue: "RCTTextStrokeWidth"), + in: characterRange, + options: [] + ) { value, range, stop in + if let number = value as? NSNumber { + let width = CGFloat(number.floatValue) + if width > 0 { + strokeWidth = width + strokeColor = textStorage.attribute( + NSAttributedString.Key(rawValue: "RCTTextStrokeColor"), + at: range.location, + effectiveRange: nil + ) as? UIColor + stop.pointee = true + } + } + } + + guard strokeWidth > 0, let strokeColor = strokeColor else { + return false + } + + context.setLineWidth(strokeWidth) + context.setLineJoin(.round) + context.setLineCap(.round) + + let path = CGMutablePath() + path.addRect(CGRect(x: 0, y: 0, width: contentFrame.size.width, height: contentFrame.size.height)) + + // PASS 1: Draw stroke outline + let strokeText = NSMutableAttributedString(attributedString: textStorage) + strokeText.addAttribute(.foregroundColor, value: strokeColor, range: characterRange) + + context.setTextDrawingMode(.stroke) + drawAttributedString(strokeText, context: context, contentFrame: contentFrame, bounds: bounds, path: path) + + // PASS 2: Draw fill on top + context.setTextDrawingMode(.fill) + drawAttributedString(textStorage, context: context, contentFrame: contentFrame, bounds: bounds, path: path) + + return true + } +} diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index 9028cb7bdc377d..ee059149db7c19 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -14,7 +14,7 @@ #import #import -#import "RCTTextStrokeRenderer.h" +#import #import From 64e9e3ebda4e879212e592bbd1976762cbbca4e7 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 15:39:17 -0500 Subject: [PATCH 03/34] Match mobile implementation to web --- .../Libraries/Text/Text/RCTTextShadowView.mm | 22 +-------- .../Text/Text/RCTTextStrokeRenderer.swift | 45 +------------------ .../text/internal/span/StrokeStyleSpan.kt | 19 ++------ 3 files changed, 6 insertions(+), 80 deletions(-) diff --git a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm index c8c66da2980ce5..23ab38142bc113 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm @@ -14,7 +14,6 @@ #import #import "NSTextStorage+FontScaling.h" -#import @implementation RCTTextShadowView { __weak RCTBridge *_bridge; @@ -84,18 +83,6 @@ - (void)uiManagerWillPerformMounting NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:self.contentFrame.size exclusiveOwnership:YES]; - // Adjust contentFrame origin to account for stroke padding - // This matches Android's behavior where font metrics expand the line box - // but keep the text baseline at the same relative position - CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFrom:textStorage]; - if (strokeWidth > 0) { - // Offset the contentFrame origin by half stroke (measurement added strokeWidth total) - // This centers the text within the expanded bounds - CGFloat strokeInset = strokeWidth / 2; - contentFrame.origin.x += strokeInset; - contentFrame.origin.y += strokeInset; - } - NSNumber *tag = self.reactTag; NSMutableArray *descendantViewTags = [NSMutableArray new]; [textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName @@ -424,8 +411,7 @@ - (CGFloat)lastBaselineForSize:(CGSize)size } }]; - CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFrom:textStorage]; - return size.height + maximumDescender + strokeWidth; + return size.height + maximumDescender; } static YGSize RCTTextShadowViewMeasure( @@ -455,12 +441,6 @@ static YGSize RCTTextShadowViewMeasure( size.width -= letterSpacing; } - CGFloat strokeWidth = [RCTTextStrokeRenderer strokeWidthFrom:textStorage]; - if (strokeWidth > 0) { - size.width += strokeWidth; - size.height += strokeWidth; - } - size = (CGSize){ MIN(RCTCeilPixelValue(size.width), maximumSize.width), MIN(RCTCeilPixelValue(size.height), maximumSize.height)}; diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift index c0857e16d980d8..70307c60cf1866 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift @@ -24,49 +24,6 @@ import UIKit context.restoreGState() } - @objc public class func hasStrokeAttributes(_ textStorage: NSTextStorage?) -> Bool { - guard let textStorage = textStorage, textStorage.length > 0 else { - return false - } - - var hasStroke = false - textStorage.enumerateAttribute( - NSAttributedString.Key(rawValue: "RCTTextStrokeWidth"), - in: NSRange(location: 0, length: textStorage.length), - options: [] - ) { value, _, stop in - if let number = value as? NSNumber, number.floatValue > 0 { - hasStroke = true - stop.pointee = true - } - } - - return hasStroke - } - - @objc public class func strokeWidth(from textStorage: NSTextStorage?) -> CGFloat { - guard let textStorage = textStorage, textStorage.length > 0 else { - return 0 - } - - var strokeWidth: CGFloat = 0 - textStorage.enumerateAttribute( - NSAttributedString.Key(rawValue: "RCTTextStrokeWidth"), - in: NSRange(location: 0, length: textStorage.length), - options: [] - ) { value, _, stop in - if let number = value as? NSNumber { - let width = CGFloat(number.floatValue) - if width > 0 { - strokeWidth = max(strokeWidth, width) - stop.pointee = true - } - } - } - - return strokeWidth - } - @objc public class func drawStrokedText( _ textStorage: NSTextStorage?, contentFrame: CGRect, @@ -120,7 +77,7 @@ import UIKit let path = CGMutablePath() path.addRect(CGRect(x: 0, y: 0, width: contentFrame.size.width, height: contentFrame.size.height)) - // PASS 1: Draw stroke outline + // PASS 1: Draw stroke let strokeText = NSMutableAttributedString(attributedString: textStorage) strokeText.addAttribute(.foregroundColor, value: strokeColor, range: characterRange) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 1b878df39a4498..0ecc0bab9bc80e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -20,18 +20,10 @@ public class StrokeStyleSpan( end: Int, fm: Paint.FontMetricsInt? ): Int { - val width = paint.measureText(text, start, end) - if (fm != null) { paint.getFontMetricsInt(fm) - val halfStroke = (strokeWidth / 2).toInt() - fm.top -= halfStroke - fm.ascent -= halfStroke - fm.descent += halfStroke - fm.bottom += halfStroke } - - return (width + strokeWidth).toInt() + return paint.measureText(text, start, end).toInt() } public override fun draw( @@ -48,22 +40,20 @@ public class StrokeStyleSpan( if (text == null) return val textToDraw = text.subSequence(start, end).toString() - val strokeInset = strokeWidth / 2 - // Store original paint settings val originalStyle = paint.style val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth val originalStrokeJoin = paint.strokeJoin val originalStrokeCap = paint.strokeCap - // First pass: Draw stroke only (solid color) + // First pass: Draw stroke paint.style = Paint.Style.STROKE paint.strokeWidth = strokeWidth paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND paint.color = strokeColor - canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) + canvas.drawText(textToDraw, x, y.toFloat(), paint) // Second pass: Draw fill on top paint.style = Paint.Style.FILL @@ -74,9 +64,8 @@ public class StrokeStyleSpan( span.updateDrawState(paint) } } - canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) + canvas.drawText(textToDraw, x, y.toFloat(), paint) - // Restore original paint settings paint.style = originalStyle paint.color = originalColor paint.strokeWidth = originalStrokeWidth From 5f84a84bf426105a246f3d083d39bcf195eeefd6 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 16:18:32 -0500 Subject: [PATCH 04/34] Fix build issues --- .../Text/Text/RCTTextStrokeRenderer.h | 10 ++ .../Text/Text/RCTTextStrokeRenderer.mm | 92 ++++++++++++++++++ .../Text/Text/RCTTextStrokeRenderer.swift | 93 ------------------- .../Libraries/Text/Text/RCTTextView.mm | 2 +- 4 files changed, 103 insertions(+), 94 deletions(-) create mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h create mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm delete mode 100644 packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h new file mode 100644 index 00000000000000..a1fffd8901a2f5 --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h @@ -0,0 +1,10 @@ +#import + +@interface RCTTextStrokeRenderer : NSObject + ++ (BOOL)drawStrokedText:(NSTextStorage *)textStorage + contentFrame:(CGRect)contentFrame + bounds:(CGRect)bounds + context:(CGContextRef)context; + +@end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm new file mode 100644 index 00000000000000..8d90cbfeb17a5b --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm @@ -0,0 +1,92 @@ +#import "RCTTextStrokeRenderer.h" +#import + +@implementation RCTTextStrokeRenderer + ++ (void)drawAttributedString:(NSAttributedString *)attributedString + context:(CGContextRef)context + contentFrame:(CGRect)contentFrame + bounds:(CGRect)bounds + path:(CGPathRef)path +{ + CGContextSaveGState(context); + + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM(context, contentFrame.origin.x, bounds.size.height - contentFrame.origin.y); + CGContextScaleCTM(context, 1.0, -1.0); + + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString); + CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(frame, context); + + CFRelease(frame); + CFRelease(framesetter); + + CGContextRestoreGState(context); +} + ++ (BOOL)drawStrokedText:(NSTextStorage *)textStorage + contentFrame:(CGRect)contentFrame + bounds:(CGRect)bounds + context:(CGContextRef)context +{ + 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; + } + + CGContextSetLineWidth(context, strokeWidth); + CGContextSetLineJoin(context, kCGLineJoinRound); + CGContextSetLineCap(context, kCGLineCapRound); + + CGMutablePathRef path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, contentFrame.size.width, contentFrame.size.height)); + + // PASS 1: Draw stroke + NSMutableAttributedString *strokeText = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage]; + [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; + + CGContextSetTextDrawingMode(context, kCGTextStroke); + [self drawAttributedString:strokeText context:context contentFrame:contentFrame bounds:bounds path:path]; + + // PASS 2: Draw fill on top + CGContextSetTextDrawingMode(context, kCGTextFill); + [self drawAttributedString:textStorage context:context contentFrame:contentFrame bounds:bounds path:path]; + + CGPathRelease(path); + + return YES; +} + +@end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift deleted file mode 100644 index 70307c60cf1866..00000000000000 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.swift +++ /dev/null @@ -1,93 +0,0 @@ -import CoreText -import UIKit - -@objcMembers -@objc public class RCTTextStrokeRenderer: NSObject { - - private class func drawAttributedString( - _ attributedString: NSAttributedString, - context: CGContext, - contentFrame: CGRect, - bounds: CGRect, - path: CGPath - ) { - context.saveGState() - - context.textMatrix = .identity - context.translateBy(x: contentFrame.origin.x, y: bounds.size.height - contentFrame.origin.y) - context.scaleBy(x: 1.0, y: -1.0) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedString as CFAttributedString) - let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil) - CTFrameDraw(frame, context) - - context.restoreGState() - } - - @objc public class func drawStrokedText( - _ textStorage: NSTextStorage?, - contentFrame: CGRect, - bounds: CGRect, - context: CGContext? - ) -> Bool { - guard let textStorage = textStorage, - textStorage.length > 0, - let context = context else { - return false - } - - guard let layoutManager = textStorage.layoutManagers.firstObject as? NSLayoutManager, - let textContainer = layoutManager.textContainers.first else { - return false - } - - let glyphRange = layoutManager.glyphRange(for: textContainer) - let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - - var strokeWidth: CGFloat = 0 - var strokeColor: UIColor? - - textStorage.enumerateAttribute( - NSAttributedString.Key(rawValue: "RCTTextStrokeWidth"), - in: characterRange, - options: [] - ) { value, range, stop in - if let number = value as? NSNumber { - let width = CGFloat(number.floatValue) - if width > 0 { - strokeWidth = width - strokeColor = textStorage.attribute( - NSAttributedString.Key(rawValue: "RCTTextStrokeColor"), - at: range.location, - effectiveRange: nil - ) as? UIColor - stop.pointee = true - } - } - } - - guard strokeWidth > 0, let strokeColor = strokeColor else { - return false - } - - context.setLineWidth(strokeWidth) - context.setLineJoin(.round) - context.setLineCap(.round) - - let path = CGMutablePath() - path.addRect(CGRect(x: 0, y: 0, width: contentFrame.size.width, height: contentFrame.size.height)) - - // PASS 1: Draw stroke - let strokeText = NSMutableAttributedString(attributedString: textStorage) - strokeText.addAttribute(.foregroundColor, value: strokeColor, range: characterRange) - - context.setTextDrawingMode(.stroke) - drawAttributedString(strokeText, context: context, contentFrame: contentFrame, bounds: bounds, path: path) - - // PASS 2: Draw fill on top - context.setTextDrawingMode(.fill) - drawAttributedString(textStorage, context: context, contentFrame: contentFrame, bounds: bounds, path: path) - - return true - } -} diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index ee059149db7c19..9028cb7bdc377d 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -14,7 +14,7 @@ #import #import -#import +#import "RCTTextStrokeRenderer.h" #import From d001dfb8f4826fa588d1afc88aa02cfaec5dc079 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 16:25:23 -0500 Subject: [PATCH 05/34] Update the text stroke effect --- .../views/text/PreparedLayoutTextView.kt | 4 + .../react/views/text/ReactTextView.java | 4 + .../text/internal/span/StrokeStyleSpan.kt | 159 +++++++++++------- 3 files changed, 106 insertions(+), 61 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index c0a9d3e147aab5..becf9a0ccdd149 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -29,6 +29,7 @@ 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 @@ -122,6 +123,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context)) } + // Draw stroke pass first (underneath), then fill pass + StrokeStyleSpan.drawStrokePassWithLayout(layout, canvas, 0f, 0f) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) } else { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 05ab157865940d..a6405b0357d706 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -54,6 +54,7 @@ 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 com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaMeasureMode; @@ -372,6 +373,9 @@ protected void onDraw(Canvas canvas) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } + // Draw stroke pass first (underneath), then fill pass via super.onDraw + StrokeStyleSpan.drawStrokePass(this, canvas); + super.onDraw(canvas); canvas.restore(); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 0ecc0bab9bc80e..2d8b2c8d63b47a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -2,74 +2,111 @@ package com.facebook.react.views.text.internal.span import android.graphics.Canvas import android.graphics.Paint -import android.text.style.ReplacementSpan +import android.text.Spanned +import android.text.TextPaint +import android.text.style.CharacterStyle +import android.widget.TextView /** - * A span that applies text stroke styling with two-pass rendering. - * First draws stroke, then draws fill on top to create outer stroke effect. + * A span that applies text stroke styling via view-level two-pass rendering. + * Uses CharacterStyle to preserve ellipsis behavior - actual rendering is done + * by the companion object's drawStrokePass method called from the view's onDraw. */ public class StrokeStyleSpan( - private val strokeWidth: Float, - private val strokeColor: Int -) : ReplacementSpan(), ReactSpan { - - public override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - if (fm != null) { - paint.getFontMetricsInt(fm) - } - return paint.measureText(text, start, end).toInt() + public val strokeWidth: Float, + public val strokeColor: Int +) : CharacterStyle(), ReactSpan { + + override fun updateDrawState(textPaint: TextPaint) { + // No-op: rendering is handled at view level via drawStrokePass } - public override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return - - val textToDraw = text.subSequence(start, end).toString() - - val originalStyle = paint.style - val originalColor = paint.color - val originalStrokeWidth = paint.strokeWidth - val originalStrokeJoin = paint.strokeJoin - val originalStrokeCap = paint.strokeCap - - // First pass: Draw stroke - paint.style = Paint.Style.STROKE - paint.strokeWidth = strokeWidth - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND - paint.color = strokeColor - canvas.drawText(textToDraw, x, y.toFloat(), paint) - - // Second pass: Draw fill on top - paint.style = Paint.Style.FILL - paint.color = originalColor - if (text is android.text.Spanned && paint is android.text.TextPaint) { - val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) - for (span in spans) { - span.updateDrawState(paint) - } + public companion object { + @JvmStatic + public fun getStrokeSpan(spanned: Spanned?): StrokeStyleSpan? { + if (spanned == null) return null + val spans = spanned.getSpans(0, spanned.length, StrokeStyleSpan::class.java) + return spans.firstOrNull() + } + + @JvmStatic + public fun hasStroke(spanned: Spanned?): Boolean { + return getStrokeSpan(spanned) != null + } + + @JvmStatic + public fun drawStrokePass(textView: TextView, canvas: Canvas): Boolean { + val text = textView.text + if (text !is Spanned) return false + + val span = getStrokeSpan(text) ?: return false + val layout = textView.layout ?: return false + + val paint = textView.paint + + val originalStyle = paint.style + val originalColor = paint.color + val originalStrokeWidth = paint.strokeWidth + val originalStrokeJoin = paint.strokeJoin + val originalStrokeCap = paint.strokeCap + + paint.style = Paint.Style.STROKE + paint.strokeWidth = span.strokeWidth + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + paint.color = span.strokeColor + + canvas.save() + canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) + layout.draw(canvas) + canvas.restore() + + paint.style = originalStyle + paint.color = originalColor + paint.strokeWidth = originalStrokeWidth + paint.strokeJoin = originalStrokeJoin + paint.strokeCap = originalStrokeCap + + return true } - canvas.drawText(textToDraw, x, y.toFloat(), paint) - paint.style = originalStyle - paint.color = originalColor - paint.strokeWidth = originalStrokeWidth - paint.strokeJoin = originalStrokeJoin - paint.strokeCap = originalStrokeCap + @JvmStatic + public fun drawStrokePassWithLayout( + layout: android.text.Layout, + canvas: Canvas, + offsetX: Float, + offsetY: Float + ): Boolean { + val text = layout.text + if (text !is Spanned) return false + + val span = getStrokeSpan(text) ?: return false + val paint = layout.paint + + val originalStyle = paint.style + val originalColor = paint.color + val originalStrokeWidth = paint.strokeWidth + val originalStrokeJoin = paint.strokeJoin + val originalStrokeCap = paint.strokeCap + + paint.style = Paint.Style.STROKE + paint.strokeWidth = span.strokeWidth + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + paint.color = span.strokeColor + + canvas.save() + canvas.translate(offsetX, offsetY) + layout.draw(canvas) + canvas.restore() + + paint.style = originalStyle + paint.color = originalColor + paint.strokeWidth = originalStrokeWidth + paint.strokeJoin = originalStrokeJoin + paint.strokeCap = originalStrokeCap + + return true + } } } From f877549ec31cedcb8965957142a63d91d41d9bf8 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 16:55:56 -0500 Subject: [PATCH 06/34] Attempt to fix shadow with character style span --- .../views/text/PreparedLayoutTextView.kt | 7 +- .../react/views/text/ReactTextView.java | 15 +- .../internal/span/DiscordShadowStyleSpan.kt | 141 ++---------------- 3 files changed, 21 insertions(+), 142 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index becf9a0ccdd149..66ecf9df731d7e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -103,18 +103,17 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re override fun onDraw(canvas: Canvas) { val layout = preparedLayout?.layout - // Get shadow adjustment from custom span if configured val spanned = layout?.text as? Spanned - val shadowAdj = DiscordShadowStyleSpan.getShadowAdjustment(spanned) + val hasShadow = DiscordShadowStyleSpan.hasShadow(spanned) - if (overflow != Overflow.VISIBLE && !shadowAdj.hasShadow) { + if (overflow != Overflow.VISIBLE && !hasShadow) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } super.onDraw(canvas) canvas.translate( - paddingLeft.toFloat() + shadowAdj.leftOffset, + paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index a6405b0357d706..541d815dd6b70f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -362,14 +362,10 @@ protected void onDraw(Canvas canvas) { setText(spanned); } - // Get shadow adjustment from custom span if configured - DiscordShadowStyleSpan.ShadowAdjustment shadowAdj = - DiscordShadowStyleSpan.getShadowAdjustment(spanned); + boolean hasShadow = DiscordShadowStyleSpan.hasShadow(spanned); - canvas.save(); - canvas.translate(shadowAdj.getLeftOffset(), 0); - - if (mOverflow != Overflow.VISIBLE && !shadowAdj.getHasShadow()) { + if (mOverflow != Overflow.VISIBLE && !hasShadow) { + canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } @@ -377,7 +373,10 @@ protected void onDraw(Canvas canvas) { StrokeStyleSpan.drawStrokePass(this, canvas); super.onDraw(canvas); - canvas.restore(); + + if (mOverflow != Overflow.VISIBLE && !hasShadow) { + canvas.restore(); + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt index 76afe866758b15..27a3dbfbcbf195 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt @@ -7,151 +7,32 @@ package com.facebook.react.views.text.internal.span -import android.graphics.Canvas -import android.graphics.Paint import android.text.Spanned -import android.text.style.ReplacementSpan -import kotlin.math.max +import android.text.TextPaint +import android.text.style.CharacterStyle /** - * A span that applies text shadow with proper bounds calculation. - * Extends ReplacementSpan to control measurement and drawing, ensuring shadows render correctly. - * This is Discord's custom implementation that contains all shadow logic. + * A span that applies text shadow via CharacterStyle. + * Uses CharacterStyle to preserve ellipsis behavior - shadow is applied via setShadowLayer. + * Shadow overflows the text bounds (like web behavior) - user adds padding if needed. */ public class DiscordShadowStyleSpan( private val dx: Float, private val dy: Float, private val radius: Float, private val color: Int -) : ReplacementSpan(), ReactSpan { +) : CharacterStyle(), ReactSpan { - // Getters for shadow properties (used by getShadowAdjustment) - public fun getShadowRadius(): Float = radius - public fun getShadowDx(): Float = dx - - override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - val width = paint.measureText(text, start, end) - - if (fm != null) { - paint.getFontMetricsInt(fm) - - val shadowTopNeeded = max(0f, radius - dy) - val shadowBottomNeeded = max(0f, radius + dy) - - val topExpansion = shadowTopNeeded.toInt() - val bottomExpansion = shadowBottomNeeded.toInt() - - // Adjust font metrics to account for shadow - fm.top -= topExpansion - fm.ascent -= topExpansion - fm.descent += bottomExpansion - fm.bottom += bottomExpansion - } - - val shadowLeftNeeded = max(0f, radius - dx) - val shadowRightNeeded = max(0f, radius + dx) - - // Subtract 1 pixel to prevent TextView ellipsization while keeping shadow mostly intact - return (width + shadowLeftNeeded + shadowRightNeeded).toInt() - 1 - } - - override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return - - val textToDraw = text.subSequence(start, end).toString() - - // Offset text to keep shadow in positive coordinates - val shadowLeftNeeded = max(0f, radius - dx) - - // Store original shadow settings - val originalShadowRadius = paint.shadowLayerRadius - val originalShadowDx = paint.shadowLayerDx - val originalShadowDy = paint.shadowLayerDy - val originalShadowColor = paint.shadowLayerColor - - paint.setShadowLayer(radius, dx, dy, color) - - if (text is Spanned && paint is android.text.TextPaint) { - val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) - for (span in spans) { - if (span !is DiscordShadowStyleSpan) { - span.updateDrawState(paint) - } - } - } - - // Offset text by shadowLeftNeeded to keep shadow in positive coordinates - // The view will compensate with canvas translation - canvas.drawText(textToDraw, x + shadowLeftNeeded, y.toFloat(), paint) - - // Restore original shadow settings - if (originalShadowRadius > 0f) { - paint.setShadowLayer( - originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) - } else { - paint.clearShadowLayer() - } - } - - /** - * Result class for shadow adjustment calculation. - * Contains the horizontal offset needed to compensate for shadow positioning - * and whether a shadow is present. - */ - public data class ShadowAdjustment( - val leftOffset: Float, - val hasShadow: Boolean - ) { - public companion object { - @JvmStatic - public val NONE: ShadowAdjustment = ShadowAdjustment(0f, false) - } + override fun updateDrawState(textPaint: TextPaint) { + textPaint.setShadowLayer(radius, dx, dy, color) } public companion object { - /** - * Helper method for ReactTextView and PreparedLayoutTextView to get shadow adjustment values. - * Calculates the horizontal offset needed to compensate for shadow positioning - * when the span offsets text to keep shadows in positive coordinates. - * - * @param spanned The text to check for shadow spans, or null if no text - * @return ShadowAdjustment with negative leftOffset (ready to use in canvas.translate) - */ @JvmStatic - public fun getShadowAdjustment(spanned: Spanned?): ShadowAdjustment { - if (spanned == null) { - return ShadowAdjustment.NONE - } - + public fun hasShadow(spanned: Spanned?): Boolean { + if (spanned == null) return false val spans = spanned.getSpans(0, spanned.length, DiscordShadowStyleSpan::class.java) - if (spans.isEmpty()) { - return ShadowAdjustment.NONE - } - - // Use the first shadow span to calculate offset - val span = spans[0] - val radius = span.getShadowRadius() - val dx = span.getShadowDx() - // Return negative offset so views can use it directly in canvas.translate - val shadowLeftOffset = -max(0f, radius - dx) - - return ShadowAdjustment(shadowLeftOffset, true) + return spans.isNotEmpty() } } } From f19505e1fac340de60b6995f2261a219a1cf042e Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 17:01:09 -0500 Subject: [PATCH 07/34] Make sure the stroke is rendered correctly on ios --- .../Text/Text/RCTTextStrokeRenderer.mm | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm index 8d90cbfeb17a5b..ff84285df8f03d 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm @@ -1,30 +1,7 @@ #import "RCTTextStrokeRenderer.h" -#import @implementation RCTTextStrokeRenderer -+ (void)drawAttributedString:(NSAttributedString *)attributedString - context:(CGContextRef)context - contentFrame:(CGRect)contentFrame - bounds:(CGRect)bounds - path:(CGPathRef)path -{ - CGContextSaveGState(context); - - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM(context, contentFrame.origin.x, bounds.size.height - contentFrame.origin.y); - CGContextScaleCTM(context, 1.0, -1.0); - - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString); - CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - CTFrameDraw(frame, context); - - CFRelease(frame); - CFRelease(framesetter); - - CGContextRestoreGState(context); -} - + (BOOL)drawStrokedText:(NSTextStorage *)textStorage contentFrame:(CGRect)contentFrame bounds:(CGRect)bounds @@ -66,25 +43,21 @@ + (BOOL)drawStrokedText:(NSTextStorage *)textStorage return NO; } + // PASS 1: Draw stroke using NSLayoutManager + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, kCGTextStroke); + CGContextSetStrokeColorWithColor(context, strokeColor.CGColor); CGContextSetLineWidth(context, strokeWidth); CGContextSetLineJoin(context, kCGLineJoinRound); CGContextSetLineCap(context, kCGLineCapRound); + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:contentFrame.origin]; + CGContextRestoreGState(context); - CGMutablePathRef path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, contentFrame.size.width, contentFrame.size.height)); - - // PASS 1: Draw stroke - NSMutableAttributedString *strokeText = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage]; - [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; - - CGContextSetTextDrawingMode(context, kCGTextStroke); - [self drawAttributedString:strokeText context:context contentFrame:contentFrame bounds:bounds path:path]; - - // PASS 2: Draw fill on top + // PASS 2: Draw fill on top using NSLayoutManager + CGContextSaveGState(context); CGContextSetTextDrawingMode(context, kCGTextFill); - [self drawAttributedString:textStorage context:context contentFrame:contentFrame bounds:bounds path:path]; - - CGPathRelease(path); + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:contentFrame.origin]; + CGContextRestoreGState(context); return YES; } From b40ad489b6bfbe9f2b43ce524d7c6b6a930f5bd5 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 17:25:28 -0500 Subject: [PATCH 08/34] Make sure the stroke uses the correct color --- .../Text/Text/RCTTextStrokeRenderer.h | 4 +-- .../Text/Text/RCTTextStrokeRenderer.mm | 31 ++++++++++++++++--- .../Libraries/Text/Text/RCTTextView.mm | 4 +-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h index a1fffd8901a2f5..f133e9e3911e1a 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h @@ -3,8 +3,6 @@ @interface RCTTextStrokeRenderer : NSObject + (BOOL)drawStrokedText:(NSTextStorage *)textStorage - contentFrame:(CGRect)contentFrame - bounds:(CGRect)bounds - context:(CGContextRef)context; + contentFrame:(CGRect)contentFrame; @end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm index ff84285df8f03d..da98d579bc3e70 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm @@ -4,9 +4,8 @@ @implementation RCTTextStrokeRenderer + (BOOL)drawStrokedText:(NSTextStorage *)textStorage contentFrame:(CGRect)contentFrame - bounds:(CGRect)bounds - context:(CGContextRef)context { + CGContextRef context = UIGraphicsGetCurrentContext(); if (!textStorage || textStorage.length == 0 || !context) { return NO; } @@ -43,16 +42,40 @@ + (BOOL)drawStrokedText:(NSTextStorage *)textStorage return NO; } - // PASS 1: Draw stroke using NSLayoutManager + // 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); - CGContextSetStrokeColorWithColor(context, strokeColor.CGColor); 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); diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index 9028cb7bdc377d..a334c1c30cd896 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -124,9 +124,7 @@ - (void)drawRect:(CGRect)rect // Use stroke renderer if stroke attributes are present, otherwise use standard glyph drawing BOOL didDrawStroke = [RCTTextStrokeRenderer drawStrokedText:_textStorage - contentFrame:_contentFrame - bounds:self.bounds - context:context]; + contentFrame:_contentFrame]; if (!didDrawStroke) { [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin]; From e2a59a6d13287ec35c5f5360569aafa066574a5c Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 19:29:58 -0500 Subject: [PATCH 09/34] Add some logs --- .../views/text/PreparedLayoutTextView.kt | 20 ++-- .../react/views/text/ReactTextView.java | 20 ++-- .../internal/span/DiscordShadowStyleSpan.kt | 95 ++++++++++++++++--- .../text/internal/span/StrokeStyleSpan.kt | 53 +++++++++-- 4 files changed, 159 insertions(+), 29 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 66ecf9df731d7e..8803a7297313d0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -13,6 +13,7 @@ import android.graphics.Paint import android.graphics.Path import android.graphics.Rect import android.os.Build +import android.util.Log import android.text.Layout import android.text.Spanned import android.text.style.ClickableSpan @@ -103,10 +104,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re override fun onDraw(canvas: Canvas) { val layout = preparedLayout?.layout - val spanned = layout?.text as? Spanned - val hasShadow = DiscordShadowStyleSpan.hasShadow(spanned) - - if (overflow != Overflow.VISIBLE && !hasShadow) { + if (overflow != Overflow.VISIBLE) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } @@ -122,14 +120,24 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context)) } - // Draw stroke pass first (underneath), then fill pass - StrokeStyleSpan.drawStrokePassWithLayout(layout, canvas, 0f, 0f) + Log.d("PreparedLayoutTextView", "onDraw: Starting draw passes, canvas.isHardwareAccelerated=${canvas.isHardwareAccelerated}") + + // Draw shadow pass first (underneath everything) + val didDrawShadow = DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) + Log.d("PreparedLayoutTextView", "onDraw: Shadow pass result=$didDrawShadow") + + // Draw stroke pass (underneath fill) + val didDrawStroke = StrokeStyleSpan.drawStrokePassWithLayout(layout, canvas, 0f, 0f) + Log.d("PreparedLayoutTextView", "onDraw: Stroke pass result=$didDrawStroke") + // Draw fill pass on top + Log.d("PreparedLayoutTextView", "onDraw: About to draw fill pass") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) } else { layout.draw(canvas, selection?.path, selectionPaint, 0) } + Log.d("PreparedLayoutTextView", "onDraw: Finished fill pass") } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 541d815dd6b70f..fcdc0d3d90ee99 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -362,19 +362,27 @@ protected void onDraw(Canvas canvas) { setText(spanned); } - boolean hasShadow = DiscordShadowStyleSpan.hasShadow(spanned); - - if (mOverflow != Overflow.VISIBLE && !hasShadow) { + if (mOverflow != Overflow.VISIBLE) { canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } - // Draw stroke pass first (underneath), then fill pass via super.onDraw - StrokeStyleSpan.drawStrokePass(this, canvas); + FLog.d("ReactTextView", "onDraw: Starting draw passes, canvas.isHardwareAccelerated=" + canvas.isHardwareAccelerated()); + + // Draw shadow pass first (underneath everything) + boolean didDrawShadow = DiscordShadowStyleSpan.drawShadowPass(this, canvas); + FLog.d("ReactTextView", "onDraw: Shadow pass result=" + didDrawShadow); + + // Draw stroke pass (underneath fill) + boolean didDrawStroke = StrokeStyleSpan.drawStrokePass(this, canvas); + FLog.d("ReactTextView", "onDraw: Stroke pass result=" + didDrawStroke); + // Draw fill pass on top + FLog.d("ReactTextView", "onDraw: About to call super.onDraw()"); super.onDraw(canvas); + FLog.d("ReactTextView", "onDraw: Finished super.onDraw()"); - if (mOverflow != Overflow.VISIBLE && !hasShadow) { + if (mOverflow != Overflow.VISIBLE) { canvas.restore(); } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt index 27a3dbfbcbf195..a8f7fa52c804d1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt @@ -7,32 +7,105 @@ package com.facebook.react.views.text.internal.span +import android.graphics.Canvas import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle +import android.widget.TextView /** - * A span that applies text shadow via CharacterStyle. - * Uses CharacterStyle to preserve ellipsis behavior - shadow is applied via setShadowLayer. - * Shadow overflows the text bounds (like web behavior) - user adds padding if needed. + * A span that applies text shadow via view-level rendering. + * Uses CharacterStyle to preserve ellipsis behavior - actual shadow rendering is done + * by the companion object's drawShadowPass method called from the view's onDraw. + * Shadow overflows the text bounds (like web behavior). */ public class DiscordShadowStyleSpan( - private val dx: Float, - private val dy: Float, - private val radius: Float, - private val color: Int + public val dx: Float, + public val dy: Float, + public val radius: Float, + public val color: Int ) : CharacterStyle(), ReactSpan { override fun updateDrawState(textPaint: TextPaint) { - textPaint.setShadowLayer(radius, dx, dy, color) + // No-op: shadow rendering is handled at view level via drawShadowPass } public companion object { @JvmStatic - public fun hasShadow(spanned: Spanned?): Boolean { - if (spanned == null) return false + public fun getShadowSpan(spanned: Spanned?): DiscordShadowStyleSpan? { + if (spanned == null) return null val spans = spanned.getSpans(0, spanned.length, DiscordShadowStyleSpan::class.java) - return spans.isNotEmpty() + return spans.firstOrNull() + } + + @JvmStatic + public fun hasShadow(spanned: Spanned?): Boolean { + return getShadowSpan(spanned) != null + } + + @JvmStatic + public fun drawShadowPass(textView: TextView, canvas: Canvas): Boolean { + val text = textView.text + if (text !is Spanned) return false + + val span = getShadowSpan(text) ?: return false + val layout = textView.layout ?: return false + + val paint = textView.paint + + val originalShadowRadius = paint.shadowLayerRadius + val originalShadowDx = paint.shadowLayerDx + val originalShadowDy = paint.shadowLayerDy + val originalShadowColor = paint.shadowLayerColor + + paint.setShadowLayer(span.radius, span.dx, span.dy, span.color) + + canvas.save() + canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) + layout.draw(canvas) + canvas.restore() + + if (originalShadowRadius > 0f) { + paint.setShadowLayer(originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) + } else { + paint.clearShadowLayer() + } + + return true + } + + @JvmStatic + public fun drawShadowPassWithLayout( + layout: android.text.Layout, + canvas: Canvas, + offsetX: Float, + offsetY: Float + ): Boolean { + val text = layout.text + if (text !is Spanned) return false + + val span = getShadowSpan(text) ?: return false + val paint = layout.paint + + val originalShadowRadius = paint.shadowLayerRadius + val originalShadowDx = paint.shadowLayerDx + val originalShadowDy = paint.shadowLayerDy + val originalShadowColor = paint.shadowLayerColor + + paint.setShadowLayer(span.radius, span.dx, span.dy, span.color) + + canvas.save() + canvas.translate(offsetX, offsetY) + layout.draw(canvas) + canvas.restore() + + if (originalShadowRadius > 0f) { + paint.setShadowLayer(originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) + } else { + paint.clearShadowLayer() + } + + return true } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 2d8b2c8d63b47a..df061cbf2abd9a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -5,6 +5,7 @@ import android.graphics.Paint import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle +import android.util.Log import android.widget.TextView /** @@ -22,6 +23,8 @@ public class StrokeStyleSpan( } public companion object { + private const val TAG = "StrokeStyleSpan" + @JvmStatic public fun getStrokeSpan(spanned: Spanned?): StrokeStyleSpan? { if (spanned == null) return null @@ -37,13 +40,27 @@ public class StrokeStyleSpan( @JvmStatic public fun drawStrokePass(textView: TextView, canvas: Canvas): Boolean { val text = textView.text - if (text !is Spanned) return false - - val span = getStrokeSpan(text) ?: return false - val layout = textView.layout ?: return false + if (text !is Spanned) { + Log.d(TAG, "drawStrokePass: text is not Spanned") + return false + } + + val span = getStrokeSpan(text) + if (span == null) { + Log.d(TAG, "drawStrokePass: no stroke span found") + return false + } + + val layout = textView.layout + if (layout == null) { + Log.d(TAG, "drawStrokePass: layout is null") + return false + } val paint = textView.paint + Log.d(TAG, "drawStrokePass: BEFORE - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + val originalStyle = paint.style val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth @@ -56,17 +73,24 @@ public class StrokeStyleSpan( paint.strokeCap = Paint.Cap.ROUND paint.color = span.strokeColor + Log.d(TAG, "drawStrokePass: AFTER SET - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + Log.d(TAG, "drawStrokePass: canvas.isHardwareAccelerated=${canvas.isHardwareAccelerated}") + canvas.save() canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) layout.draw(canvas) canvas.restore() + Log.d(TAG, "drawStrokePass: AFTER DRAW - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + paint.style = originalStyle paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap + Log.d(TAG, "drawStrokePass: RESTORED - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + return true } @@ -78,11 +102,21 @@ public class StrokeStyleSpan( offsetY: Float ): Boolean { val text = layout.text - if (text !is Spanned) return false + if (text !is Spanned) { + Log.d(TAG, "drawStrokePassWithLayout: text is not Spanned") + return false + } + + val span = getStrokeSpan(text) + if (span == null) { + Log.d(TAG, "drawStrokePassWithLayout: no stroke span found") + return false + } - val span = getStrokeSpan(text) ?: return false val paint = layout.paint + Log.d(TAG, "drawStrokePassWithLayout: BEFORE - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + val originalStyle = paint.style val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth @@ -95,17 +129,24 @@ public class StrokeStyleSpan( paint.strokeCap = Paint.Cap.ROUND paint.color = span.strokeColor + Log.d(TAG, "drawStrokePassWithLayout: AFTER SET - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + Log.d(TAG, "drawStrokePassWithLayout: canvas.isHardwareAccelerated=${canvas.isHardwareAccelerated}") + canvas.save() canvas.translate(offsetX, offsetY) layout.draw(canvas) canvas.restore() + Log.d(TAG, "drawStrokePassWithLayout: AFTER DRAW - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + paint.style = originalStyle paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap + Log.d(TAG, "drawStrokePassWithLayout: RESTORED - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") + return true } } From 51c4654bf4988b674b33d51ab29b2f4a3d413126 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 20:19:30 -0500 Subject: [PATCH 10/34] Add more logs --- .../text/internal/span/StrokeStyleSpan.kt | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index df061cbf2abd9a..f0f370a02eb0f3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -53,7 +53,7 @@ public class StrokeStyleSpan( val layout = textView.layout if (layout == null) { - Log.d(TAG, "drawStrokePass: layout is null") + Log.d(TAG, "drawStrokePass: layout is null, text=${textView.text?.toString()?.take(20)}") return false } @@ -78,7 +78,20 @@ public class StrokeStyleSpan( canvas.save() canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) - layout.draw(canvas) + + // Draw each line manually with canvas.drawText to ensure Paint.Style.STROKE is respected + val text = layout.text.toString() + for (line in 0 until layout.lineCount) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineVisibleEnd(line) + if (lineStart < lineEnd) { + val lineText = text.substring(lineStart, lineEnd) + val x = layout.getLineLeft(line) + val y = layout.getLineBaseline(line).toFloat() + canvas.drawText(lineText, x, y, paint) + } + } + canvas.restore() Log.d(TAG, "drawStrokePass: AFTER DRAW - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") @@ -134,7 +147,20 @@ public class StrokeStyleSpan( canvas.save() canvas.translate(offsetX, offsetY) - layout.draw(canvas) + + // Draw each line manually with canvas.drawText to ensure Paint.Style.STROKE is respected + val text = layout.text.toString() + for (line in 0 until layout.lineCount) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineVisibleEnd(line) + if (lineStart < lineEnd) { + val lineText = text.substring(lineStart, lineEnd) + val x = layout.getLineLeft(line) + val y = layout.getLineBaseline(line).toFloat() + canvas.drawText(lineText, x, y, paint) + } + } + canvas.restore() Log.d(TAG, "drawStrokePassWithLayout: AFTER DRAW - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") From 897ab541b63ef336b3ddeda105919f67c15be818 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 20:34:22 -0500 Subject: [PATCH 11/34] Fix the build --- .../react/views/text/internal/span/StrokeStyleSpan.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index f0f370a02eb0f3..ec48334eebf505 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -80,12 +80,12 @@ public class StrokeStyleSpan( canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) // Draw each line manually with canvas.drawText to ensure Paint.Style.STROKE is respected - val text = layout.text.toString() + val textString = layout.text.toString() for (line in 0 until layout.lineCount) { val lineStart = layout.getLineStart(line) val lineEnd = layout.getLineVisibleEnd(line) if (lineStart < lineEnd) { - val lineText = text.substring(lineStart, lineEnd) + val lineText = textString.substring(lineStart, lineEnd) val x = layout.getLineLeft(line) val y = layout.getLineBaseline(line).toFloat() canvas.drawText(lineText, x, y, paint) @@ -149,12 +149,12 @@ public class StrokeStyleSpan( canvas.translate(offsetX, offsetY) // Draw each line manually with canvas.drawText to ensure Paint.Style.STROKE is respected - val text = layout.text.toString() + val textString = layout.text.toString() for (line in 0 until layout.lineCount) { val lineStart = layout.getLineStart(line) val lineEnd = layout.getLineVisibleEnd(line) if (lineStart < lineEnd) { - val lineText = text.substring(lineStart, lineEnd) + val lineText = textString.substring(lineStart, lineEnd) val x = layout.getLineLeft(line) val y = layout.getLineBaseline(line).toFloat() canvas.drawText(lineText, x, y, paint) From c37cce13b332daaab30ccb69a553c98c57217d2a Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 21:26:03 -0500 Subject: [PATCH 12/34] Update implementation of text stroke effect --- .../views/text/PreparedLayoutTextView.kt | 29 +++--- .../react/views/text/ReactTextView.java | 18 ++-- .../text/internal/span/StrokeStyleSpan.kt | 99 ++++++++++--------- 3 files changed, 72 insertions(+), 74 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 8803a7297313d0..5788af8d504d93 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -13,7 +13,6 @@ import android.graphics.Paint import android.graphics.Path import android.graphics.Rect import android.os.Build -import android.util.Log import android.text.Layout import android.text.Spanned import android.text.style.ClickableSpan @@ -120,24 +119,20 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context)) } - Log.d("PreparedLayoutTextView", "onDraw: Starting draw passes, canvas.isHardwareAccelerated=${canvas.isHardwareAccelerated}") - // Draw shadow pass first (underneath everything) - val didDrawShadow = DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) - Log.d("PreparedLayoutTextView", "onDraw: Shadow pass result=$didDrawShadow") - - // Draw stroke pass (underneath fill) - val didDrawStroke = StrokeStyleSpan.drawStrokePassWithLayout(layout, canvas, 0f, 0f) - Log.d("PreparedLayoutTextView", "onDraw: Stroke pass result=$didDrawStroke") - - // Draw fill pass on top - Log.d("PreparedLayoutTextView", "onDraw: About to draw fill pass") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) - } else { - layout.draw(canvas, selection?.path, selectionPaint, 0) + DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) + + // Draw stroke+fill pass if stroke is present, otherwise use standard drawing + val didDrawStroke = StrokeStyleSpan.drawStrokeAndFillPassWithLayout(layout, canvas, 0f, 0f) + + if (!didDrawStroke) { + // No stroke, use standard text drawing + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) + } else { + layout.draw(canvas, selection?.path, selectionPaint, 0) + } } - Log.d("PreparedLayoutTextView", "onDraw: Finished fill pass") } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index fcdc0d3d90ee99..2c07bd41efd790 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -367,20 +367,16 @@ protected void onDraw(Canvas canvas) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } - FLog.d("ReactTextView", "onDraw: Starting draw passes, canvas.isHardwareAccelerated=" + canvas.isHardwareAccelerated()); - // Draw shadow pass first (underneath everything) - boolean didDrawShadow = DiscordShadowStyleSpan.drawShadowPass(this, canvas); - FLog.d("ReactTextView", "onDraw: Shadow pass result=" + didDrawShadow); + DiscordShadowStyleSpan.drawShadowPass(this, canvas); - // Draw stroke pass (underneath fill) - boolean didDrawStroke = StrokeStyleSpan.drawStrokePass(this, canvas); - FLog.d("ReactTextView", "onDraw: Stroke pass result=" + didDrawStroke); + // Draw stroke+fill pass if stroke is present, otherwise use standard drawing + boolean didDrawStroke = StrokeStyleSpan.drawStrokeAndFillPass(this, canvas); - // Draw fill pass on top - FLog.d("ReactTextView", "onDraw: About to call super.onDraw()"); - super.onDraw(canvas); - FLog.d("ReactTextView", "onDraw: Finished super.onDraw()"); + if (!didDrawStroke) { + // No stroke, use standard text drawing + super.onDraw(canvas); + } if (mOverflow != Overflow.VISIBLE) { canvas.restore(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index ec48334eebf505..6d934fcc5b7c98 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -5,7 +5,6 @@ import android.graphics.Paint import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle -import android.util.Log import android.widget.TextView /** @@ -23,8 +22,6 @@ public class StrokeStyleSpan( } public companion object { - private const val TAG = "StrokeStyleSpan" - @JvmStatic public fun getStrokeSpan(spanned: Spanned?): StrokeStyleSpan? { if (spanned == null) return null @@ -37,50 +34,56 @@ public class StrokeStyleSpan( return getStrokeSpan(spanned) != null } + /** + * Draws both stroke and fill passes for text with stroke styling. + * Returns true if stroke was drawn (caller should skip normal text drawing). + */ @JvmStatic - public fun drawStrokePass(textView: TextView, canvas: Canvas): Boolean { + public fun drawStrokeAndFillPass(textView: TextView, canvas: Canvas): Boolean { val text = textView.text if (text !is Spanned) { - Log.d(TAG, "drawStrokePass: text is not Spanned") return false } - val span = getStrokeSpan(text) - if (span == null) { - Log.d(TAG, "drawStrokePass: no stroke span found") - return false - } + val span = getStrokeSpan(text) ?: return false - val layout = textView.layout - if (layout == null) { - Log.d(TAG, "drawStrokePass: layout is null, text=${textView.text?.toString()?.take(20)}") - return false - } + val layout = textView.layout ?: return false val paint = textView.paint - Log.d(TAG, "drawStrokePass: BEFORE - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - val originalStyle = paint.style val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth val originalStrokeJoin = paint.strokeJoin val originalStrokeCap = paint.strokeCap + canvas.save() + canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) + + // PASS 1: Draw stroke (underneath) paint.style = Paint.Style.STROKE paint.strokeWidth = span.strokeWidth paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND paint.color = span.strokeColor - Log.d(TAG, "drawStrokePass: AFTER SET - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - Log.d(TAG, "drawStrokePass: canvas.isHardwareAccelerated=${canvas.isHardwareAccelerated}") + val textString = layout.text.toString() + for (line in 0 until layout.lineCount) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineVisibleEnd(line) + if (lineStart < lineEnd) { + val lineText = textString.substring(lineStart, lineEnd) + val x = layout.getLineLeft(line) + val y = layout.getLineBaseline(line).toFloat() + canvas.drawText(lineText, x, y, paint) + } + } - canvas.save() - canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) + // PASS 2: Draw fill on top + paint.style = Paint.Style.FILL + paint.color = originalColor + paint.strokeWidth = originalStrokeWidth - // Draw each line manually with canvas.drawText to ensure Paint.Style.STROKE is respected - val textString = layout.text.toString() for (line in 0 until layout.lineCount) { val lineStart = layout.getLineStart(line) val lineEnd = layout.getLineVisibleEnd(line) @@ -94,21 +97,22 @@ public class StrokeStyleSpan( canvas.restore() - Log.d(TAG, "drawStrokePass: AFTER DRAW - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - + // Restore all paint properties paint.style = originalStyle paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap - Log.d(TAG, "drawStrokePass: RESTORED - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - return true } + /** + * Draws both stroke and fill passes for layout with stroke styling. + * Returns true if stroke was drawn (caller should skip normal text drawing). + */ @JvmStatic - public fun drawStrokePassWithLayout( + public fun drawStrokeAndFillPassWithLayout( layout: android.text.Layout, canvas: Canvas, offsetX: Float, @@ -116,40 +120,46 @@ public class StrokeStyleSpan( ): Boolean { val text = layout.text if (text !is Spanned) { - Log.d(TAG, "drawStrokePassWithLayout: text is not Spanned") return false } - val span = getStrokeSpan(text) - if (span == null) { - Log.d(TAG, "drawStrokePassWithLayout: no stroke span found") - return false - } + val span = getStrokeSpan(text) ?: return false val paint = layout.paint - Log.d(TAG, "drawStrokePassWithLayout: BEFORE - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - val originalStyle = paint.style val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth val originalStrokeJoin = paint.strokeJoin val originalStrokeCap = paint.strokeCap + canvas.save() + canvas.translate(offsetX, offsetY) + + // PASS 1: Draw stroke (underneath) paint.style = Paint.Style.STROKE paint.strokeWidth = span.strokeWidth paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND paint.color = span.strokeColor - Log.d(TAG, "drawStrokePassWithLayout: AFTER SET - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - Log.d(TAG, "drawStrokePassWithLayout: canvas.isHardwareAccelerated=${canvas.isHardwareAccelerated}") + val textString = layout.text.toString() + for (line in 0 until layout.lineCount) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineVisibleEnd(line) + if (lineStart < lineEnd) { + val lineText = textString.substring(lineStart, lineEnd) + val x = layout.getLineLeft(line) + val y = layout.getLineBaseline(line).toFloat() + canvas.drawText(lineText, x, y, paint) + } + } - canvas.save() - canvas.translate(offsetX, offsetY) + // PASS 2: Draw fill on top + paint.style = Paint.Style.FILL + paint.color = originalColor + paint.strokeWidth = originalStrokeWidth - // Draw each line manually with canvas.drawText to ensure Paint.Style.STROKE is respected - val textString = layout.text.toString() for (line in 0 until layout.lineCount) { val lineStart = layout.getLineStart(line) val lineEnd = layout.getLineVisibleEnd(line) @@ -163,16 +173,13 @@ public class StrokeStyleSpan( canvas.restore() - Log.d(TAG, "drawStrokePassWithLayout: AFTER DRAW - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - + // Restore all paint properties paint.style = originalStyle paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap - Log.d(TAG, "drawStrokePassWithLayout: RESTORED - style=${paint.style}, color=${paint.color}, strokeWidth=${paint.strokeWidth}") - return true } } From 2a21b846480ec54ce79aeb62f50608c652b27afd Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 21:55:36 -0500 Subject: [PATCH 13/34] Attempt to fix stroke effect --- .../views/text/PreparedLayoutTextView.kt | 18 ++- .../react/views/text/ReactTextView.java | 10 +- .../text/internal/span/StrokeStyleSpan.kt | 104 +++++------------- 3 files changed, 40 insertions(+), 92 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 5788af8d504d93..64fbd2ca47e6fc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -122,16 +122,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re // Draw shadow pass first (underneath everything) DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) - // Draw stroke+fill pass if stroke is present, otherwise use standard drawing - val didDrawStroke = StrokeStyleSpan.drawStrokeAndFillPassWithLayout(layout, canvas, 0f, 0f) - - if (!didDrawStroke) { - // No stroke, use standard text drawing - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) - } else { - layout.draw(canvas, selection?.path, selectionPaint, 0) - } + // Draw stroke pass (underneath fill) - uses FILL_AND_STROKE with color filter + StrokeStyleSpan.drawStrokePassWithLayout(layout, canvas, 0f, 0f) + + // Draw fill pass on top + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) + } else { + layout.draw(canvas, selection?.path, selectionPaint, 0) } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 2c07bd41efd790..a2a67c2fb3abcc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -370,13 +370,11 @@ protected void onDraw(Canvas canvas) { // Draw shadow pass first (underneath everything) DiscordShadowStyleSpan.drawShadowPass(this, canvas); - // Draw stroke+fill pass if stroke is present, otherwise use standard drawing - boolean didDrawStroke = StrokeStyleSpan.drawStrokeAndFillPass(this, canvas); + // Draw stroke pass (underneath fill) - uses FILL_AND_STROKE with color filter + StrokeStyleSpan.drawStrokePass(this, canvas); - if (!didDrawStroke) { - // No stroke, use standard text drawing - super.onDraw(canvas); - } + // Draw fill pass on top + super.onDraw(canvas); if (mOverflow != Overflow.VISIBLE) { canvas.restore(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 6d934fcc5b7c98..5aaef7d4ffea26 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -35,11 +35,11 @@ public class StrokeStyleSpan( } /** - * Draws both stroke and fill passes for text with stroke styling. - * Returns true if stroke was drawn (caller should skip normal text drawing). + * Draws stroke pass for text with stroke styling using layout.draw(). + * The stroke is drawn first, then the caller draws the fill on top. */ @JvmStatic - public fun drawStrokeAndFillPass(textView: TextView, canvas: Canvas): Boolean { + public fun drawStrokePass(textView: TextView, canvas: Canvas): Boolean { val text = textView.text if (text !is Spanned) { return false @@ -52,54 +52,30 @@ public class StrokeStyleSpan( val paint = textView.paint val originalStyle = paint.style - val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth val originalStrokeJoin = paint.strokeJoin val originalStrokeCap = paint.strokeCap - canvas.save() - canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) - - // PASS 1: Draw stroke (underneath) - paint.style = Paint.Style.STROKE + // Set stroke properties on paint - layout.draw() will use this + paint.style = Paint.Style.FILL_AND_STROKE paint.strokeWidth = span.strokeWidth paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND - paint.color = span.strokeColor - - val textString = layout.text.toString() - for (line in 0 until layout.lineCount) { - val lineStart = layout.getLineStart(line) - val lineEnd = layout.getLineVisibleEnd(line) - if (lineStart < lineEnd) { - val lineText = textString.substring(lineStart, lineEnd) - val x = layout.getLineLeft(line) - val y = layout.getLineBaseline(line).toFloat() - canvas.drawText(lineText, x, y, paint) - } - } - // PASS 2: Draw fill on top - paint.style = Paint.Style.FILL - paint.color = originalColor - paint.strokeWidth = originalStrokeWidth + canvas.save() + canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) - for (line in 0 until layout.lineCount) { - val lineStart = layout.getLineStart(line) - val lineEnd = layout.getLineVisibleEnd(line) - if (lineStart < lineEnd) { - val lineText = textString.substring(lineStart, lineEnd) - val x = layout.getLineLeft(line) - val y = layout.getLineBaseline(line).toFloat() - canvas.drawText(lineText, x, y, paint) - } - } + // Use a color filter to override all text colors with the stroke color + val originalColorFilter = paint.colorFilter + paint.colorFilter = android.graphics.PorterDuffColorFilter(span.strokeColor, android.graphics.PorterDuff.Mode.SRC_IN) + + layout.draw(canvas) canvas.restore() - // Restore all paint properties + // Restore paint properties + paint.colorFilter = originalColorFilter paint.style = originalStyle - paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap @@ -108,11 +84,11 @@ public class StrokeStyleSpan( } /** - * Draws both stroke and fill passes for layout with stroke styling. - * Returns true if stroke was drawn (caller should skip normal text drawing). + * Draws stroke pass for layout with stroke styling using layout.draw(). + * The stroke is drawn first, then the caller draws the fill on top. */ @JvmStatic - public fun drawStrokeAndFillPassWithLayout( + public fun drawStrokePassWithLayout( layout: android.text.Layout, canvas: Canvas, offsetX: Float, @@ -128,54 +104,30 @@ public class StrokeStyleSpan( val paint = layout.paint val originalStyle = paint.style - val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth val originalStrokeJoin = paint.strokeJoin val originalStrokeCap = paint.strokeCap - canvas.save() - canvas.translate(offsetX, offsetY) - - // PASS 1: Draw stroke (underneath) - paint.style = Paint.Style.STROKE + // Set stroke properties on paint - layout.draw() will use this + paint.style = Paint.Style.FILL_AND_STROKE paint.strokeWidth = span.strokeWidth paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND - paint.color = span.strokeColor - - val textString = layout.text.toString() - for (line in 0 until layout.lineCount) { - val lineStart = layout.getLineStart(line) - val lineEnd = layout.getLineVisibleEnd(line) - if (lineStart < lineEnd) { - val lineText = textString.substring(lineStart, lineEnd) - val x = layout.getLineLeft(line) - val y = layout.getLineBaseline(line).toFloat() - canvas.drawText(lineText, x, y, paint) - } - } - // PASS 2: Draw fill on top - paint.style = Paint.Style.FILL - paint.color = originalColor - paint.strokeWidth = originalStrokeWidth + canvas.save() + canvas.translate(offsetX, offsetY) - for (line in 0 until layout.lineCount) { - val lineStart = layout.getLineStart(line) - val lineEnd = layout.getLineVisibleEnd(line) - if (lineStart < lineEnd) { - val lineText = textString.substring(lineStart, lineEnd) - val x = layout.getLineLeft(line) - val y = layout.getLineBaseline(line).toFloat() - canvas.drawText(lineText, x, y, paint) - } - } + // We can't modify spans, so use a color filter on paint instead + val originalColorFilter = paint.colorFilter + paint.colorFilter = android.graphics.PorterDuffColorFilter(span.strokeColor, android.graphics.PorterDuff.Mode.SRC_IN) + + layout.draw(canvas) canvas.restore() - // Restore all paint properties + // Restore paint properties + paint.colorFilter = originalColorFilter paint.style = originalStyle - paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap From abde1967e04293540637966012b9d847c6016ac5 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 22:24:21 -0500 Subject: [PATCH 14/34] Another attempt at fixing the text stroke effect --- .../text/internal/span/StrokeStyleSpan.kt | 77 +++++++------------ 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 5aaef7d4ffea26..854311f1cb6b5a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -11,14 +11,28 @@ import android.widget.TextView * A span that applies text stroke styling via view-level two-pass rendering. * Uses CharacterStyle to preserve ellipsis behavior - actual rendering is done * by the companion object's drawStrokePass method called from the view's onDraw. + * + * The stroke is achieved by: + * 1. First pass: Enable stroke mode on span, draw layout (draws stroke with stroke color) + * 2. Second pass: Disable stroke mode, draw layout normally (draws fill on top) */ public class StrokeStyleSpan( public val strokeWidth: Float, public val strokeColor: Int ) : CharacterStyle(), ReactSpan { + // When true, updateDrawState will configure paint for stroke rendering + internal var isStrokePass: Boolean = false + override fun updateDrawState(textPaint: TextPaint) { - // No-op: rendering is handled at view level via drawStrokePass + if (isStrokePass) { + textPaint.style = Paint.Style.STROKE + textPaint.strokeWidth = strokeWidth + textPaint.strokeJoin = Paint.Join.ROUND + textPaint.strokeCap = Paint.Cap.ROUND + textPaint.color = strokeColor + } + // When isStrokePass is false, don't modify paint - let normal rendering happen } public companion object { @@ -35,8 +49,7 @@ public class StrokeStyleSpan( } /** - * Draws stroke pass for text with stroke styling using layout.draw(). - * The stroke is drawn first, then the caller draws the fill on top. + * Draws stroke pass for TextView. Sets stroke mode on span, draws, then resets. */ @JvmStatic public fun drawStrokePass(textView: TextView, canvas: Canvas): Boolean { @@ -46,46 +59,27 @@ public class StrokeStyleSpan( } val span = getStrokeSpan(text) ?: return false - val layout = textView.layout ?: return false - val paint = textView.paint - - val originalStyle = paint.style - val originalStrokeWidth = paint.strokeWidth - val originalStrokeJoin = paint.strokeJoin - val originalStrokeCap = paint.strokeCap - - // Set stroke properties on paint - layout.draw() will use this - paint.style = Paint.Style.FILL_AND_STROKE - paint.strokeWidth = span.strokeWidth - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND + // Enable stroke mode on the span + span.isStrokePass = true canvas.save() canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) - // Use a color filter to override all text colors with the stroke color - val originalColorFilter = paint.colorFilter - paint.colorFilter = android.graphics.PorterDuffColorFilter(span.strokeColor, android.graphics.PorterDuff.Mode.SRC_IN) - + // Draw with stroke - the span's updateDrawState will configure the paint layout.draw(canvas) canvas.restore() - // Restore paint properties - paint.colorFilter = originalColorFilter - paint.style = originalStyle - paint.strokeWidth = originalStrokeWidth - paint.strokeJoin = originalStrokeJoin - paint.strokeCap = originalStrokeCap + // Disable stroke mode for the fill pass + span.isStrokePass = false return true } /** - * Draws stroke pass for layout with stroke styling using layout.draw(). - * The stroke is drawn first, then the caller draws the fill on top. + * Draws stroke pass for PreparedLayout. Sets stroke mode on span, draws, then resets. */ @JvmStatic public fun drawStrokePassWithLayout( @@ -101,36 +95,19 @@ public class StrokeStyleSpan( val span = getStrokeSpan(text) ?: return false - val paint = layout.paint - - val originalStyle = paint.style - val originalStrokeWidth = paint.strokeWidth - val originalStrokeJoin = paint.strokeJoin - val originalStrokeCap = paint.strokeCap - - // Set stroke properties on paint - layout.draw() will use this - paint.style = Paint.Style.FILL_AND_STROKE - paint.strokeWidth = span.strokeWidth - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND + // Enable stroke mode on the span + span.isStrokePass = true canvas.save() canvas.translate(offsetX, offsetY) - // We can't modify spans, so use a color filter on paint instead - val originalColorFilter = paint.colorFilter - paint.colorFilter = android.graphics.PorterDuffColorFilter(span.strokeColor, android.graphics.PorterDuff.Mode.SRC_IN) - + // Draw with stroke - the span's updateDrawState will configure the paint layout.draw(canvas) canvas.restore() - // Restore paint properties - paint.colorFilter = originalColorFilter - paint.style = originalStyle - paint.strokeWidth = originalStrokeWidth - paint.strokeJoin = originalStrokeJoin - paint.strokeCap = originalStrokeCap + // Disable stroke mode for the fill pass + span.isStrokePass = false return true } From 152f173ea71fdaacef29d9eb1441296a440c2e43 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Wed, 17 Dec 2025 22:51:38 -0500 Subject: [PATCH 15/34] Simplify the text stroke effect --- .../views/text/PreparedLayoutTextView.kt | 6 +- .../react/views/text/ReactTextView.java | 6 +- .../text/internal/span/StrokeStyleSpan.kt | 95 +++---------------- 3 files changed, 14 insertions(+), 93 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 64fbd2ca47e6fc..1ca1ebcceb6faa 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -29,7 +29,6 @@ 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 @@ -122,10 +121,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re // Draw shadow pass first (underneath everything) DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) - // Draw stroke pass (underneath fill) - uses FILL_AND_STROKE with color filter - StrokeStyleSpan.drawStrokePassWithLayout(layout, canvas, 0f, 0f) - - // Draw fill pass on top + // 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 { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index a2a67c2fb3abcc..0e32da18cc5b5c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -54,7 +54,6 @@ 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 com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaMeasureMode; @@ -370,10 +369,7 @@ protected void onDraw(Canvas canvas) { // Draw shadow pass first (underneath everything) DiscordShadowStyleSpan.drawShadowPass(this, canvas); - // Draw stroke pass (underneath fill) - uses FILL_AND_STROKE with color filter - StrokeStyleSpan.drawStrokePass(this, canvas); - - // Draw fill pass on top + // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) super.onDraw(canvas); if (mOverflow != Overflow.VISIBLE) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 854311f1cb6b5a..cda0908be6fa94 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -1,38 +1,31 @@ package com.facebook.react.views.text.internal.span -import android.graphics.Canvas import android.graphics.Paint import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle -import android.widget.TextView /** - * A span that applies text stroke styling via view-level two-pass rendering. - * Uses CharacterStyle to preserve ellipsis behavior - actual rendering is done - * by the companion object's drawStrokePass method called from the view's onDraw. + * A span that applies text stroke styling using FILL_AND_STROKE. * - * The stroke is achieved by: - * 1. First pass: Enable stroke mode on span, draw layout (draws stroke with stroke color) - * 2. Second pass: Disable stroke mode, draw layout normally (draws fill on top) + * This span renders text with both fill and stroke in the stroke color. + * To achieve an "outer stroke" effect, use two overlapping text views: + * 1. Bottom layer: Text with this span applied (renders stroke color) + * 2. Top layer: Normal text without this span (renders fill color) + * + * The top layer covers the fill portion, leaving only the outer stroke visible. */ public class StrokeStyleSpan( public val strokeWidth: Float, public val strokeColor: Int ) : CharacterStyle(), ReactSpan { - // When true, updateDrawState will configure paint for stroke rendering - internal var isStrokePass: Boolean = false - override fun updateDrawState(textPaint: TextPaint) { - if (isStrokePass) { - textPaint.style = Paint.Style.STROKE - textPaint.strokeWidth = strokeWidth - textPaint.strokeJoin = Paint.Join.ROUND - textPaint.strokeCap = Paint.Cap.ROUND - textPaint.color = strokeColor - } - // When isStrokePass is false, don't modify paint - let normal rendering happen + textPaint.style = Paint.Style.FILL_AND_STROKE + textPaint.strokeWidth = strokeWidth + textPaint.strokeJoin = Paint.Join.ROUND + textPaint.strokeCap = Paint.Cap.ROUND + textPaint.color = strokeColor } public companion object { @@ -47,69 +40,5 @@ public class StrokeStyleSpan( public fun hasStroke(spanned: Spanned?): Boolean { return getStrokeSpan(spanned) != null } - - /** - * Draws stroke pass for TextView. Sets stroke mode on span, draws, then resets. - */ - @JvmStatic - public fun drawStrokePass(textView: TextView, canvas: Canvas): Boolean { - val text = textView.text - if (text !is Spanned) { - return false - } - - val span = getStrokeSpan(text) ?: return false - val layout = textView.layout ?: return false - - // Enable stroke mode on the span - span.isStrokePass = true - - canvas.save() - canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) - - // Draw with stroke - the span's updateDrawState will configure the paint - layout.draw(canvas) - - canvas.restore() - - // Disable stroke mode for the fill pass - span.isStrokePass = false - - return true - } - - /** - * Draws stroke pass for PreparedLayout. Sets stroke mode on span, draws, then resets. - */ - @JvmStatic - public fun drawStrokePassWithLayout( - layout: android.text.Layout, - canvas: Canvas, - offsetX: Float, - offsetY: Float - ): Boolean { - val text = layout.text - if (text !is Spanned) { - return false - } - - val span = getStrokeSpan(text) ?: return false - - // Enable stroke mode on the span - span.isStrokePass = true - - canvas.save() - canvas.translate(offsetX, offsetY) - - // Draw with stroke - the span's updateDrawState will configure the paint - layout.draw(canvas) - - canvas.restore() - - // Disable stroke mode for the fill pass - span.isStrokePass = false - - return true - } } } From b070bb4074de15402313a0bd67d60b370d6cbac7 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 11:14:03 -0500 Subject: [PATCH 16/34] Attempt to fix the stroke effect v3 --- .../facebook/react/views/text/PreparedLayoutTextView.kt | 9 +++++++-- .../react/views/text/ReactBaseTextShadowNode.java | 2 +- .../com/facebook/react/views/text/ReactTextView.java | 2 +- .../com/facebook/react/views/text/TextLayoutManager.kt | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 1ca1ebcceb6faa..c667f27b8a16d4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -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 @@ -59,6 +59,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() + // Disable clipToPadding if text has stroke spans to prevent stroke from being clipped + val hasStroke = (value?.layout?.text as? Spanned)?.let { StrokeStyleSpan.hasStroke(it) } ?: false + clipToPadding = !hasStroke + field = value invalidate() } @@ -97,6 +101,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re initView() BackgroundStyleApplicator.reset(this) overflow = Overflow.HIDDEN + clipToPadding = true } override fun onDraw(canvas: Canvas) { @@ -119,7 +124,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } // Draw shadow pass first (underneath everything) - DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) + //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) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index f97dbed7a34ffd..bf11909b68de04 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -234,7 +234,7 @@ private static void buildSpannedFromShadowNode( new SetSpanOperation( start, end, - new DiscordShadowStyleSpan( + new ShadowStyleSpan( textShadowNode.mTextShadowOffsetDx, textShadowNode.mTextShadowOffsetDy, textShadowNode.mTextShadowRadius, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 0e32da18cc5b5c..bd422f1912cc29 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -367,7 +367,7 @@ protected void onDraw(Canvas canvas) { } // Draw shadow pass first (underneath everything) - DiscordShadowStyleSpan.drawShadowPass(this, canvas); + //DiscordShadowStyleSpan.drawShadowPass(this, canvas); // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) super.onDraw(canvas); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 735dd1f87488eb..84b2d7a7b4da21 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -306,7 +306,7 @@ internal object TextLayoutManager { SetSpanOperation( start, end, - DiscordShadowStyleSpan( + ShadowStyleSpan( textAttributes.mTextShadowOffsetDx, textAttributes.mTextShadowOffsetDy, textAttributes.mTextShadowRadius, @@ -470,7 +470,7 @@ internal object TextLayoutManager { fragment.props.textShadowRadius != 0f) && Color.alpha(fragment.props.textShadowColor) != 0) { spannable.setSpan( - DiscordShadowStyleSpan( + ShadowStyleSpan( fragment.props.textShadowOffsetDx, fragment.props.textShadowOffsetDy, fragment.props.textShadowRadius, From 6ece2a3cf6606eefa4b50c23e2bf306a4b0e7dd8 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 11:42:09 -0500 Subject: [PATCH 17/34] Fix the build issues --- .../java/com/facebook/react/views/text/TextLayoutManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 84b2d7a7b4da21..81bff6ceb93627 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -47,8 +47,8 @@ import com.facebook.react.views.text.internal.span.ReactOpacitySpan import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan import com.facebook.react.views.text.internal.span.ReactTagSpan import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan import com.facebook.react.views.text.internal.span.ReactUnderlineSpan +import com.facebook.react.views.text.internal.span.ShadowStyleSpan import com.facebook.react.views.text.internal.span.SetSpanOperation import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan From 51fc7a01deb0184c62dfa87e77ce7fe81f9778eb Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 12:13:46 -0500 Subject: [PATCH 18/34] Fix imports --- .../com/facebook/react/views/text/ReactBaseTextShadowNode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index bf11909b68de04..bf75eb4ecbd284 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -48,7 +48,7 @@ import com.facebook.react.views.text.internal.span.ReactTagSpan; 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.DiscordShadowStyleSpan; +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; From 76094c975abc0fa0d4cce92105e12277d4afe80e Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 16:27:14 -0500 Subject: [PATCH 19/34] Attempt to fix text stroke effect by expanding clip bounds --- .../views/text/PreparedLayoutTextView.kt | 29 ++++++++++++++--- .../react/views/text/ReactTextView.java | 31 +++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index c667f27b8a16d4..d35ae44ad07f8e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -43,6 +43,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re private var clickableSpans: List = emptyList() private var selection: TextSelection? = null + private var strokeWidth: Float = 0f var preparedLayout: PreparedLayout? = null set(value) { @@ -59,9 +60,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() - // Disable clipToPadding if text has stroke spans to prevent stroke from being clipped - val hasStroke = (value?.layout?.text as? Spanned)?.let { StrokeStyleSpan.hasStroke(it) } ?: false - clipToPadding = !hasStroke + // 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 field = value invalidate() @@ -95,6 +97,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = emptyList() selection = null preparedLayout = null + strokeWidth = 0f } fun recycleView(): Unit { @@ -106,8 +109,22 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re override fun onDraw(canvas: Canvas) { val layout = preparedLayout?.layout + val hasStroke = strokeWidth > 0f + + // Save and expand clip bounds to allow stroke overflow + if (hasStroke) { + canvas.save() + val extra = strokeWidth + canvas.clipRect( + scrollX.toFloat() - extra, + scrollY.toFloat() - extra, + scrollX.toFloat() + width.toFloat() + extra, + scrollY.toFloat() + height.toFloat() + extra, + android.graphics.Region.Op.REPLACE) + } - if (overflow != Overflow.VISIBLE) { + // Skip clipping when stroke spans are present to prevent stroke from being clipped + if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } @@ -133,6 +150,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re layout.draw(canvas, selection?.path, selectionPaint, 0) } } + + if (hasStroke) { + canvas.restore() + } } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index bd422f1912cc29..9167bed0267051 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -10,6 +10,7 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; +import android.graphics.Region; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; @@ -52,8 +53,8 @@ import com.facebook.react.uimanager.style.BorderStyle; import com.facebook.react.uimanager.style.LogicalEdge; 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 com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaMeasureMode; @@ -78,6 +79,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private boolean mTextIsSelectable; private boolean mShouldAdjustSpannableFontSize; private Overflow mOverflow = Overflow.VISIBLE; + private float mStrokeWidth = 0f; private @Nullable Spannable mSpanned; @@ -103,6 +105,7 @@ private void initView() { mLetterSpacing = 0.f; mOverflow = Overflow.VISIBLE; mSpanned = null; + mStrokeWidth = 0f; } /* package */ void recycleView() { @@ -361,7 +364,21 @@ protected void onDraw(Canvas canvas) { setText(spanned); } - if (mOverflow != Overflow.VISIBLE) { + boolean hasStroke = mStrokeWidth > 0f; + + // Save and expand clip bounds to allow stroke overflow + if (hasStroke) { + canvas.save(); + float extra = mStrokeWidth; + canvas.clipRect( + getScrollX() - extra, + getScrollY() - extra, + getScrollX() + getWidth() + extra, + getScrollY() + getHeight() + extra, + Region.Op.REPLACE); + } + + if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } @@ -372,7 +389,11 @@ protected void onDraw(Canvas canvas) { // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) super.onDraw(canvas); - if (mOverflow != Overflow.VISIBLE) { + if (mOverflow != Overflow.VISIBLE && !hasStroke) { + canvas.restore(); + } + + if (hasStroke) { canvas.restore(); } } @@ -718,6 +739,10 @@ public void setBorderStyle(@Nullable String style) { public void setSpanned(Spannable spanned) { mSpanned = spanned; mShouldAdjustSpannableFontSize = true; + + // Check for stroke span and store stroke width + StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(spanned); + mStrokeWidth = strokeSpan != null ? strokeSpan.getStrokeWidth() : 0f; } public @Nullable Spannable getSpanned() { From 987274392e39092b02121e3f1d7ea1aac5148749 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 16:47:44 -0500 Subject: [PATCH 20/34] Attempt to fix text stroke effect by disabling clipping mechanisms --- .../views/text/PreparedLayoutTextView.kt | 23 +++++----------- .../react/views/text/ReactTextView.java | 26 +++++++------------ 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index d35ae44ad07f8e..b56576cbaa14f8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -65,6 +65,13 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re strokeWidth = stroke?.strokeWidth ?: 0f clipToPadding = stroke == null + // Disable various clipping mechanisms when stroke is present + if (strokeWidth > 0f) { + clipToOutline = false + setWillNotDraw(false) + (parent as? ViewGroup)?.clipChildren = false + } + field = value invalidate() } @@ -111,18 +118,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val layout = preparedLayout?.layout val hasStroke = strokeWidth > 0f - // Save and expand clip bounds to allow stroke overflow - if (hasStroke) { - canvas.save() - val extra = strokeWidth - canvas.clipRect( - scrollX.toFloat() - extra, - scrollY.toFloat() - extra, - scrollX.toFloat() + width.toFloat() + extra, - scrollY.toFloat() + height.toFloat() + extra, - android.graphics.Region.Op.REPLACE) - } - // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) @@ -150,10 +145,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re layout.draw(canvas, selection?.path, selectionPaint, 0) } } - - if (hasStroke) { - canvas.restore() - } } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 9167bed0267051..2de9ad306a9825 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -10,7 +10,6 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; -import android.graphics.Region; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; @@ -366,18 +365,6 @@ protected void onDraw(Canvas canvas) { boolean hasStroke = mStrokeWidth > 0f; - // Save and expand clip bounds to allow stroke overflow - if (hasStroke) { - canvas.save(); - float extra = mStrokeWidth; - canvas.clipRect( - getScrollX() - extra, - getScrollY() - extra, - getScrollX() + getWidth() + extra, - getScrollY() + getHeight() + extra, - Region.Op.REPLACE); - } - if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); @@ -392,10 +379,6 @@ protected void onDraw(Canvas canvas) { if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.restore(); } - - if (hasStroke) { - canvas.restore(); - } } } @@ -743,6 +726,15 @@ public void setSpanned(Spannable spanned) { // Check for stroke span and store stroke width StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(spanned); mStrokeWidth = strokeSpan != null ? strokeSpan.getStrokeWidth() : 0f; + + // Disable various clipping mechanisms when stroke is present + if (mStrokeWidth > 0f) { + setClipToOutline(false); + setWillNotDraw(false); + if (getParent() instanceof ViewGroup) { + ((ViewGroup) getParent()).setClipChildren(false); + } + } } public @Nullable Spannable getSpanned() { From b8b307f9c5e6a293b32c4eb455baf996a8ffcc34 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 17:42:18 -0500 Subject: [PATCH 21/34] Add test logs --- .../views/text/PreparedLayoutTextView.kt | 20 +++++++++++++++++-- .../react/views/text/ReactTextView.java | 18 +++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index b56576cbaa14f8..146d4e1bb3488f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -118,6 +118,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val layout = preparedLayout?.layout val hasStroke = strokeWidth > 0f + Log.d("TextDebug1", "=== PreparedLayoutTextView onDraw ==="); + Log.d("TextDebug1", "View: " + getWidth() + "x" + getHeight()); + Log.d("TextDebug1", "Clip: " + canvas.getClipBounds()); // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) @@ -125,9 +128,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re super.onDraw(canvas) + // Offset drawing to account for expanded stroke bounds + val strokeExtra = if (hasStroke) kotlin.math.ceil(strokeWidth.toDouble()).toFloat() else 0f + canvas.translate( - paddingLeft.toFloat(), - paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) + paddingLeft.toFloat() + strokeExtra, + paddingTop.toFloat() + strokeExtra + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { if (selection != null) { @@ -147,6 +153,16 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (strokeWidth > 0f) { + val extra = kotlin.math.ceil(strokeWidth.toDouble()).toInt() + setMeasuredDimension( + measuredWidth + extra * 2, + measuredHeight + extra * 2) + } + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { // No-op } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 2de9ad306a9825..291fc33afa8680 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -341,6 +341,12 @@ protected void onLayout( @Override protected void onDraw(Canvas canvas) { try (SystraceSection s = new SystraceSection("ReactTextView.onDraw")) { + Log.d("TextDebug", "=== ReactTextView onDraw ==="); + Log.d("TextDebug", "View: " + getWidth() + "x" + getHeight()); + Log.d("TextDebug", "Clip: " + canvas.getClipBounds()); + Log.d("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); + + Spannable spanned = getSpanned(); if (mAdjustsFontSizeToFit && spanned != null && mShouldAdjustSpannableFontSize) { mShouldAdjustSpannableFontSize = false; @@ -370,6 +376,12 @@ protected void onDraw(Canvas canvas) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } + // Offset drawing to account for expanded stroke bounds + if (hasStroke) { + float strokeExtra = (float) Math.ceil(mStrokeWidth); + canvas.translate(strokeExtra, strokeExtra); + } + // Draw shadow pass first (underneath everything) //DiscordShadowStyleSpan.drawShadowPass(this, canvas); @@ -386,6 +398,12 @@ protected void onDraw(Canvas canvas) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { try (SystraceSection s = new SystraceSection("ReactTextView.onMeasure")) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mStrokeWidth > 0f) { + int extra = (int) Math.ceil(mStrokeWidth); + setMeasuredDimension( + getMeasuredWidth() + extra * 2, + getMeasuredHeight() + extra * 2); + } } } From c2039b54dcaf94c90e9bab719e1193fc127d4d82 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 17:56:45 -0500 Subject: [PATCH 22/34] Fix the logs --- .../com/facebook/react/views/text/PreparedLayoutTextView.kt | 6 +++--- .../java/com/facebook/react/views/text/ReactTextView.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 146d4e1bb3488f..3b6a7520e8a4a9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -118,9 +118,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val layout = preparedLayout?.layout val hasStroke = strokeWidth > 0f - Log.d("TextDebug1", "=== PreparedLayoutTextView onDraw ==="); - Log.d("TextDebug1", "View: " + getWidth() + "x" + getHeight()); - Log.d("TextDebug1", "Clip: " + canvas.getClipBounds()); + System.out.println("TextDebug1", "=== PreparedLayoutTextView onDraw ==="); + System.out.println("TextDebug1", "View: " + getWidth() + "x" + getHeight()); + System.out.println("TextDebug1", "Clip: " + canvas.getClipBounds()); // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 291fc33afa8680..659d810004eb11 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -342,9 +342,9 @@ protected void onLayout( protected void onDraw(Canvas canvas) { try (SystraceSection s = new SystraceSection("ReactTextView.onDraw")) { Log.d("TextDebug", "=== ReactTextView onDraw ==="); - Log.d("TextDebug", "View: " + getWidth() + "x" + getHeight()); - Log.d("TextDebug", "Clip: " + canvas.getClipBounds()); - Log.d("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); + System.out.println("TextDebug", "View: " + getWidth() + "x" + getHeight()); + System.out.println("TextDebug", "Clip: " + canvas.getClipBounds()); + System.out.println("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); Spannable spanned = getSpanned(); From 75444c6749ca20dd6573a0390ac4f4f505c06995 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 18:49:01 -0500 Subject: [PATCH 23/34] Fix logs --- .../facebook/react/views/text/PreparedLayoutTextView.kt | 6 +++--- .../com/facebook/react/views/text/ReactTextView.java | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 3b6a7520e8a4a9..b02d2821ce7f73 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -118,9 +118,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val layout = preparedLayout?.layout val hasStroke = strokeWidth > 0f - System.out.println("TextDebug1", "=== PreparedLayoutTextView onDraw ==="); - System.out.println("TextDebug1", "View: " + getWidth() + "x" + getHeight()); - System.out.println("TextDebug1", "Clip: " + canvas.getClipBounds()); + android.util.Log.d("TextDebug1", "=== PreparedLayoutTextView onDraw ===") + android.util.Log.d("TextDebug1", "View: " + width + "x" + height) + android.util.Log.d("TextDebug1", "Clip: " + canvas.clipBounds) // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 659d810004eb11..c8d33e06db11a7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -10,6 +10,7 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; +import android.util.Log; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; @@ -341,10 +342,10 @@ protected void onLayout( @Override protected void onDraw(Canvas canvas) { try (SystraceSection s = new SystraceSection("ReactTextView.onDraw")) { - Log.d("TextDebug", "=== ReactTextView onDraw ==="); - System.out.println("TextDebug", "View: " + getWidth() + "x" + getHeight()); - System.out.println("TextDebug", "Clip: " + canvas.getClipBounds()); - System.out.println("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); + Log.d("TextDebug", "=== ReactTextView onDraw ==="); + Log.d("TextDebug", "View: " + getWidth() + "x" + getHeight()); + Log.d("TextDebug", "Clip: " + canvas.getClipBounds()); + Log.d("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); Spannable spanned = getSpanned(); From fc70ebc8c9d914c44d6b177f262b682105cfb5e3 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 19:42:53 -0500 Subject: [PATCH 24/34] Add more logs --- .../views/text/PreparedLayoutTextView.kt | 27 ++++++++++++++++--- .../react/views/text/ReactTextView.java | 15 +++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index b02d2821ce7f73..2739179e366395 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -65,11 +65,23 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re 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") + // Disable various clipping mechanisms when stroke is present if (strokeWidth > 0f) { + android.util.Log.d("TextDebug", "Disabling clipping for stroke") clipToOutline = false setWillNotDraw(false) - (parent as? ViewGroup)?.clipChildren = false + (parent as? ViewGroup)?.let { + it.clipChildren = false + android.util.Log.d("TextDebug", "Parent clipChildren set to false") + } + // Request re-measure since stroke width changed + requestLayout() } field = value @@ -118,9 +130,11 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val layout = preparedLayout?.layout val hasStroke = strokeWidth > 0f - android.util.Log.d("TextDebug1", "=== PreparedLayoutTextView onDraw ===") - android.util.Log.d("TextDebug1", "View: " + width + "x" + height) - android.util.Log.d("TextDebug1", "Clip: " + canvas.clipBounds) + 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") // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) @@ -155,11 +169,16 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) + android.util.Log.d("TextDebug", "=== PreparedLayout onMeasure ===") + android.util.Log.d("TextDebug", "StrokeWidth: $strokeWidth") + android.util.Log.d("TextDebug", "MeasuredDimension before: ${measuredWidth}x${measuredHeight}") if (strokeWidth > 0f) { val extra = kotlin.math.ceil(strokeWidth.toDouble()).toInt() + android.util.Log.d("TextDebug", "Adding extra: $extra * 2 = ${extra * 2}") setMeasuredDimension( measuredWidth + extra * 2, measuredHeight + extra * 2) + android.util.Log.d("TextDebug", "MeasuredDimension after: ${measuredWidth}x${measuredHeight}") } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index c8d33e06db11a7..db09cd5dd97add 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -346,6 +346,7 @@ protected void onDraw(Canvas canvas) { Log.d("TextDebug", "View: " + getWidth() + "x" + getHeight()); Log.d("TextDebug", "Clip: " + canvas.getClipBounds()); Log.d("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); + Log.d("TextDebug", "StrokeWidth: " + mStrokeWidth); Spannable spanned = getSpanned(); @@ -399,11 +400,16 @@ protected void onDraw(Canvas canvas) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { try (SystraceSection s = new SystraceSection("ReactTextView.onMeasure")) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); + Log.d("TextDebug", "=== onMeasure ==="); + Log.d("TextDebug", "StrokeWidth: " + mStrokeWidth); + Log.d("TextDebug", "MeasuredDimension before: " + getMeasuredWidth() + "x" + getMeasuredHeight()); if (mStrokeWidth > 0f) { int extra = (int) Math.ceil(mStrokeWidth); + Log.d("TextDebug", "Adding extra: " + extra + " * 2 = " + (extra * 2)); setMeasuredDimension( getMeasuredWidth() + extra * 2, getMeasuredHeight() + extra * 2); + Log.d("TextDebug", "MeasuredDimension after: " + getMeasuredWidth() + "x" + getMeasuredHeight()); } } } @@ -746,13 +752,22 @@ public void setSpanned(Spannable spanned) { StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(spanned); mStrokeWidth = strokeSpan != null ? strokeSpan.getStrokeWidth() : 0f; + Log.d("TextDebug", "=== setSpanned ==="); + Log.d("TextDebug", "Text: " + (spanned != null ? spanned.toString().substring(0, Math.min(20, spanned.length())) : "null")); + Log.d("TextDebug", "StrokeSpan found: " + (strokeSpan != null)); + Log.d("TextDebug", "StrokeWidth set to: " + mStrokeWidth); + // Disable various clipping mechanisms when stroke is present if (mStrokeWidth > 0f) { + Log.d("TextDebug", "Disabling clipping for stroke"); setClipToOutline(false); setWillNotDraw(false); if (getParent() instanceof ViewGroup) { ((ViewGroup) getParent()).setClipChildren(false); + Log.d("TextDebug", "Parent clipChildren set to false"); } + // Request re-measure since stroke width changed + requestLayout(); } } From d1168f2957ef094e61458a4646d4ffd35e09d153 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 20:30:57 -0500 Subject: [PATCH 25/34] Attempt to fix text stroke effect by expanding clip bounds --- .../views/text/PreparedLayoutTextView.kt | 47 +++++++------------ .../react/views/text/ReactTextView.java | 45 ++++++------------ 2 files changed, 33 insertions(+), 59 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 2739179e366395..4471be5fac0d35 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -71,19 +71,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re android.util.Log.d("TextDebug", "StrokeSpan found: ${stroke != null}") android.util.Log.d("TextDebug", "StrokeWidth set to: $strokeWidth") - // Disable various clipping mechanisms when stroke is present - if (strokeWidth > 0f) { - android.util.Log.d("TextDebug", "Disabling clipping for stroke") - clipToOutline = false - setWillNotDraw(false) - (parent as? ViewGroup)?.let { - it.clipChildren = false - android.util.Log.d("TextDebug", "Parent clipChildren set to false") - } - // Request re-measure since stroke width changed - requestLayout() - } - field = value invalidate() } @@ -135,6 +122,18 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re 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 (hasStroke) { + // Create an offscreen layer with expanded bounds to allow stroke overflow + val extra = kotlin.math.ceil(strokeWidth.toDouble()).toInt() + canvas.saveLayerAlpha( + -extra.toFloat(), + -extra.toFloat(), + (width + extra).toFloat(), + (height + extra).toFloat(), + 255) + } + // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) @@ -142,12 +141,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re super.onDraw(canvas) - // Offset drawing to account for expanded stroke bounds - val strokeExtra = if (hasStroke) kotlin.math.ceil(strokeWidth.toDouble()).toFloat() else 0f - canvas.translate( - paddingLeft.toFloat() + strokeExtra, - paddingTop.toFloat() + strokeExtra + (preparedLayout?.verticalOffset ?: 0f)) + paddingLeft.toFloat(), + paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { if (selection != null) { @@ -165,21 +161,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re layout.draw(canvas, selection?.path, selectionPaint, 0) } } + + if (hasStroke) { + canvas.restore() + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) - android.util.Log.d("TextDebug", "=== PreparedLayout onMeasure ===") - android.util.Log.d("TextDebug", "StrokeWidth: $strokeWidth") - android.util.Log.d("TextDebug", "MeasuredDimension before: ${measuredWidth}x${measuredHeight}") - if (strokeWidth > 0f) { - val extra = kotlin.math.ceil(strokeWidth.toDouble()).toInt() - android.util.Log.d("TextDebug", "Adding extra: $extra * 2 = ${extra * 2}") - setMeasuredDimension( - measuredWidth + extra * 2, - measuredHeight + extra * 2) - android.util.Log.d("TextDebug", "MeasuredDimension after: ${measuredWidth}x${measuredHeight}") - } } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index db09cd5dd97add..3c2dc9efdce287 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -373,17 +373,22 @@ protected void onDraw(Canvas canvas) { boolean hasStroke = mStrokeWidth > 0f; + if (hasStroke) { + // Create an offscreen layer with expanded bounds to allow stroke overflow + int extra = (int) Math.ceil(mStrokeWidth); + canvas.saveLayerAlpha( + -extra, + -extra, + getWidth() + extra, + getHeight() + extra, + 255); + } + if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } - // Offset drawing to account for expanded stroke bounds - if (hasStroke) { - float strokeExtra = (float) Math.ceil(mStrokeWidth); - canvas.translate(strokeExtra, strokeExtra); - } - // Draw shadow pass first (underneath everything) //DiscordShadowStyleSpan.drawShadowPass(this, canvas); @@ -393,6 +398,10 @@ protected void onDraw(Canvas canvas) { if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.restore(); } + + if (hasStroke) { + canvas.restore(); + } } } @@ -400,17 +409,6 @@ protected void onDraw(Canvas canvas) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { try (SystraceSection s = new SystraceSection("ReactTextView.onMeasure")) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); - Log.d("TextDebug", "=== onMeasure ==="); - Log.d("TextDebug", "StrokeWidth: " + mStrokeWidth); - Log.d("TextDebug", "MeasuredDimension before: " + getMeasuredWidth() + "x" + getMeasuredHeight()); - if (mStrokeWidth > 0f) { - int extra = (int) Math.ceil(mStrokeWidth); - Log.d("TextDebug", "Adding extra: " + extra + " * 2 = " + (extra * 2)); - setMeasuredDimension( - getMeasuredWidth() + extra * 2, - getMeasuredHeight() + extra * 2); - Log.d("TextDebug", "MeasuredDimension after: " + getMeasuredWidth() + "x" + getMeasuredHeight()); - } } } @@ -756,19 +754,6 @@ public void setSpanned(Spannable spanned) { Log.d("TextDebug", "Text: " + (spanned != null ? spanned.toString().substring(0, Math.min(20, spanned.length())) : "null")); Log.d("TextDebug", "StrokeSpan found: " + (strokeSpan != null)); Log.d("TextDebug", "StrokeWidth set to: " + mStrokeWidth); - - // Disable various clipping mechanisms when stroke is present - if (mStrokeWidth > 0f) { - Log.d("TextDebug", "Disabling clipping for stroke"); - setClipToOutline(false); - setWillNotDraw(false); - if (getParent() instanceof ViewGroup) { - ((ViewGroup) getParent()).setClipChildren(false); - Log.d("TextDebug", "Parent clipChildren set to false"); - } - // Request re-measure since stroke width changed - requestLayout(); - } } public @Nullable Spannable getSpanned() { From 712c50820f07b019fb02e369cda0cc35fc723cbb Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 21:03:59 -0500 Subject: [PATCH 26/34] Attempt to fix text stroke effect by moving canvas --- .../views/text/PreparedLayoutTextView.kt | 26 ++++++------------- .../react/views/text/ReactTextView.java | 25 +++++++----------- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 4471be5fac0d35..bd2366640f5045 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -123,17 +123,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re android.util.Log.d("TextDebug", "StrokeWidth: $strokeWidth") android.util.Log.d("TextDebug", "Padding L/T/R/B: $paddingLeft/$paddingTop/$paddingRight/$paddingBottom") - if (hasStroke) { - // Create an offscreen layer with expanded bounds to allow stroke overflow - val extra = kotlin.math.ceil(strokeWidth.toDouble()).toInt() - canvas.saveLayerAlpha( - -extra.toFloat(), - -extra.toFloat(), - (width + extra).toFloat(), - (height + extra).toFloat(), - 255) - } - // Skip clipping when stroke spans are present to prevent stroke from being clipped if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) @@ -141,9 +130,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re super.onDraw(canvas) - canvas.translate( - paddingLeft.toFloat(), - paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) + if (hasStroke) { + // Shift drawing left by padding amount so stroke uses the padding space + canvas.translate(0f, paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) + } else { + canvas.translate( + paddingLeft.toFloat(), + paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) + } if (layout != null) { if (selection != null) { @@ -161,10 +155,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re layout.draw(canvas, selection?.path, selectionPaint, 0) } } - - if (hasStroke) { - canvas.restore() - } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 3c2dc9efdce287..a3ca6b20a5d6d4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -373,17 +373,6 @@ protected void onDraw(Canvas canvas) { boolean hasStroke = mStrokeWidth > 0f; - if (hasStroke) { - // Create an offscreen layer with expanded bounds to allow stroke overflow - int extra = (int) Math.ceil(mStrokeWidth); - canvas.saveLayerAlpha( - -extra, - -extra, - getWidth() + extra, - getHeight() + extra, - 255); - } - if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); @@ -392,14 +381,18 @@ protected void onDraw(Canvas canvas) { // Draw shadow pass first (underneath everything) //DiscordShadowStyleSpan.drawShadowPass(this, canvas); - // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) - super.onDraw(canvas); - - if (mOverflow != Overflow.VISIBLE && !hasStroke) { + if (hasStroke) { + // Shift drawing left by padding amount so stroke uses the padding space + canvas.save(); + canvas.translate(-getPaddingLeft(), 0); + super.onDraw(canvas); canvas.restore(); + } else { + // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) + super.onDraw(canvas); } - if (hasStroke) { + if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.restore(); } } From cec54bd51817d1bd83ea1f94f09709fff2b28b05 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Thu, 18 Dec 2025 22:11:26 -0500 Subject: [PATCH 27/34] Attempt once more to fix text stroke effect --- .../com/facebook/react/views/text/PreparedLayoutTextView.kt | 6 ++++-- .../java/com/facebook/react/views/text/ReactTextView.java | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index bd2366640f5045..e00fbec1a8bac5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -131,8 +131,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re super.onDraw(canvas) if (hasStroke) { - // Shift drawing left by padding amount so stroke uses the padding space - canvas.translate(0f, paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) + // Shift text right by paddingLeft so stroke has room + canvas.translate( + paddingLeft.toFloat() + paddingLeft.toFloat(), + paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) } else { canvas.translate( paddingLeft.toFloat(), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index a3ca6b20a5d6d4..e66f89f2d15a56 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -382,9 +382,9 @@ protected void onDraw(Canvas canvas) { //DiscordShadowStyleSpan.drawShadowPass(this, canvas); if (hasStroke) { - // Shift drawing left by padding amount so stroke uses the padding space + // Shift text right by paddingLeft so stroke has room canvas.save(); - canvas.translate(-getPaddingLeft(), 0); + canvas.translate(getPaddingLeft(), 0); super.onDraw(canvas); canvas.restore(); } else { From ed73a46300356bc60615e0de0da572efb5856120 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Fri, 19 Dec 2025 09:43:00 -0500 Subject: [PATCH 28/34] Make the layout wider to fit the stroke --- .../views/text/PreparedLayoutTextView.kt | 18 ++++++------- .../react/views/text/ReactTextShadowNode.kt | 26 ++++++++++++++++--- .../react/views/text/ReactTextView.java | 15 +++++++---- .../react/views/text/TextLayoutManager.kt | 22 ++++++++++++++-- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index e00fbec1a8bac5..94b4c3e73b0c49 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -130,16 +130,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re super.onDraw(canvas) - if (hasStroke) { - // Shift text right by paddingLeft so stroke has room - canvas.translate( - paddingLeft.toFloat() + paddingLeft.toFloat(), - paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) - } else { - canvas.translate( - paddingLeft.toFloat(), - paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) - } + // paddingLeft/paddingTop: standard offset for text content to respect view padding + // strokeWidth offset: when stroke is present, the Layout was made wider in TextLayoutManager + // by strokeWidth*2. We translate by strokeWidth so the text (and its stroke) starts inside + // the Layout bounds, giving room for the left-side stroke. + val strokeOffset = if (hasStroke) strokeWidth else 0f + canvas.translate( + paddingLeft.toFloat() + strokeOffset, + paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { if (selection != null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt index e0fc35e08bcf32..25453ac6b844ff 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt @@ -33,6 +33,7 @@ import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.events.RCTEventEmitter import com.facebook.react.views.text.FontMetricsUtil.getFontMetrics import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan +import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan import com.facebook.yoga.YogaBaselineFunction import com.facebook.yoga.YogaConstants @@ -180,6 +181,10 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = val boring = BoringLayout.isBoring(text, textPaint) val desiredWidth = if (boring == null) Layout.getDesiredWidth(text, textPaint) else Float.NaN + // Check if text has stroke span and get the stroke width for extra padding + val strokeSpan = StrokeStyleSpan.getStrokeSpan(text) + val strokeExtra = strokeSpan?.strokeWidth?.toInt() ?: 0 + // technically, width should never be negative, but there is currently a bug in val unconstrainedWidth = widthMode == YogaMeasureMode.UNDEFINED || width < 0 @@ -196,7 +201,11 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = (!YogaConstants.isUndefined(desiredWidth) && desiredWidth <= width))) { // Is used when the width is not known and the text is not boring, ie. if it contains // unicode characters. - val hintWidth = ceil(desiredWidth.toDouble()).toInt() + var hintWidth = ceil(desiredWidth.toDouble()).toInt() + // Add extra width for stroke to prevent clipping (both sides) + if (strokeExtra > 0) { + hintWidth += strokeExtra * 2 + } val builder = StaticLayout.Builder.obtain(text, 0, text.length, textPaint, hintWidth) .setAlignment(alignment) @@ -215,11 +224,16 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = } else if (boring != null && (unconstrainedWidth || boring.width <= width)) { // Is used for single-line, boring text when the width is either unknown or bigger // than the width of the text. + var boringWidth = max(boring.width.toDouble(), 0.0).toInt() + // Add extra width for stroke to prevent clipping (both sides) + if (strokeExtra > 0) { + boringWidth += strokeExtra * 2 + } layout = BoringLayout.make( text, textPaint, - max(boring.width.toDouble(), 0.0).toInt(), + boringWidth, alignment, 1f, 0f, @@ -234,8 +248,14 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = width = ceil(width.toDouble()).toFloat() } + // Add extra width for stroke to prevent clipping (both sides) + var layoutWidth = width.toInt() + if (strokeExtra > 0) { + layoutWidth += strokeExtra * 2 + } + val builder = - StaticLayout.Builder.obtain(text, 0, text.length, textPaint, width.toInt()) + StaticLayout.Builder.obtain(text, 0, text.length, textPaint, layoutWidth) .setAlignment(alignment) .setLineSpacing(0f, 1f) .setIncludePad(mIncludeFontPadding) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index e66f89f2d15a56..0b2ed05419e356 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -382,11 +382,16 @@ protected void onDraw(Canvas canvas) { //DiscordShadowStyleSpan.drawShadowPass(this, canvas); if (hasStroke) { - // Shift text right by paddingLeft so stroke has room - canvas.save(); - canvas.translate(getPaddingLeft(), 0); - super.onDraw(canvas); - canvas.restore(); + // Draw the layout manually with offset to give room for stroke + Layout layout = getLayout(); + if (layout != null) { + int strokeOffset = (int) Math.ceil(mStrokeWidth); + canvas.save(); + // Move to standard text position plus stroke offset + canvas.translate(getCompoundPaddingLeft() + strokeOffset, getExtendedPaddingTop()); + layout.draw(canvas); + canvas.restore(); + } } else { // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) super.onDraw(canvas); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 81bff6ceb93627..4c5b80ecf27d67 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -574,26 +574,44 @@ internal object TextLayoutManager { maxNumberOfLines: Int, paint: TextPaint ): Layout { + // Check if text has stroke span and get the stroke width for extra padding + val strokeSpan = StrokeStyleSpan.getStrokeSpan(text) + val strokeExtra = strokeSpan?.strokeWidth?.toInt() ?: 0 + + android.util.Log.d("TextDebug", "=== TextLayoutManager createLayout ===") + android.util.Log.d("TextDebug", "strokeExtra: $strokeExtra, width: $width") + // If our text is boring, and fully fits in the available space, we can represent the text // layout as a BoringLayout if (boring != null && (widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || boring.width <= floor(width))) { - val layoutWidth = + var layoutWidth = if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) floor(width).toInt() else boring.width + // Add extra width for stroke to prevent clipping (both sides) + if (strokeExtra > 0) { + layoutWidth += strokeExtra * 2 + android.util.Log.d("TextDebug", "BoringLayout: Added stroke extra, new width: $layoutWidth") + } return BoringLayout.make( text, paint, layoutWidth, alignment, 1f, 0f, boring, includeFontPadding) } val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() - val layoutWidth = + var layoutWidth = when (widthYogaMeasureMode) { YogaMeasureMode.EXACTLY -> floor(width).toInt() YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt()) else -> desiredWidth } + // Add extra width for stroke to prevent clipping (both sides) + if (strokeExtra > 0) { + layoutWidth += strokeExtra * 2 + android.util.Log.d("TextDebug", "StaticLayout: Added stroke extra, new width: $layoutWidth") + } + val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth) .setAlignment(alignment) From 859a0c6398d4ba1c881a1114ba8840a5acb27ca1 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Fri, 19 Dec 2025 10:18:43 -0500 Subject: [PATCH 29/34] Use leading margin span to handle the stroke --- .../views/text/PreparedLayoutTextView.kt | 7 ++--- .../views/text/ReactBaseTextShadowNode.java | 14 +++++++++ .../react/views/text/ReactTextShadowNode.kt | 26 ++-------------- .../react/views/text/ReactTextView.java | 17 ++-------- .../react/views/text/TextLayoutManager.kt | 31 +++++++------------ 5 files changed, 32 insertions(+), 63 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 94b4c3e73b0c49..4b2e0e44657037 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -131,12 +131,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re super.onDraw(canvas) // paddingLeft/paddingTop: standard offset for text content to respect view padding - // strokeWidth offset: when stroke is present, the Layout was made wider in TextLayoutManager - // by strokeWidth*2. We translate by strokeWidth so the text (and its stroke) starts inside - // the Layout bounds, giving room for the left-side stroke. - val strokeOffset = if (hasStroke) strokeWidth else 0f + // When stroke is present, LeadingMarginSpan handles the left margin inside the Layout canvas.translate( - paddingLeft.toFloat() + strokeOffset, + paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index bf75eb4ecbd284..3c536bd68e2e64 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -13,7 +13,9 @@ import android.text.Layout; import android.text.Spannable; import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; +import android.text.style.LeadingMarginSpan; import android.view.Gravity; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; @@ -335,6 +337,18 @@ protected Spannable spannedFromShadowNode( textShadowNode.mTextAttributes.setHeightOfTallestInlineViewOrImage( heightOfTallestInlineViewOrImage); + // Add leading margin if stroke is present to give room for the stroke on the left side + // Note: JS side should add padding equal to strokeWidth to prevent early text wrapping + StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(sb); + if (strokeSpan != null) { + int strokeMargin = (int) Math.ceil(strokeSpan.getStrokeWidth()); + sb.setSpan( + new LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + sb.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + if (mReactTextViewManagerCallback != null) { mReactTextViewManagerCallback.onPostProcessSpannable(sb); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt index 25453ac6b844ff..e0fc35e08bcf32 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt @@ -33,7 +33,6 @@ import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.events.RCTEventEmitter import com.facebook.react.views.text.FontMetricsUtil.getFontMetrics import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan -import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan import com.facebook.yoga.YogaBaselineFunction import com.facebook.yoga.YogaConstants @@ -181,10 +180,6 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = val boring = BoringLayout.isBoring(text, textPaint) val desiredWidth = if (boring == null) Layout.getDesiredWidth(text, textPaint) else Float.NaN - // Check if text has stroke span and get the stroke width for extra padding - val strokeSpan = StrokeStyleSpan.getStrokeSpan(text) - val strokeExtra = strokeSpan?.strokeWidth?.toInt() ?: 0 - // technically, width should never be negative, but there is currently a bug in val unconstrainedWidth = widthMode == YogaMeasureMode.UNDEFINED || width < 0 @@ -201,11 +196,7 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = (!YogaConstants.isUndefined(desiredWidth) && desiredWidth <= width))) { // Is used when the width is not known and the text is not boring, ie. if it contains // unicode characters. - var hintWidth = ceil(desiredWidth.toDouble()).toInt() - // Add extra width for stroke to prevent clipping (both sides) - if (strokeExtra > 0) { - hintWidth += strokeExtra * 2 - } + val hintWidth = ceil(desiredWidth.toDouble()).toInt() val builder = StaticLayout.Builder.obtain(text, 0, text.length, textPaint, hintWidth) .setAlignment(alignment) @@ -224,16 +215,11 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = } else if (boring != null && (unconstrainedWidth || boring.width <= width)) { // Is used for single-line, boring text when the width is either unknown or bigger // than the width of the text. - var boringWidth = max(boring.width.toDouble(), 0.0).toInt() - // Add extra width for stroke to prevent clipping (both sides) - if (strokeExtra > 0) { - boringWidth += strokeExtra * 2 - } layout = BoringLayout.make( text, textPaint, - boringWidth, + max(boring.width.toDouble(), 0.0).toInt(), alignment, 1f, 0f, @@ -248,14 +234,8 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = width = ceil(width.toDouble()).toFloat() } - // Add extra width for stroke to prevent clipping (both sides) - var layoutWidth = width.toInt() - if (strokeExtra > 0) { - layoutWidth += strokeExtra * 2 - } - val builder = - StaticLayout.Builder.obtain(text, 0, text.length, textPaint, layoutWidth) + StaticLayout.Builder.obtain(text, 0, text.length, textPaint, width.toInt()) .setAlignment(alignment) .setLineSpacing(0f, 1f) .setIncludePad(mIncludeFontPadding) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 0b2ed05419e356..03b5143f8b8654 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -381,21 +381,8 @@ protected void onDraw(Canvas canvas) { // Draw shadow pass first (underneath everything) //DiscordShadowStyleSpan.drawShadowPass(this, canvas); - if (hasStroke) { - // Draw the layout manually with offset to give room for stroke - Layout layout = getLayout(); - if (layout != null) { - int strokeOffset = (int) Math.ceil(mStrokeWidth); - canvas.save(); - // Move to standard text position plus stroke offset - canvas.translate(getCompoundPaddingLeft() + strokeOffset, getExtendedPaddingTop()); - layout.draw(canvas); - canvas.restore(); - } - } else { - // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) - super.onDraw(canvas); - } + // Draw text - when stroke is present, LeadingMarginSpan handles the left margin inside the Layout + super.onDraw(canvas); if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.restore(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 4c5b80ecf27d67..79870271eec736 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -18,6 +18,7 @@ import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned import android.text.StaticLayout +import android.text.style.LeadingMarginSpan import android.text.TextDirectionHeuristics import android.text.TextPaint import android.text.TextUtils @@ -489,6 +490,14 @@ internal object TextLayoutManager { start, end, spanFlags) + // Add leading margin to give room for the stroke on the left side + // The margin is applied to the entire paragraph (0 to length) so the text shifts right + val strokeMargin = Math.ceil(strokeWidth.toDouble()).toInt() + spannable.setSpan( + LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + spannable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE) } if (!fragment.props.effectiveLineHeight.isNaN()) { @@ -574,44 +583,26 @@ internal object TextLayoutManager { maxNumberOfLines: Int, paint: TextPaint ): Layout { - // Check if text has stroke span and get the stroke width for extra padding - val strokeSpan = StrokeStyleSpan.getStrokeSpan(text) - val strokeExtra = strokeSpan?.strokeWidth?.toInt() ?: 0 - - android.util.Log.d("TextDebug", "=== TextLayoutManager createLayout ===") - android.util.Log.d("TextDebug", "strokeExtra: $strokeExtra, width: $width") - // If our text is boring, and fully fits in the available space, we can represent the text // layout as a BoringLayout if (boring != null && (widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || boring.width <= floor(width))) { - var layoutWidth = + val layoutWidth = if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) floor(width).toInt() else boring.width - // Add extra width for stroke to prevent clipping (both sides) - if (strokeExtra > 0) { - layoutWidth += strokeExtra * 2 - android.util.Log.d("TextDebug", "BoringLayout: Added stroke extra, new width: $layoutWidth") - } return BoringLayout.make( text, paint, layoutWidth, alignment, 1f, 0f, boring, includeFontPadding) } val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() - var layoutWidth = + val layoutWidth = when (widthYogaMeasureMode) { YogaMeasureMode.EXACTLY -> floor(width).toInt() YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt()) else -> desiredWidth } - // Add extra width for stroke to prevent clipping (both sides) - if (strokeExtra > 0) { - layoutWidth += strokeExtra * 2 - android.util.Log.d("TextDebug", "StaticLayout: Added stroke extra, new width: $layoutWidth") - } - val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth) .setAlignment(alignment) From 391c0d0bc4e8107be87c169fbed65d7dcc6727e5 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Fri, 19 Dec 2025 10:49:07 -0500 Subject: [PATCH 30/34] Fix the leading margin span --- .../views/text/ReactBaseTextShadowNode.java | 3 ++ .../react/views/text/TextLayoutManager.kt | 41 ++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index 3c536bd68e2e64..356c277f3262f4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -342,6 +342,9 @@ protected Spannable spannedFromShadowNode( StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(sb); if (strokeSpan != null) { int strokeMargin = (int) Math.ceil(strokeSpan.getStrokeWidth()); + android.util.Log.d("TextDebug", "=== ReactBaseTextShadowNode adding LeadingMarginSpan ==="); + android.util.Log.d("TextDebug", "strokeWidth: " + strokeSpan.getStrokeWidth() + ", strokeMargin: " + strokeMargin); + android.util.Log.d("TextDebug", "spannable length: " + sb.length()); sb.setSpan( new LeadingMarginSpan.Standard(strokeMargin, strokeMargin), 0, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 79870271eec736..70e775f0943453 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -485,19 +485,13 @@ internal object TextLayoutManager { fragment.props.textStrokeWidth > 0 && fragment.props.isTextStrokeColorSet) { val strokeWidth = PixelUtil.toPixelFromDIP(fragment.props.textStrokeWidth.toDouble()).toFloat() + android.util.Log.d("TextDebug", "=== TextLayoutManager adding StrokeStyleSpan ===") + android.util.Log.d("TextDebug", "strokeWidth: $strokeWidth, start: $start, end: $end") spannable.setSpan( StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor), start, end, spanFlags) - // Add leading margin to give room for the stroke on the left side - // The margin is applied to the entire paragraph (0 to length) so the text shifts right - val strokeMargin = Math.ceil(strokeWidth.toDouble()).toInt() - spannable.setSpan( - LeadingMarginSpan.Standard(strokeMargin, strokeMargin), - 0, - spannable.length, - Spanned.SPAN_INCLUSIVE_INCLUSIVE) } if (!fragment.props.effectiveLineHeight.isNaN()) { @@ -537,13 +531,13 @@ internal object TextLayoutManager { attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback? ): Spannable { + val spannable: Spannable if (ReactNativeFeatureFlags.enableAndroidTextMeasurementOptimizations()) { - val spannable = + spannable = buildSpannableFromFragmentsOptimized( context, attributedString.getMapBuffer(AS_KEY_FRAGMENTS)) reactTextViewManagerCallback?.onPostProcessSpannable(spannable) - return spannable } else { val sb = SpannableStringBuilder() @@ -565,8 +559,33 @@ internal object TextLayoutManager { } reactTextViewManagerCallback?.onPostProcessSpannable(sb) - return sb + spannable = sb } + + // Add leading margin if stroke is present to give room for the stroke on the left side + // Note: JS side should add padding equal to strokeWidth to prevent early text wrapping + val strokeSpan = StrokeStyleSpan.getStrokeSpan(spannable) + if (strokeSpan != null) { + val strokeMargin = Math.ceil(strokeSpan.strokeWidth.toDouble()).toInt() + android.util.Log.d("TextDebug", "=== TextLayoutManager adding LeadingMarginSpan ===") + android.util.Log.d("TextDebug", "strokeWidth: ${strokeSpan.strokeWidth}, strokeMargin: $strokeMargin") + android.util.Log.d("TextDebug", "spannable length: ${spannable.length}") + (spannable as? SpannableStringBuilder)?.setSpan( + LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + spannable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE) + // For SpannableString (from optimized path), we need different handling + if (spannable is SpannableString) { + spannable.setSpan( + LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + spannable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + } + + return spannable } private fun createLayout( From 6c38b41dfb24e9da1763dfc1fddc450c84fcad66 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Fri, 19 Dec 2025 17:16:32 -0500 Subject: [PATCH 31/34] Fix the stroke style span --- .../views/text/ReactBaseTextShadowNode.java | 30 ++++++++++++------- .../react/views/text/TextLayoutManager.kt | 10 +++++-- .../text/internal/span/StrokeStyleSpan.kt | 15 +++++++++- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index 356c277f3262f4..45b1a7894e8ac6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -173,6 +173,24 @@ private static void buildSpannedFromShadowNode( } int end = sb.length(); if (end >= start) { + // Add StrokeStyleSpan FIRST so it has highest priority and gets applied LAST during drawing, + // ensuring our color overwrites any ForegroundColorSpan + if (!Float.isNaN(textShadowNode.mTextStrokeWidth) + && textShadowNode.mTextStrokeWidth > 0 + && textShadowNode.mIsTextStrokeColorSet) { + String debugText = sb.subSequence(start, end).toString(); + android.util.Log.d("TextDebug", "=== ReactBaseTextShadowNode adding StrokeStyleSpan (FIRST) ==="); + android.util.Log.d("TextDebug", "strokeWidth: " + textShadowNode.mTextStrokeWidth + ", start: " + start + ", end: " + end + ", text: '" + debugText + "', strokeColor: " + textShadowNode.mTextStrokeColor + " (hex: " + Integer.toHexString(textShadowNode.mTextStrokeColor) + ")"); + ops.add( + new SetSpanOperation( + start, + end, + new StrokeStyleSpan( + textShadowNode.mTextStrokeWidth, + textShadowNode.mTextStrokeColor, + debugText))); + } + if (textShadowNode.mIsColorSet) { ops.add(new SetSpanOperation(start, end, new ReactForegroundColorSpan(textShadowNode.mColor))); } @@ -242,17 +260,7 @@ 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))); - } + // StrokeStyleSpan is added at the beginning of this block for correct priority float effectiveLineHeight = textAttributes.getEffectiveLineHeight(); if (!Float.isNaN(effectiveLineHeight) && (parentTextAttributes == null diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 70e775f0943453..4b15ce84f45c20 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -485,13 +485,17 @@ internal object TextLayoutManager { fragment.props.textStrokeWidth > 0 && fragment.props.isTextStrokeColorSet) { val strokeWidth = PixelUtil.toPixelFromDIP(fragment.props.textStrokeWidth.toDouble()).toFloat() + val debugText = spannable.subSequence(start, end).toString() android.util.Log.d("TextDebug", "=== TextLayoutManager adding StrokeStyleSpan ===") - android.util.Log.d("TextDebug", "strokeWidth: $strokeWidth, start: $start, end: $end") + android.util.Log.d("TextDebug", "strokeWidth: $strokeWidth, start: $start, end: $end, text: '$debugText', strokeColor: ${fragment.props.textStrokeColor} (hex: ${Integer.toHexString(fragment.props.textStrokeColor)})") + // Use maximum priority so StrokeStyleSpan is applied LAST during drawing, + // ensuring our color overwrites any ForegroundColorSpan + val strokeSpanFlags = spanFlags or Spannable.SPAN_PRIORITY spannable.setSpan( - StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor), + StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor, debugText), start, end, - spanFlags) + strokeSpanFlags) } if (!fragment.props.effectiveLineHeight.isNaN()) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index cda0908be6fa94..e550140e5fbb91 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -17,15 +17,28 @@ import android.text.style.CharacterStyle */ public class StrokeStyleSpan( public val strokeWidth: Float, - public val strokeColor: Int + public val strokeColor: Int, + public val debugText: String = "" ) : CharacterStyle(), ReactSpan { + // Store original paint values to detect if paint is being reused incorrectly override fun updateDrawState(textPaint: TextPaint) { + val originalStyle = textPaint.style + val originalStrokeWidth = textPaint.strokeWidth + val originalColor = textPaint.color + + android.util.Log.d("TextDebug", "StrokeStyleSpan.updateDrawState: text='$debugText', strokeColor=$strokeColor (hex=${Integer.toHexString(strokeColor)}), strokeWidth=$strokeWidth") + android.util.Log.d("TextDebug", " BEFORE: paintColor=${originalColor} (hex=${Integer.toHexString(originalColor)}), paintStyle=$originalStyle, paintStrokeWidth=$originalStrokeWidth") + + // Reset paint to default state first to avoid inheriting stale values textPaint.style = Paint.Style.FILL_AND_STROKE textPaint.strokeWidth = strokeWidth textPaint.strokeJoin = Paint.Join.ROUND textPaint.strokeCap = Paint.Cap.ROUND + textPaint.shader = null textPaint.color = strokeColor + + android.util.Log.d("TextDebug", " AFTER: paintColor=${textPaint.color} (hex=${Integer.toHexString(textPaint.color)})") } public companion object { From c0d8f18debd1f94090ba59ef71ae2385083f2ded Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Fri, 19 Dec 2025 22:06:29 -0500 Subject: [PATCH 32/34] Add more logs# --- .../react/views/text/ReactTextView.java | 17 +++++++++++------ .../internal/span/ReactForegroundColorSpan.kt | 9 ++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 03b5143f8b8654..03f5bfc680acb3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -342,14 +342,15 @@ protected void onLayout( @Override protected void onDraw(Canvas canvas) { try (SystraceSection s = new SystraceSection("ReactTextView.onDraw")) { - Log.d("TextDebug", "=== ReactTextView onDraw ==="); - Log.d("TextDebug", "View: " + getWidth() + "x" + getHeight()); - Log.d("TextDebug", "Clip: " + canvas.getClipBounds()); - Log.d("TextDebug", "Padding L/T/R/B: " + getPaddingLeft() + "/" + getPaddingTop() + "/" + getPaddingRight() + "/" + getPaddingBottom()); - Log.d("TextDebug", "StrokeWidth: " + mStrokeWidth); + Spannable spanned = getSpanned(); + // Log paint state before drawing + android.text.TextPaint paint = getPaint(); + Log.d("TextDebug", "=== ReactTextView onDraw START ==="); + Log.d("TextDebug", "Text: " + (spanned != null ? spanned.toString().substring(0, Math.min(20, spanned.length())) : "null")); + Log.d("TextDebug", "StrokeWidth: " + mStrokeWidth); + Log.d("TextDebug", "Paint BEFORE draw: color=" + paint.getColor() + " (hex=" + Integer.toHexString(paint.getColor()) + "), style=" + paint.getStyle() + ", strokeWidth=" + paint.getStrokeWidth()); - Spannable spanned = getSpanned(); if (mAdjustsFontSizeToFit && spanned != null && mShouldAdjustSpannableFontSize) { mShouldAdjustSpannableFontSize = false; TextLayoutManager.adjustSpannableFontToFit( @@ -384,6 +385,10 @@ protected void onDraw(Canvas canvas) { // Draw text - when stroke is present, LeadingMarginSpan handles the left margin inside the Layout super.onDraw(canvas); + // Log paint state after drawing + Log.d("TextDebug", "Paint AFTER draw: color=" + paint.getColor() + " (hex=" + Integer.toHexString(paint.getColor()) + "), style=" + paint.getStyle() + ", strokeWidth=" + paint.getStrokeWidth()); + Log.d("TextDebug", "=== ReactTextView onDraw END ==="); + if (mOverflow != Overflow.VISIBLE && !hasStroke) { canvas.restore(); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt index 9ff41f9d436708..364aeadf191611 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt @@ -7,7 +7,14 @@ package com.facebook.react.views.text.internal.span +import android.text.TextPaint import android.text.style.ForegroundColorSpan /** Wraps [ForegroundColorSpan] as a [ReactSpan]. */ -internal class ReactForegroundColorSpan(color: Int) : ForegroundColorSpan(color), ReactSpan +internal class ReactForegroundColorSpan(color: Int) : ForegroundColorSpan(color), ReactSpan { + override fun updateDrawState(textPaint: TextPaint) { + android.util.Log.d("TextDebug", "ReactForegroundColorSpan.updateDrawState: color=${foregroundColor} (hex=${Integer.toHexString(foregroundColor)}), BEFORE paintColor=${textPaint.color} (hex=${Integer.toHexString(textPaint.color)})") + super.updateDrawState(textPaint) + android.util.Log.d("TextDebug", "ReactForegroundColorSpan.updateDrawState: AFTER paintColor=${textPaint.color} (hex=${Integer.toHexString(textPaint.color)})") + } +} From d6a479e664fe33c8fb1a94330e57a931ef443c81 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Sun, 21 Dec 2025 12:39:24 -0500 Subject: [PATCH 33/34] Update the stroke style span order --- .../views/text/ReactBaseTextShadowNode.java | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index 45b1a7894e8ac6..ac0e85ff3624ed 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -173,24 +173,6 @@ private static void buildSpannedFromShadowNode( } int end = sb.length(); if (end >= start) { - // Add StrokeStyleSpan FIRST so it has highest priority and gets applied LAST during drawing, - // ensuring our color overwrites any ForegroundColorSpan - if (!Float.isNaN(textShadowNode.mTextStrokeWidth) - && textShadowNode.mTextStrokeWidth > 0 - && textShadowNode.mIsTextStrokeColorSet) { - String debugText = sb.subSequence(start, end).toString(); - android.util.Log.d("TextDebug", "=== ReactBaseTextShadowNode adding StrokeStyleSpan (FIRST) ==="); - android.util.Log.d("TextDebug", "strokeWidth: " + textShadowNode.mTextStrokeWidth + ", start: " + start + ", end: " + end + ", text: '" + debugText + "', strokeColor: " + textShadowNode.mTextStrokeColor + " (hex: " + Integer.toHexString(textShadowNode.mTextStrokeColor) + ")"); - ops.add( - new SetSpanOperation( - start, - end, - new StrokeStyleSpan( - textShadowNode.mTextStrokeWidth, - textShadowNode.mTextStrokeColor, - debugText))); - } - if (textShadowNode.mIsColorSet) { ops.add(new SetSpanOperation(start, end, new ReactForegroundColorSpan(textShadowNode.mColor))); } @@ -260,7 +242,6 @@ private static void buildSpannedFromShadowNode( textShadowNode.mTextShadowRadius, textShadowNode.mTextShadowColor))); } - // StrokeStyleSpan is added at the beginning of this block for correct priority float effectiveLineHeight = textAttributes.getEffectiveLineHeight(); if (!Float.isNaN(effectiveLineHeight) && (parentTextAttributes == null @@ -268,6 +249,24 @@ private static void buildSpannedFromShadowNode( ops.add(new SetSpanOperation(start, end, new CustomLineHeightSpan(effectiveLineHeight))); } ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textShadowNode.getReactTag()))); + + // Add StrokeStyleSpan LAST so it has lowest priority index, which means highest priority, + // and gets applied LAST during drawing, ensuring our color overwrites any ForegroundColorSpan + if (!Float.isNaN(textShadowNode.mTextStrokeWidth) + && textShadowNode.mTextStrokeWidth > 0 + && textShadowNode.mIsTextStrokeColorSet) { + String debugText = sb.subSequence(start, end).toString(); + android.util.Log.d("TextDebug", "=== ReactBaseTextShadowNode adding StrokeStyleSpan (LAST) ==="); + android.util.Log.d("TextDebug", "strokeWidth: " + textShadowNode.mTextStrokeWidth + ", start: " + start + ", end: " + end + ", text: '" + debugText + "', strokeColor: " + textShadowNode.mTextStrokeColor + " (hex: " + Integer.toHexString(textShadowNode.mTextStrokeColor) + ")"); + ops.add( + new SetSpanOperation( + start, + end, + new StrokeStyleSpan( + textShadowNode.mTextStrokeWidth, + textShadowNode.mTextStrokeColor, + debugText))); + } } } From 53cc3bdb146f4c56d589aa1a52b1437d5d969a43 Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Sun, 21 Dec 2025 16:47:39 -0500 Subject: [PATCH 34/34] Ignore ReactForegroundColorSpan when stroke is present --- .../react/views/text/ReactBaseTextShadowNode.java | 6 +++++- .../facebook/react/views/text/TextLayoutManager.kt | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index ac0e85ff3624ed..65626c670c778b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -173,7 +173,11 @@ private static void buildSpannedFromShadowNode( } int end = sb.length(); if (end >= start) { - if (textShadowNode.mIsColorSet) { + // Skip ReactForegroundColorSpan when stroke is present - StrokeStyleSpan handles both stroke and fill colors + boolean hasStroke = !Float.isNaN(textShadowNode.mTextStrokeWidth) + && textShadowNode.mTextStrokeWidth > 0 + && textShadowNode.mIsTextStrokeColorSet; + if (textShadowNode.mIsColorSet && !hasStroke) { ops.add(new SetSpanOperation(start, end, new ReactForegroundColorSpan(textShadowNode.mColor))); } if (textShadowNode.mGradientColors != null && textShadowNode.mGradientColors.length >= 2) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 4b15ce84f45c20..7fbd9efa98fd5a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -254,7 +254,11 @@ internal object TextLayoutManager { if (roleIsLink) { ops.add(SetSpanOperation(start, end, ReactClickableSpan(reactTag))) } - if (textAttributes.mIsColorSet) { + // Skip ReactForegroundColorSpan when stroke is present - StrokeStyleSpan handles both stroke and fill colors + val hasStroke = !textAttributes.textStrokeWidth.isNaN() && + textAttributes.textStrokeWidth > 0 && + textAttributes.isTextStrokeColorSet + if (textAttributes.mIsColorSet && !hasStroke) { ops.add(SetSpanOperation(start, end, ReactForegroundColorSpan(textAttributes.mColor))) } if (textAttributes.gradientColors != null && textAttributes.gradientColors!!.size >= 2) { @@ -412,7 +416,11 @@ internal object TextLayoutManager { spannable.setSpan(ReactClickableSpan(fragment.reactTag), start, end, spanFlags) } - if (fragment.props.isColorSet) { + // Skip ReactForegroundColorSpan when stroke is present - StrokeStyleSpan handles both stroke and fill colors + val hasStroke = !fragment.props.textStrokeWidth.isNaN() && + fragment.props.textStrokeWidth > 0 && + fragment.props.isTextStrokeColorSet + if (fragment.props.isColorSet && !hasStroke) { spannable.setSpan(ReactForegroundColorSpan(fragment.props.color), start, end, spanFlags) }