From bb79dbc045fd695fa7d890afda24eabcad27d385 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:35:13 +0000 Subject: [PATCH 1/5] iOS: draw clamp-mode text gradients as a single clamped fill Vertical gradient text with gradientMode: "clamp" was effectively a no-op on iOS. Clamp mode only skipped appending the mirror color; the fill was still a UIColor colorWithPatternImage, which tiles the gradient image in both axes. Descenders (and any glyph area past the pattern) got a wrapped tile instead of the clamped edge color, unlike Android's Shader.TileMode.CLAMP. Render clamp mode the same way Android does: as one clipped gradient instead of a tiled pattern. - RCTAttributedTextUtils.mm: for clamp mode, carry the gradient colors and angle as text attributes (RCTTextGradientColors / RCTTextGradientAngle), mirroring the existing RCTTextStrokeWidth pattern, and keep a solid fallback foreground (the edge color) so measurement and non-custom draw paths stay correct. Mirror mode keeps its existing pattern-image path. - RCTTextLayoutManager.mm: when the gradient attributes are present, replace the kCGTextFill pass with a clip-then-gradient pass: accumulate the glyph outlines into the clip via kCGTextClip, then draw a single linear gradient sized to usedRectForTextContainer (which includes the descent) with kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation so pixels past the gradient ends clamp to the edge color. This also fixes descenders for Gradient/Prism/Pop, not just Toon. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c --- .../RCTAttributedTextUtils.mm | 95 ++++++++---- .../textlayoutmanager/RCTTextLayoutManager.mm | 144 +++++++++++++----- 2 files changed, 172 insertions(+), 67 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 28ea7e6762b4..14c2d4980db3 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -136,7 +136,9 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex return RCTFontWithFontProperties(fontProperties); } -inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes(const TextAttributes &textAttributes) +inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes( + const TextAttributes &textAttributes, + NSMutableDictionary *attributes) { UIColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [UIColor blackColor]; @@ -151,40 +153,67 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex if ([cgColors count] > 0) { BOOL isClampMode = textAttributes.gradientMode.value_or("") == "clamp"; - if (!isClampMode) { - // Mirror mode (default) duplicates the first color at the end. - [cgColors addObject:cgColors[0]]; - } - - CAGradientLayer *gradient = [CAGradientLayer layer]; - CGFloat patternWidth = - (!isnan(textAttributes.gradientWidth) && textAttributes.gradientWidth > 0) ? textAttributes.gradientWidth : 100; - CGFloat lineHeight = !isnan(textAttributes.lineHeight) - ? textAttributes.lineHeight - : (!isnan(textAttributes.fontSize) ? textAttributes.fontSize : 14); - CGFloat height = lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); - gradient.frame = CGRectMake(0, 0, patternWidth, height); - gradient.colors = cgColors; - CGFloat angle = !isnan(textAttributes.gradientAngle) ? textAttributes.gradientAngle : 0.0; - CGFloat radians = angle * M_PI / 180.0; - CGFloat startX = 0.5 - 0.5 * cos(radians); - CGFloat startY = 0.5 - 0.5 * sin(radians); - CGFloat endX = 0.5 + 0.5 * cos(radians); - CGFloat endY = 0.5 + 0.5 * sin(radians); - - gradient.startPoint = CGPointMake(startX, startY); - gradient.endPoint = CGPointMake(endX, endY); - - UIGraphicsImageRenderer *renderer = - [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; - UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { - [gradient renderInContext:rendererContext.CGContext]; - }]; + if (isClampMode) { + // Clamp mode matches Android's Shader.TileMode.CLAMP. Rather than tiling a pattern image + // (which wraps vertically and leaves descenders re-tiled), the fill is drawn by + // RCTTextLayoutManager as a single glyph-clipped gradient that clamps to its edge colors. + // Carry the gradient parameters as attributes for that custom draw path, and keep a solid + // fallback foreground (the last/edge color) so measurement and any non-custom draw path + // remain correct. + NSArray *gradientColors = cgColors; + if (!isnan(textAttributes.opacity)) { + NSMutableArray *fadedColors = [NSMutableArray arrayWithCapacity:cgColors.count]; + for (id cgColor in cgColors) { + CGColorRef color = (__bridge CGColorRef)cgColor; + CGColorRef fadedColor = + CGColorCreateCopyWithAlpha(color, CGColorGetAlpha(color) * textAttributes.opacity); + [fadedColors addObject:(__bridge id)fadedColor]; + CGColorRelease(fadedColor); + } + gradientColors = fadedColors; + } + + if (attributes != nil) { + attributes[@"RCTTextGradientColors"] = gradientColors; + attributes[@"RCTTextGradientAngle"] = @(angle); + } + + effectiveForegroundColor = [UIColor colorWithCGColor:(__bridge CGColorRef)cgColors.lastObject]; + } else { + // Mirror mode (default) duplicates the first color at the end and tiles a pattern image. + [cgColors addObject:cgColors[0]]; - if (gradientImage) { - effectiveForegroundColor = [UIColor colorWithPatternImage:gradientImage]; + CAGradientLayer *gradient = [CAGradientLayer layer]; + CGFloat patternWidth = (!isnan(textAttributes.gradientWidth) && textAttributes.gradientWidth > 0) + ? textAttributes.gradientWidth + : 100; + CGFloat lineHeight = !isnan(textAttributes.lineHeight) + ? textAttributes.lineHeight + : (!isnan(textAttributes.fontSize) ? textAttributes.fontSize : 14); + CGFloat height = lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); + gradient.frame = CGRectMake(0, 0, patternWidth, height); + gradient.colors = cgColors; + + CGFloat radians = angle * M_PI / 180.0; + + CGFloat startX = 0.5 - 0.5 * cos(radians); + CGFloat startY = 0.5 - 0.5 * sin(radians); + CGFloat endX = 0.5 + 0.5 * cos(radians); + CGFloat endY = 0.5 + 0.5 * sin(radians); + + gradient.startPoint = CGPointMake(startX, startY); + gradient.endPoint = CGPointMake(endX, endY); + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; + UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + [gradient renderInContext:rendererContext.CGContext]; + }]; + + if (gradientImage) { + effectiveForegroundColor = [UIColor colorWithPatternImage:gradientImage]; + } } } } @@ -221,7 +250,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex } // Colors - UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes); + UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes, attributes); if (textAttributes.foregroundColor || !isnan(textAttributes.opacity) || textAttributes.gradientColors.has_value()) { diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 1b0ae8c85016..0aea41668ec3 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -126,59 +126,135 @@ - (void)drawAttributedString:(AttributedString)attributedString } }]; } - if (strokeWidth > 0 && strokeColor) { + + // Clamp-mode gradient fill parameters (see RCTAttributedTextUtils.mm). When present, the fill is + // drawn as a single glyph-clipped gradient that clamps to its edge colors - the iOS equivalent of + // Android's Shader.TileMode.CLAMP - instead of the tiled pattern image used for mirror mode. + __block NSArray *gradientColors = nil; + __block CGFloat gradientAngle = 0.0; + [textStorage enumerateAttribute:@"RCTTextGradientColors" + inRange:characterRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value isKindOfClass:[NSArray class]] && [(NSArray *)value count] > 0) { + gradientColors = value; + *stop = YES; + } + }]; + if (gradientColors != nil) { + [textStorage enumerateAttribute:@"RCTTextGradientAngle" + inRange:characterRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value isKindOfClass:[NSNumber class]]) { + gradientAngle = [value floatValue]; + *stop = YES; + } + }]; + } + + BOOL hasStroke = (strokeWidth > 0 && strokeColor != nil); + BOOL hasGradientFill = (gradientColors != nil); + + if (hasStroke || hasGradientFill) { CGContextRef context = UIGraphicsGetCurrentContext(); - CGContextSetLineWidth(context, strokeWidth); - CGContextSetLineJoin(context, kCGLineJoinRound); - CGContextSetLineCap(context, kCGLineCapRound); // Shift glyphs by strokeWidth/2 when there's room so the outward stroke fits; otherwise // center whatever slack is left. `_measureTextStorage:` grew the returned size by // strokeWidth, so `frame.size` is already inflated - `frame.size - strokeWidth` recovers // the natural text size and `viewBounds - frame + strokeWidth` is the slack around it. - CGFloat slackX = viewBounds.size.width - frame.size.width + strokeWidth; - CGFloat slackY = viewBounds.size.height - frame.size.height + strokeWidth; - CGFloat strokeShiftX = - (slackX <= 0) ? 0 : (slackX >= strokeWidth ? strokeWidth / 2.0 : slackX / 2.0); - CGFloat strokeShiftY = - (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); - - // PASS 1: Draw stroke outline - CGContextSaveGState(context); - CGContextSetTextDrawingMode(context, kCGTextStroke); + // With no stroke both shifts are 0, so the fill lands exactly where drawGlyphsForGlyphRange + // would have placed it. + CGFloat strokeShiftX = 0; + CGFloat strokeShiftY = 0; + if (hasStroke) { + CGFloat slackX = viewBounds.size.width - frame.size.width + strokeWidth; + CGFloat slackY = viewBounds.size.height - frame.size.height + strokeWidth; + strokeShiftX = (slackX <= 0) ? 0 : (slackX >= strokeWidth ? strokeWidth / 2.0 : slackX / 2.0); + strokeShiftY = (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); + + // PASS 1: Draw stroke outline + CGContextSetLineWidth(context, strokeWidth); + CGContextSetLineJoin(context, kCGLineJoinRound); + CGContextSetLineCap(context, kCGLineCapRound); + + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, kCGTextStroke); + + NSMutableAttributedString *strokeText = [textStorage mutableCopy]; + [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; + + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM( + context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); + CGContextScaleCTM(context, 1.0, -1.0); + + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText); + CGMutablePathRef path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); + CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(ctFrame, context); + CFRelease(ctFrame); + CFRelease(path); + CFRelease(framesetter); + CGContextRestoreGState(context); + } - NSMutableAttributedString *strokeText = [textStorage mutableCopy]; - [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; + // PASS 2: Draw fill on top, in the same flipped CoreText space as the stroke pass. + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, hasGradientFill ? kCGTextClip : kCGTextFill); CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM( context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); CGContextScaleCTM(context, 1.0, -1.0); - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText); + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage); CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + // For a gradient fill this only accumulates the glyph outlines into the clip; for a solid + // fill it draws the glyphs directly. CTFrameDraw(ctFrame, context); - CFRelease(ctFrame); - CFRelease(path); - CFRelease(framesetter); - CGContextRestoreGState(context); - - // PASS 2: Draw fill on top - CGContextSaveGState(context); - CGContextSetTextDrawingMode(context, kCGTextFill); - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM( - context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); - CGContextScaleCTM(context, 1.0, -1.0); + if (hasGradientFill) { + // `usedRectForTextContainer` includes the descent, so sizing the gradient axis to it + // guarantees descenders (which extend below the last stop) are covered by the clamped edge + // color rather than being left uncovered or re-tiled. + CGRect glyphBounds = [layoutManager usedRectForTextContainer:textContainer]; + + // Map the angle to start/end points across the glyph bounds. Coordinates are in the flipped + // CoreText space established above (origin bottom-left, y increasing upward), so the visual + // top of the text sits at the highest y. This matches the normalized start/end convention + // used for mirror mode in RCTAttributedTextUtils.mm. + CGFloat radians = gradientAngle * M_PI / 180.0; + CGFloat startNormX = 0.5 - 0.5 * cos(radians); + CGFloat startNormY = 0.5 - 0.5 * sin(radians); + CGFloat endNormX = 0.5 + 0.5 * cos(radians); + CGFloat endNormY = 0.5 + 0.5 * sin(radians); + + CGFloat topY = frame.size.height - glyphBounds.origin.y; + CGPoint start = CGPointMake( + glyphBounds.origin.x + startNormX * glyphBounds.size.width, topY - startNormY * glyphBounds.size.height); + CGPoint end = CGPointMake( + glyphBounds.origin.x + endNormX * glyphBounds.size.width, topY - endNormY * glyphBounds.size.height); + + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (CFArrayRef)gradientColors, NULL); + if (gradient != NULL) { + // kCGGradientDrawsBefore/AfterStartLocation clamps pixels beyond the gradient ends to the + // edge colors - the iOS equivalent of Shader.TileMode.CLAMP. + CGContextDrawLinearGradient( + context, + gradient, + start, + end, + kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation); + CGGradientRelease(gradient); + } + CGColorSpaceRelease(colorSpace); + } - framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage); - path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); - ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - CTFrameDraw(ctFrame, context); CFRelease(ctFrame); CFRelease(path); CFRelease(framesetter); From c0cbe6205d5b5eed271ca92087b43575cc8d6f54 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:56:40 +0000 Subject: [PATCH 2/5] iOS: factor shared CoreText draw boilerplate into a helper The stroke pass and the fill/gradient pass each duplicated the same CTM setup + CTFramesetter/CTFrame create/draw/release sequence. Extract it into RCTDrawAttributedStringInFlippedContext so the coordinate math lives in one place; the caller keeps ownership of the surrounding save/restore (the kCGTextClip clip must outlive the call so the gradient can be drawn into it). Also compute the shared translate once. No behavior change. The NSLayoutManager default path is intentionally left separate - the CoreText path sacrifices truncation/ellipsize and adjustsFontSizeToFit fidelity for glyph-path control, so the two are not interchangeable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c --- .../textlayoutmanager/RCTTextLayoutManager.mm | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 0aea41668ec3..79e05487beba 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -56,6 +56,33 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return strokeWidth; } +// Draws `attributedString` with CoreText in the flipped coordinate space shared by the custom +// stroke and gradient-fill passes, using `drawingMode` (stroke / fill / clip). The caller owns the +// surrounding graphics-state save/restore: a clip accumulated via kCGTextClip must outlive this +// call so the gradient can be drawn into it. +static void RCTDrawAttributedStringInFlippedContext( + CGContextRef context, + NSAttributedString *attributedString, + CGSize frameSize, + CGFloat translateX, + CGFloat translateY, + CGTextDrawingMode drawingMode) +{ + CGContextSetTextDrawingMode(context, drawingMode); + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM(context, translateX, translateY); + CGContextScaleCTM(context, 1.0, -1.0); + + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString); + CGMutablePathRef path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, frameSize.width, frameSize.height)); + CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(ctFrame, context); + CFRelease(ctFrame); + CFRelease(path); + CFRelease(framesetter); +} + - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext @@ -157,6 +184,11 @@ - (void)drawAttributedString:(AttributedString)attributedString BOOL hasGradientFill = (gradientColors != nil); if (hasStroke || hasGradientFill) { + // Custom glyph-level rendering (outer stroke and/or clamped gradient fill) that + // -drawGlyphsForGlyphRange: can't express, so both passes re-draw the glyphs with CoreText in a + // shared flipped coordinate space. This deliberately trades some NSLayoutManager fidelity + // (truncation/ellipsize, adjustsFontSizeToFit) for glyph-path control; plain text keeps the + // NSLayoutManager path below. CGContextRef context = UIGraphicsGetCurrentContext(); // Shift glyphs by strokeWidth/2 when there's room so the outward stroke fits; otherwise @@ -172,50 +204,30 @@ - (void)drawAttributedString:(AttributedString)attributedString CGFloat slackY = viewBounds.size.height - frame.size.height + strokeWidth; strokeShiftX = (slackX <= 0) ? 0 : (slackX >= strokeWidth ? strokeWidth / 2.0 : slackX / 2.0); strokeShiftY = (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); + } + CGFloat translateX = frame.origin.x + strokeShiftX; + CGFloat translateY = viewBounds.size.height - frame.origin.y + strokeShiftY; - // PASS 1: Draw stroke outline + // PASS 1: Draw stroke outline + if (hasStroke) { CGContextSetLineWidth(context, strokeWidth); CGContextSetLineJoin(context, kCGLineJoinRound); CGContextSetLineCap(context, kCGLineCapRound); - CGContextSaveGState(context); - CGContextSetTextDrawingMode(context, kCGTextStroke); - NSMutableAttributedString *strokeText = [textStorage mutableCopy]; [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM( - context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); - CGContextScaleCTM(context, 1.0, -1.0); - - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText); - CGMutablePathRef path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); - CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - CTFrameDraw(ctFrame, context); - CFRelease(ctFrame); - CFRelease(path); - CFRelease(framesetter); + CGContextSaveGState(context); + RCTDrawAttributedStringInFlippedContext(context, strokeText, frame.size, translateX, translateY, kCGTextStroke); CGContextRestoreGState(context); } - // PASS 2: Draw fill on top, in the same flipped CoreText space as the stroke pass. + // PASS 2: Draw fill on top. For a gradient fill the glyph outlines are accumulated into the clip + // (kCGTextClip) and a single clamped gradient is drawn into it; otherwise the glyphs are filled + // directly (kCGTextFill). CGContextSaveGState(context); - CGContextSetTextDrawingMode(context, hasGradientFill ? kCGTextClip : kCGTextFill); - - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM( - context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); - CGContextScaleCTM(context, 1.0, -1.0); - - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage); - CGMutablePathRef path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); - CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - // For a gradient fill this only accumulates the glyph outlines into the clip; for a solid - // fill it draws the glyphs directly. - CTFrameDraw(ctFrame, context); + RCTDrawAttributedStringInFlippedContext( + context, textStorage, frame.size, translateX, translateY, hasGradientFill ? kCGTextClip : kCGTextFill); if (hasGradientFill) { // `usedRectForTextContainer` includes the descent, so sizing the gradient axis to it @@ -224,9 +236,9 @@ - (void)drawAttributedString:(AttributedString)attributedString CGRect glyphBounds = [layoutManager usedRectForTextContainer:textContainer]; // Map the angle to start/end points across the glyph bounds. Coordinates are in the flipped - // CoreText space established above (origin bottom-left, y increasing upward), so the visual - // top of the text sits at the highest y. This matches the normalized start/end convention - // used for mirror mode in RCTAttributedTextUtils.mm. + // CoreText space established by the draw helper (origin bottom-left, y increasing upward), so + // the visual top of the text sits at the highest y. This matches the normalized start/end + // convention used for mirror mode in RCTAttributedTextUtils.mm. CGFloat radians = gradientAngle * M_PI / 180.0; CGFloat startNormX = 0.5 - 0.5 * cos(radians); CGFloat startNormY = 0.5 - 0.5 * sin(radians); @@ -255,9 +267,6 @@ - (void)drawAttributedString:(AttributedString)attributedString CGColorSpaceRelease(colorSpace); } - CFRelease(ctFrame); - CFRelease(path); - CFRelease(framesetter); CGContextRestoreGState(context); } else { [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:frame.origin]; From 1eb1c033a9200897d9c76abcafca013dacabed47 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:01:00 +0000 Subject: [PATCH 3/5] iOS: extract gradient-angle and attribute-lookup helpers for readability Two small helpers to reduce duplication and clarify intent: - RCTNormalizedGradientPointsForAngle (RCTAttributedTextUtils.h): the 0.5 +/- 0.5*{cos,sin} start/end formula was duplicated verbatim in the mirror path (RCTAttributedTextUtils.mm) and the clamp draw path (RCTTextLayoutManager.mm). Both now call the shared helper, which documents the angle convention (0deg left->right, 90deg top->bottom) once and guarantees mirror and clamp point the same direction. - RCTFirstTextAttributeValue (RCTTextLayoutManager.mm): collapses the three near-identical enumerateAttribute blocks (stroke color, gradient colors, gradient angle) into single-line, class-checked lookups and removes the __block locals. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c --- .../RCTAttributedTextUtils.h | 17 ++++ .../RCTAttributedTextUtils.mm | 14 ++- .../textlayoutmanager/RCTTextLayoutManager.mm | 85 +++++++++---------- 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index 908cfc0b612b..2d465ee4ed54 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -52,6 +52,23 @@ BOOL RCTIsAttributedStringEffectivelySame( NSDictionary *insensitiveAttributes, const facebook::react::TextAttributes &baseTextAttributes); +/* + * Normalized [0, 1] start/end points (top-left origin, y increasing downward) describing the + * gradient line for `angleDegrees`, following the same convention as `CAGradientLayer`: 0deg runs + * left->right, 90deg runs top->bottom, and increasing angles rotate from there. Callers map these + * onto their target rect (e.g. the layer frame, or glyph bounds in a flipped context). Sharing this + * keeps text gradient direction identical across mirror and clamp modes and across the two files + * that draw them. + */ +static inline void RCTNormalizedGradientPointsForAngle(CGFloat angleDegrees, CGPoint *outStart, CGPoint *outEnd) +{ + CGFloat radians = angleDegrees * M_PI / 180.0; + CGFloat halfCos = 0.5 * cos(radians); + CGFloat halfSin = 0.5 * sin(radians); + *outStart = CGPointMake(0.5 - halfCos, 0.5 - halfSin); + *outEnd = CGPointMake(0.5 + halfCos, 0.5 + halfSin); +} + static inline NSData *RCTWrapEventEmitter(const facebook::react::SharedEventEmitter &eventEmitter) { auto eventEmitterPtr = new std::weak_ptr(eventEmitter); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 14c2d4980db3..f5a7d82ff605 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -196,15 +196,11 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex gradient.frame = CGRectMake(0, 0, patternWidth, height); gradient.colors = cgColors; - CGFloat radians = angle * M_PI / 180.0; - - CGFloat startX = 0.5 - 0.5 * cos(radians); - CGFloat startY = 0.5 - 0.5 * sin(radians); - CGFloat endX = 0.5 + 0.5 * cos(radians); - CGFloat endY = 0.5 + 0.5 * sin(radians); - - gradient.startPoint = CGPointMake(startX, startY); - gradient.endPoint = CGPointMake(endX, endY); + CGPoint startPoint; + CGPoint endPoint; + RCTNormalizedGradientPointsForAngle(angle, &startPoint, &endPoint); + gradient.startPoint = startPoint; + gradient.endPoint = endPoint; UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 79e05487beba..5738c3741c81 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -56,6 +56,27 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return strokeWidth; } +// Returns the first value for `attributeName` within `range` that is a kind of `expectedClass`, or +// nil if there is none. Used to read the custom stroke/gradient attributes off the text storage. +static id RCTFirstTextAttributeValue( + NSAttributedString *attributedString, + NSAttributedStringKey attributeName, + NSRange range, + Class expectedClass) +{ + __block id result = nil; + [attributedString enumerateAttribute:attributeName + inRange:range + options:0 + usingBlock:^(id value, NSRange valueRange, BOOL *stop) { + if ([value isKindOfClass:expectedClass]) { + result = value; + *stop = YES; + } + }]; + return result; +} + // Draws `attributedString` with CoreText in the flipped coordinate space shared by the custom // stroke and gradient-fill passes, using `drawingMode` (stroke / fill / clip). The caller owns the // surrounding graphics-state save/restore: a clip accumulated via kCGTextClip must outlive this @@ -141,43 +162,23 @@ - (void)drawAttributedString:(AttributedString)attributedString NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; CGFloat strokeWidth = getStrokeWidth(textStorage); - __block UIColor *strokeColor = nil; - if (strokeWidth > 0) { - [textStorage enumerateAttribute:@"RCTTextStrokeColor" - inRange:characterRange - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if ([value isKindOfClass:[UIColor class]]) { - strokeColor = value; - *stop = YES; - } - }]; - } + UIColor *strokeColor = strokeWidth > 0 + ? RCTFirstTextAttributeValue(textStorage, @"RCTTextStrokeColor", characterRange, [UIColor class]) + : nil; // Clamp-mode gradient fill parameters (see RCTAttributedTextUtils.mm). When present, the fill is // drawn as a single glyph-clipped gradient that clamps to its edge colors - the iOS equivalent of // Android's Shader.TileMode.CLAMP - instead of the tiled pattern image used for mirror mode. - __block NSArray *gradientColors = nil; - __block CGFloat gradientAngle = 0.0; - [textStorage enumerateAttribute:@"RCTTextGradientColors" - inRange:characterRange - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if ([value isKindOfClass:[NSArray class]] && [(NSArray *)value count] > 0) { - gradientColors = value; - *stop = YES; - } - }]; + NSArray *gradientColors = + RCTFirstTextAttributeValue(textStorage, @"RCTTextGradientColors", characterRange, [NSArray class]); + if (gradientColors.count == 0) { + gradientColors = nil; + } + CGFloat gradientAngle = 0.0; if (gradientColors != nil) { - [textStorage enumerateAttribute:@"RCTTextGradientAngle" - inRange:characterRange - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if ([value isKindOfClass:[NSNumber class]]) { - gradientAngle = [value floatValue]; - *stop = YES; - } - }]; + NSNumber *angleValue = + RCTFirstTextAttributeValue(textStorage, @"RCTTextGradientAngle", characterRange, [NSNumber class]); + gradientAngle = angleValue != nil ? angleValue.floatValue : 0.0; } BOOL hasStroke = (strokeWidth > 0 && strokeColor != nil); @@ -235,21 +236,19 @@ - (void)drawAttributedString:(AttributedString)attributedString // color rather than being left uncovered or re-tiled. CGRect glyphBounds = [layoutManager usedRectForTextContainer:textContainer]; - // Map the angle to start/end points across the glyph bounds. Coordinates are in the flipped - // CoreText space established by the draw helper (origin bottom-left, y increasing upward), so - // the visual top of the text sits at the highest y. This matches the normalized start/end - // convention used for mirror mode in RCTAttributedTextUtils.mm. - CGFloat radians = gradientAngle * M_PI / 180.0; - CGFloat startNormX = 0.5 - 0.5 * cos(radians); - CGFloat startNormY = 0.5 - 0.5 * sin(radians); - CGFloat endNormX = 0.5 + 0.5 * cos(radians); - CGFloat endNormY = 0.5 + 0.5 * sin(radians); + // Same normalized start/end convention as mirror mode; mapped onto the glyph bounds. The + // normalized points use a top-left origin with y increasing downward, while the draw helper + // set up a flipped CoreText space (origin bottom-left, y increasing upward), so the y axis is + // flipped here (topY - normY * height) and the visual top of the text sits at the highest y. + CGPoint startNorm; + CGPoint endNorm; + RCTNormalizedGradientPointsForAngle(gradientAngle, &startNorm, &endNorm); CGFloat topY = frame.size.height - glyphBounds.origin.y; CGPoint start = CGPointMake( - glyphBounds.origin.x + startNormX * glyphBounds.size.width, topY - startNormY * glyphBounds.size.height); + glyphBounds.origin.x + startNorm.x * glyphBounds.size.width, topY - startNorm.y * glyphBounds.size.height); CGPoint end = CGPointMake( - glyphBounds.origin.x + endNormX * glyphBounds.size.width, topY - endNormY * glyphBounds.size.height); + glyphBounds.origin.x + endNorm.x * glyphBounds.size.width, topY - endNorm.y * glyphBounds.size.height); CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (CFArrayRef)gradientColors, NULL); From 73d8f6ba3f5fb85532604cf606151a16ac1c0e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:08:59 +0000 Subject: [PATCH 4/5] iOS: simplify gradient draw path per cleanup review Applies the actionable findings from a quality pass: - Name the private draw-attribute keys as shared constants in RCTAttributedTextUtils.h (RCTTextStrokeWidth/Color, RCTTextGradientColors/Angle) and reference them from both the producer and consumer, so the cross-file contract can't silently drift on a typo. - Collapse the gradient-angle extraction to a single expression (floatValue on a nil NSNumber is the desired 0 default). - Drop the dead `if (attributes != nil)` guard - the sole caller always passes a dictionary. - Create the device RGB color space once via dispatch_once instead of allocating/freeing it on every gradient draw. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c --- .../RCTAttributedTextUtils.h | 9 +++++++ .../RCTAttributedTextUtils.mm | 10 ++++---- .../textlayoutmanager/RCTTextLayoutManager.mm | 24 ++++++++++--------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index 2d465ee4ed54..11db24349580 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -19,6 +19,15 @@ NSString *const RCTAttributedStringEventEmitterKey = @"EventEmitter"; // String representation of either `role` or `accessibilityRole` NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"AccessibilityRole"; +// Private attribute keys used to carry parameters for the custom glyph-level draw passes in +// RCTTextLayoutManager (outer stroke and clamp-mode gradient fill). Written in +// RCTNSTextAttributesFromTextAttributes and read back at draw time; kept as shared constants so the +// producer and consumer can't drift. +NSString *const RCTTextStrokeWidthAttributeName = @"RCTTextStrokeWidth"; +NSString *const RCTTextStrokeColorAttributeName = @"RCTTextStrokeColor"; +NSString *const RCTTextGradientColorsAttributeName = @"RCTTextGradientColors"; +NSString *const RCTTextGradientAngleAttributeName = @"RCTTextGradientAngle"; + /* * Creates `NSTextAttributes` from given `facebook::react::TextAttributes` */ diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index f5a7d82ff605..2031ec1bd6e6 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -175,10 +175,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex gradientColors = fadedColors; } - if (attributes != nil) { - attributes[@"RCTTextGradientColors"] = gradientColors; - attributes[@"RCTTextGradientAngle"] = @(angle); - } + attributes[RCTTextGradientColorsAttributeName] = gradientColors; + attributes[RCTTextGradientAngleAttributeName] = @(angle); effectiveForegroundColor = [UIColor colorWithCGColor:(__bridge CGColorRef)cgColors.lastObject]; } else { @@ -355,8 +353,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex // Instead, we do custom two-pass rendering to get true outer stroke if (!isnan(textAttributes.textStrokeWidth) && textAttributes.textStrokeWidth > 0) { UIColor *strokeColorToUse = RCTUIColorFromSharedColor(textAttributes.textStrokeColor) ?: effectiveForegroundColor; - attributes[@"RCTTextStrokeWidth"] = @(textAttributes.textStrokeWidth); - attributes[@"RCTTextStrokeColor"] = strokeColorToUse; + attributes[RCTTextStrokeWidthAttributeName] = @(textAttributes.textStrokeWidth); + attributes[RCTTextStrokeColorAttributeName] = strokeColorToUse; } // Special diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 5738c3741c81..30fb11eadd19 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -42,7 +42,7 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return 0; } __block CGFloat strokeWidth = 0; - [attributedString enumerateAttribute:@"RCTTextStrokeWidth" + [attributedString enumerateAttribute:RCTTextStrokeWidthAttributeName inRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) { @@ -163,23 +163,21 @@ - (void)drawAttributedString:(AttributedString)attributedString CGFloat strokeWidth = getStrokeWidth(textStorage); UIColor *strokeColor = strokeWidth > 0 - ? RCTFirstTextAttributeValue(textStorage, @"RCTTextStrokeColor", characterRange, [UIColor class]) + ? RCTFirstTextAttributeValue(textStorage, RCTTextStrokeColorAttributeName, characterRange, [UIColor class]) : nil; // Clamp-mode gradient fill parameters (see RCTAttributedTextUtils.mm). When present, the fill is // drawn as a single glyph-clipped gradient that clamps to its edge colors - the iOS equivalent of // Android's Shader.TileMode.CLAMP - instead of the tiled pattern image used for mirror mode. NSArray *gradientColors = - RCTFirstTextAttributeValue(textStorage, @"RCTTextGradientColors", characterRange, [NSArray class]); + RCTFirstTextAttributeValue(textStorage, RCTTextGradientColorsAttributeName, characterRange, [NSArray class]); if (gradientColors.count == 0) { gradientColors = nil; } - CGFloat gradientAngle = 0.0; - if (gradientColors != nil) { - NSNumber *angleValue = - RCTFirstTextAttributeValue(textStorage, @"RCTTextGradientAngle", characterRange, [NSNumber class]); - gradientAngle = angleValue != nil ? angleValue.floatValue : 0.0; - } + // `floatValue` on a nil NSNumber is 0, which is also the desired default when the angle is absent. + CGFloat gradientAngle = + [RCTFirstTextAttributeValue(textStorage, RCTTextGradientAngleAttributeName, characterRange, [NSNumber class]) + floatValue]; BOOL hasStroke = (strokeWidth > 0 && strokeColor != nil); BOOL hasGradientFill = (gradientColors != nil); @@ -250,7 +248,12 @@ - (void)drawAttributedString:(AttributedString)attributedString CGPoint end = CGPointMake( glyphBounds.origin.x + endNorm.x * glyphBounds.size.width, topY - endNorm.y * glyphBounds.size.height); - CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + // Device RGB is immutable and shareable, so create it once rather than per draw. + static CGColorSpaceRef colorSpace; + static dispatch_once_t colorSpaceToken; + dispatch_once(&colorSpaceToken, ^{ + colorSpace = CGColorSpaceCreateDeviceRGB(); + }); CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (CFArrayRef)gradientColors, NULL); if (gradient != NULL) { // kCGGradientDrawsBefore/AfterStartLocation clamps pixels beyond the gradient ends to the @@ -263,7 +266,6 @@ - (void)drawAttributedString:(AttributedString)attributedString kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation); CGGradientRelease(gradient); } - CGColorSpaceRelease(colorSpace); } CGContextRestoreGState(context); From 644b73e6b3b9fd4b6f70bcb851041b9e3a1aef65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 00:17:05 +0000 Subject: [PATCH 5/5] iOS: simplify clamp-mode text gradient to the pattern-image path Per review, the draw-time glyph-clip approach was more machinery than this edge case needs. Revert the RCTTextLayoutManager draw-path changes (and the cross-file attribute plumbing) and handle clamp entirely where the mirror path already lives, in RCTEffectiveForegroundColorFromTextAttributes. The wrap artifact comes from colorWithPatternImage tiling a pattern shorter than the text (descenders below the last stop wrap to the opposite-end color). CAGradientLayer itself already clamps to its edge colors, so for clamp mode we render the pattern taller than any single line (padded by the font's line height) and scale the gradient endpoints so it spans only its intended height at the top - CAGradientLayer holds the edge color below it, and the taller pattern means a single line never tiles vertically. Trade-off (accepted): reliably fixes the vertical case that wraps. Text width is unbounded, so a horizontal gradient that is too short can still tile; multiline repeats per pattern height rather than clamping globally. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c --- .../RCTAttributedTextUtils.h | 9 - .../RCTAttributedTextUtils.mm | 102 ++++----- .../textlayoutmanager/RCTTextLayoutManager.mm | 202 +++++------------- 3 files changed, 105 insertions(+), 208 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index 11db24349580..2d465ee4ed54 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -19,15 +19,6 @@ NSString *const RCTAttributedStringEventEmitterKey = @"EventEmitter"; // String representation of either `role` or `accessibilityRole` NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"AccessibilityRole"; -// Private attribute keys used to carry parameters for the custom glyph-level draw passes in -// RCTTextLayoutManager (outer stroke and clamp-mode gradient fill). Written in -// RCTNSTextAttributesFromTextAttributes and read back at draw time; kept as shared constants so the -// producer and consumer can't drift. -NSString *const RCTTextStrokeWidthAttributeName = @"RCTTextStrokeWidth"; -NSString *const RCTTextStrokeColorAttributeName = @"RCTTextStrokeColor"; -NSString *const RCTTextGradientColorsAttributeName = @"RCTTextGradientColors"; -NSString *const RCTTextGradientAngleAttributeName = @"RCTTextGradientAngle"; - /* * Creates `NSTextAttributes` from given `facebook::react::TextAttributes` */ diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 2031ec1bd6e6..6ba535619ff1 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -136,9 +136,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex return RCTFontWithFontProperties(fontProperties); } -inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes( - const TextAttributes &textAttributes, - NSMutableDictionary *attributes) +inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes(const TextAttributes &textAttributes) { UIColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [UIColor blackColor]; @@ -155,59 +153,53 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex BOOL isClampMode = textAttributes.gradientMode.value_or("") == "clamp"; CGFloat angle = !isnan(textAttributes.gradientAngle) ? textAttributes.gradientAngle : 0.0; + CGFloat patternWidth = + (!isnan(textAttributes.gradientWidth) && textAttributes.gradientWidth > 0) ? textAttributes.gradientWidth : 100; + CGFloat lineHeight = !isnan(textAttributes.lineHeight) + ? textAttributes.lineHeight + : (!isnan(textAttributes.fontSize) ? textAttributes.fontSize : 14); + CGFloat gradientHeight = lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); + + CGPoint startPoint; + CGPoint endPoint; + RCTNormalizedGradientPointsForAngle(angle, &startPoint, &endPoint); + + CGFloat imageHeight = gradientHeight; if (isClampMode) { - // Clamp mode matches Android's Shader.TileMode.CLAMP. Rather than tiling a pattern image - // (which wraps vertically and leaves descenders re-tiled), the fill is drawn by - // RCTTextLayoutManager as a single glyph-clipped gradient that clamps to its edge colors. - // Carry the gradient parameters as attributes for that custom draw path, and keep a solid - // fallback foreground (the last/edge color) so measurement and any non-custom draw path - // remain correct. - NSArray *gradientColors = cgColors; - if (!isnan(textAttributes.opacity)) { - NSMutableArray *fadedColors = [NSMutableArray arrayWithCapacity:cgColors.count]; - for (id cgColor in cgColors) { - CGColorRef color = (__bridge CGColorRef)cgColor; - CGColorRef fadedColor = - CGColorCreateCopyWithAlpha(color, CGColorGetAlpha(color) * textAttributes.opacity); - [fadedColors addObject:(__bridge id)fadedColor]; - CGColorRelease(fadedColor); - } - gradientColors = fadedColors; - } - - attributes[RCTTextGradientColorsAttributeName] = gradientColors; - attributes[RCTTextGradientAngleAttributeName] = @(angle); - - effectiveForegroundColor = [UIColor colorWithCGColor:(__bridge CGColorRef)cgColors.lastObject]; + // Clamp mode (Shader.TileMode.CLAMP on Android): the gradient must not repeat when the text + // is taller than the gradient - e.g. descenders below the last stop should hold the edge + // color, not wrap back to the opposite end. `colorWithPatternImage` tiles, so instead of + // appending a color we render the pattern taller than any single line of text and let + // CAGradientLayer clamp the padded remainder to its edge colors. Text height is bounded by + // the font, so padding the height covers the vertical case (the one that wraps); a + // horizontal gradient's width is unbounded and can still tile. + UIFont *font = RCTEffectiveFontFromTextAttributes(textAttributes); + CGFloat glyphHeight = font != nil ? font.lineHeight : gradientHeight; + imageHeight = MAX(gradientHeight, glyphHeight) * 2.0; + + // Keep the gradient spanning its intended `gradientHeight` at the top of the padded image so + // everything below it clamps to the edge color. + CGFloat scaleY = imageHeight > 0 ? gradientHeight / imageHeight : 1.0; + startPoint.y *= scaleY; + endPoint.y *= scaleY; } else { - // Mirror mode (default) duplicates the first color at the end and tiles a pattern image. + // Mirror mode (default) duplicates the first color at the end so the tiled pattern mirrors. [cgColors addObject:cgColors[0]]; + } + + CAGradientLayer *gradient = [CAGradientLayer layer]; + gradient.frame = CGRectMake(0, 0, patternWidth, imageHeight); + gradient.colors = cgColors; + gradient.startPoint = startPoint; + gradient.endPoint = endPoint; + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; + UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + [gradient renderInContext:rendererContext.CGContext]; + }]; - CAGradientLayer *gradient = [CAGradientLayer layer]; - CGFloat patternWidth = (!isnan(textAttributes.gradientWidth) && textAttributes.gradientWidth > 0) - ? textAttributes.gradientWidth - : 100; - CGFloat lineHeight = !isnan(textAttributes.lineHeight) - ? textAttributes.lineHeight - : (!isnan(textAttributes.fontSize) ? textAttributes.fontSize : 14); - CGFloat height = lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); - gradient.frame = CGRectMake(0, 0, patternWidth, height); - gradient.colors = cgColors; - - CGPoint startPoint; - CGPoint endPoint; - RCTNormalizedGradientPointsForAngle(angle, &startPoint, &endPoint); - gradient.startPoint = startPoint; - gradient.endPoint = endPoint; - - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; - UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { - [gradient renderInContext:rendererContext.CGContext]; - }]; - - if (gradientImage) { - effectiveForegroundColor = [UIColor colorWithPatternImage:gradientImage]; - } + if (gradientImage) { + effectiveForegroundColor = [UIColor colorWithPatternImage:gradientImage]; } } } @@ -244,7 +236,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex } // Colors - UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes, attributes); + UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes); if (textAttributes.foregroundColor || !isnan(textAttributes.opacity) || textAttributes.gradientColors.has_value()) { @@ -353,8 +345,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex // Instead, we do custom two-pass rendering to get true outer stroke if (!isnan(textAttributes.textStrokeWidth) && textAttributes.textStrokeWidth > 0) { UIColor *strokeColorToUse = RCTUIColorFromSharedColor(textAttributes.textStrokeColor) ?: effectiveForegroundColor; - attributes[RCTTextStrokeWidthAttributeName] = @(textAttributes.textStrokeWidth); - attributes[RCTTextStrokeColorAttributeName] = strokeColorToUse; + attributes[@"RCTTextStrokeWidth"] = @(textAttributes.textStrokeWidth); + attributes[@"RCTTextStrokeColor"] = strokeColorToUse; } // Special diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 30fb11eadd19..1b0ae8c85016 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -42,7 +42,7 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return 0; } __block CGFloat strokeWidth = 0; - [attributedString enumerateAttribute:RCTTextStrokeWidthAttributeName + [attributedString enumerateAttribute:@"RCTTextStrokeWidth" inRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) { @@ -56,54 +56,6 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return strokeWidth; } -// Returns the first value for `attributeName` within `range` that is a kind of `expectedClass`, or -// nil if there is none. Used to read the custom stroke/gradient attributes off the text storage. -static id RCTFirstTextAttributeValue( - NSAttributedString *attributedString, - NSAttributedStringKey attributeName, - NSRange range, - Class expectedClass) -{ - __block id result = nil; - [attributedString enumerateAttribute:attributeName - inRange:range - options:0 - usingBlock:^(id value, NSRange valueRange, BOOL *stop) { - if ([value isKindOfClass:expectedClass]) { - result = value; - *stop = YES; - } - }]; - return result; -} - -// Draws `attributedString` with CoreText in the flipped coordinate space shared by the custom -// stroke and gradient-fill passes, using `drawingMode` (stroke / fill / clip). The caller owns the -// surrounding graphics-state save/restore: a clip accumulated via kCGTextClip must outlive this -// call so the gradient can be drawn into it. -static void RCTDrawAttributedStringInFlippedContext( - CGContextRef context, - NSAttributedString *attributedString, - CGSize frameSize, - CGFloat translateX, - CGFloat translateY, - CGTextDrawingMode drawingMode) -{ - CGContextSetTextDrawingMode(context, drawingMode); - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM(context, translateX, translateY); - CGContextScaleCTM(context, 1.0, -1.0); - - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString); - CGMutablePathRef path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, frameSize.width, frameSize.height)); - CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - CTFrameDraw(ctFrame, context); - CFRelease(ctFrame); - CFRelease(path); - CFRelease(framesetter); -} - - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext @@ -162,112 +114,74 @@ - (void)drawAttributedString:(AttributedString)attributedString NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; CGFloat strokeWidth = getStrokeWidth(textStorage); - UIColor *strokeColor = strokeWidth > 0 - ? RCTFirstTextAttributeValue(textStorage, RCTTextStrokeColorAttributeName, characterRange, [UIColor class]) - : nil; - - // Clamp-mode gradient fill parameters (see RCTAttributedTextUtils.mm). When present, the fill is - // drawn as a single glyph-clipped gradient that clamps to its edge colors - the iOS equivalent of - // Android's Shader.TileMode.CLAMP - instead of the tiled pattern image used for mirror mode. - NSArray *gradientColors = - RCTFirstTextAttributeValue(textStorage, RCTTextGradientColorsAttributeName, characterRange, [NSArray class]); - if (gradientColors.count == 0) { - gradientColors = nil; + __block UIColor *strokeColor = nil; + if (strokeWidth > 0) { + [textStorage enumerateAttribute:@"RCTTextStrokeColor" + inRange:characterRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value isKindOfClass:[UIColor class]]) { + strokeColor = value; + *stop = YES; + } + }]; } - // `floatValue` on a nil NSNumber is 0, which is also the desired default when the angle is absent. - CGFloat gradientAngle = - [RCTFirstTextAttributeValue(textStorage, RCTTextGradientAngleAttributeName, characterRange, [NSNumber class]) - floatValue]; - - BOOL hasStroke = (strokeWidth > 0 && strokeColor != nil); - BOOL hasGradientFill = (gradientColors != nil); - - if (hasStroke || hasGradientFill) { - // Custom glyph-level rendering (outer stroke and/or clamped gradient fill) that - // -drawGlyphsForGlyphRange: can't express, so both passes re-draw the glyphs with CoreText in a - // shared flipped coordinate space. This deliberately trades some NSLayoutManager fidelity - // (truncation/ellipsize, adjustsFontSizeToFit) for glyph-path control; plain text keeps the - // NSLayoutManager path below. + if (strokeWidth > 0 && strokeColor) { CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetLineWidth(context, strokeWidth); + CGContextSetLineJoin(context, kCGLineJoinRound); + CGContextSetLineCap(context, kCGLineCapRound); // Shift glyphs by strokeWidth/2 when there's room so the outward stroke fits; otherwise // center whatever slack is left. `_measureTextStorage:` grew the returned size by // strokeWidth, so `frame.size` is already inflated - `frame.size - strokeWidth` recovers // the natural text size and `viewBounds - frame + strokeWidth` is the slack around it. - // With no stroke both shifts are 0, so the fill lands exactly where drawGlyphsForGlyphRange - // would have placed it. - CGFloat strokeShiftX = 0; - CGFloat strokeShiftY = 0; - if (hasStroke) { - CGFloat slackX = viewBounds.size.width - frame.size.width + strokeWidth; - CGFloat slackY = viewBounds.size.height - frame.size.height + strokeWidth; - strokeShiftX = (slackX <= 0) ? 0 : (slackX >= strokeWidth ? strokeWidth / 2.0 : slackX / 2.0); - strokeShiftY = (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); - } - CGFloat translateX = frame.origin.x + strokeShiftX; - CGFloat translateY = viewBounds.size.height - frame.origin.y + strokeShiftY; + CGFloat slackX = viewBounds.size.width - frame.size.width + strokeWidth; + CGFloat slackY = viewBounds.size.height - frame.size.height + strokeWidth; + CGFloat strokeShiftX = + (slackX <= 0) ? 0 : (slackX >= strokeWidth ? strokeWidth / 2.0 : slackX / 2.0); + CGFloat strokeShiftY = + (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); // PASS 1: Draw stroke outline - if (hasStroke) { - CGContextSetLineWidth(context, strokeWidth); - CGContextSetLineJoin(context, kCGLineJoinRound); - CGContextSetLineCap(context, kCGLineCapRound); - - NSMutableAttributedString *strokeText = [textStorage mutableCopy]; - [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; - - CGContextSaveGState(context); - RCTDrawAttributedStringInFlippedContext(context, strokeText, frame.size, translateX, translateY, kCGTextStroke); - CGContextRestoreGState(context); - } - - // PASS 2: Draw fill on top. For a gradient fill the glyph outlines are accumulated into the clip - // (kCGTextClip) and a single clamped gradient is drawn into it; otherwise the glyphs are filled - // directly (kCGTextFill). CGContextSaveGState(context); - RCTDrawAttributedStringInFlippedContext( - context, textStorage, frame.size, translateX, translateY, hasGradientFill ? kCGTextClip : kCGTextFill); - - if (hasGradientFill) { - // `usedRectForTextContainer` includes the descent, so sizing the gradient axis to it - // guarantees descenders (which extend below the last stop) are covered by the clamped edge - // color rather than being left uncovered or re-tiled. - CGRect glyphBounds = [layoutManager usedRectForTextContainer:textContainer]; - - // Same normalized start/end convention as mirror mode; mapped onto the glyph bounds. The - // normalized points use a top-left origin with y increasing downward, while the draw helper - // set up a flipped CoreText space (origin bottom-left, y increasing upward), so the y axis is - // flipped here (topY - normY * height) and the visual top of the text sits at the highest y. - CGPoint startNorm; - CGPoint endNorm; - RCTNormalizedGradientPointsForAngle(gradientAngle, &startNorm, &endNorm); - - CGFloat topY = frame.size.height - glyphBounds.origin.y; - CGPoint start = CGPointMake( - glyphBounds.origin.x + startNorm.x * glyphBounds.size.width, topY - startNorm.y * glyphBounds.size.height); - CGPoint end = CGPointMake( - glyphBounds.origin.x + endNorm.x * glyphBounds.size.width, topY - endNorm.y * glyphBounds.size.height); - - // Device RGB is immutable and shareable, so create it once rather than per draw. - static CGColorSpaceRef colorSpace; - static dispatch_once_t colorSpaceToken; - dispatch_once(&colorSpaceToken, ^{ - colorSpace = CGColorSpaceCreateDeviceRGB(); - }); - CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (CFArrayRef)gradientColors, NULL); - if (gradient != NULL) { - // kCGGradientDrawsBefore/AfterStartLocation clamps pixels beyond the gradient ends to the - // edge colors - the iOS equivalent of Shader.TileMode.CLAMP. - CGContextDrawLinearGradient( - context, - gradient, - start, - end, - kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation); - CGGradientRelease(gradient); - } - } + CGContextSetTextDrawingMode(context, kCGTextStroke); + + NSMutableAttributedString *strokeText = [textStorage mutableCopy]; + [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; + + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM( + context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); + CGContextScaleCTM(context, 1.0, -1.0); + + CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText); + CGMutablePathRef path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); + CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(ctFrame, context); + CFRelease(ctFrame); + CFRelease(path); + CFRelease(framesetter); + CGContextRestoreGState(context); + // PASS 2: Draw fill on top + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, kCGTextFill); + + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextTranslateCTM( + context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); + CGContextScaleCTM(context, 1.0, -1.0); + + framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage); + path = CGPathCreateMutable(); + CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); + ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); + CTFrameDraw(ctFrame, context); + CFRelease(ctFrame); + CFRelease(path); + CFRelease(framesetter); CGContextRestoreGState(context); } else { [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:frame.origin];