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 28ea7e6762b4..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 @@ -151,34 +151,49 @@ 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]]; - } + CGFloat angle = !isnan(textAttributes.gradientAngle) ? textAttributes.gradientAngle : 0.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); + CGFloat gradientHeight = lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); + + CGPoint startPoint; + CGPoint endPoint; + RCTNormalizedGradientPointsForAngle(angle, &startPoint, &endPoint); + + CGFloat imageHeight = gradientHeight; + if (isClampMode) { + // 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 so the tiled pattern mirrors. + [cgColors addObject:cgColors[0]]; + } - gradient.startPoint = CGPointMake(startX, startY); - gradient.endPoint = CGPointMake(endX, endY); + 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]; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:gradient.frame.size]; UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { [gradient renderInContext:rendererContext.CGContext]; }];