From e3f6041a130c93044ac663572e1a10d657b50dda Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Thu, 18 Dec 2025 14:35:16 -0800 Subject: [PATCH 1/3] Implemented simple integer gradientWidth param. --- .../Text/BaseText/RCTBaseTextViewManager.mm | 1 + .../Libraries/Text/RCTTextAttributes.h | 1 + .../Libraries/Text/RCTTextAttributes.mm | 25 +++++++++++-------- .../react-native/Libraries/Text/Text.d.ts | 5 ++++ .../Libraries/Text/TextNativeComponent.js | 2 ++ .../views/text/ReactBaseTextShadowNode.java | 10 +++++++- .../react/views/text/TextAttributeProps.java | 14 +++++++++++ .../react/views/text/TextLayoutManager.kt | 6 +++-- .../text/internal/span/LinearGradientSpan.kt | 14 ++++++++--- 9 files changed, 61 insertions(+), 17 deletions(-) diff --git a/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm b/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm index 67c4d674396e8a..ff69c3436e2093 100644 --- a/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm +++ b/packages/react-native/Libraries/Text/BaseText/RCTBaseTextViewManager.mm @@ -30,6 +30,7 @@ - (RCTShadowView *)shadowView RCT_REMAP_SHADOW_PROPERTY(backgroundColor, textAttributes.backgroundColor, UIColor) RCT_REMAP_SHADOW_PROPERTY(gradientColors, textAttributes.gradientColors, NSArray) RCT_REMAP_SHADOW_PROPERTY(gradientAngle, textAttributes.gradientAngle, CGFloat) +RCT_REMAP_SHADOW_PROPERTY(gradientWidth, textAttributes.gradientWidth, CGFloat) RCT_REMAP_SHADOW_PROPERTY(opacity, textAttributes.opacity, CGFloat) // Font RCT_REMAP_SHADOW_PROPERTY(fontFamily, textAttributes.fontFamily, NSString) diff --git a/packages/react-native/Libraries/Text/RCTTextAttributes.h b/packages/react-native/Libraries/Text/RCTTextAttributes.h index b087336ebd91d8..d246ec6a3f366e 100644 --- a/packages/react-native/Libraries/Text/RCTTextAttributes.h +++ b/packages/react-native/Libraries/Text/RCTTextAttributes.h @@ -28,6 +28,7 @@ extern NSString *const RCTTextAttributesTagAttributeName; @property (nonatomic, strong, nullable) UIColor *backgroundColor; @property (nonatomic, copy, nullable) NSArray *gradientColors; @property (nonatomic, assign) CGFloat gradientAngle; +@property (nonatomic, assign) CGFloat gradientWidth; // Width of gradient pattern in pixels; NAN = use default (100px) @property (nonatomic, assign) CGFloat opacity; // Font @property (nonatomic, copy, nullable) NSString *fontFamily; diff --git a/packages/react-native/Libraries/Text/RCTTextAttributes.mm b/packages/react-native/Libraries/Text/RCTTextAttributes.mm index 5494e15c374100..e04ff1a536e928 100644 --- a/packages/react-native/Libraries/Text/RCTTextAttributes.mm +++ b/packages/react-native/Libraries/Text/RCTTextAttributes.mm @@ -34,6 +34,7 @@ - (instancetype)init _textTransform = RCTTextTransformUndefined; _textStrokeWidth = NAN; _gradientAngle = NAN; + _gradientWidth = NAN; } return self; @@ -50,6 +51,7 @@ - (void)applyTextAttributes:(RCTTextAttributes *)textAttributes _backgroundColor = textAttributes->_backgroundColor ?: _backgroundColor; _gradientColors = textAttributes->_gradientColors ?: _gradientColors; _gradientAngle = !isnan(textAttributes->_gradientAngle) ? textAttributes->_gradientAngle : _gradientAngle; + _gradientWidth = !isnan(textAttributes->_gradientWidth) ? textAttributes->_gradientWidth : _gradientWidth; _opacity = !isnan(textAttributes->_opacity) ? (isnan(_opacity) ? 1.0 : _opacity) * textAttributes->_opacity : _opacity; @@ -168,10 +170,8 @@ - (NSParagraphStyle *)effectiveParagraphStyle } // Colors - UIColor *effectiveForegroundColor = self.effectiveForegroundColor; - - if (_foregroundColor || !isnan(_opacity)) { - attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; + if (_foregroundColor || _gradientColors || !isnan(_opacity)) { + attributes[NSForegroundColorAttributeName] = self.effectiveForegroundColor; } if (_backgroundColor || !isnan(_opacity)) { @@ -204,8 +204,9 @@ - (NSParagraphStyle *)effectiveParagraphStyle } if (_textDecorationColor || isTextDecorationEnabled) { - attributes[NSStrikethroughColorAttributeName] = _textDecorationColor ?: effectiveForegroundColor; - attributes[NSUnderlineColorAttributeName] = _textDecorationColor ?: effectiveForegroundColor; + UIColor *baseForegroundColor = _foregroundColor ?: [UIColor blackColor]; + attributes[NSStrikethroughColorAttributeName] = _textDecorationColor ?: baseForegroundColor; + attributes[NSUnderlineColorAttributeName] = _textDecorationColor ?: baseForegroundColor; } // Shadow @@ -220,12 +221,12 @@ - (NSParagraphStyle *)effectiveParagraphStyle // We don't use NSStrokeWidthAttributeName because it centers the stroke on the text path // Instead, we do custom two-pass rendering to get true outer stroke if (!isnan(_textStrokeWidth) && _textStrokeWidth > 0) { - UIColor *strokeColorToUse = _textStrokeColor ?: effectiveForegroundColor; + UIColor *baseForegroundColor = _foregroundColor ?: [UIColor blackColor]; + UIColor *strokeColorToUse = _textStrokeColor ?: baseForegroundColor; attributes[@"RCTTextStrokeWidth"] = @(_textStrokeWidth); attributes[@"RCTTextStrokeColor"] = strokeColorToUse; } - // Special if (_isHighlighted) { attributes[RCTTextAttributesIsHighlightedAttributeName] = @YES; @@ -323,8 +324,10 @@ - (UIColor *)effectiveForegroundColor if([cgColors count] > 0) { [cgColors addObject:cgColors[0]]; CAGradientLayer *gradient = [CAGradientLayer layer]; - // this pattern width corresponds roughly to desktop's pattern width - int patternWidth = 100; + + // Use gradientWidth if specified, otherwise default to 100 + CGFloat patternWidth = (!isnan(_gradientWidth) && _gradientWidth > 0) ? _gradientWidth : 100; + CGFloat height = _lineHeight * self.effectiveFontSizeMultiplier; gradient.frame = CGRectMake(0, 0, patternWidth, height); gradient.colors = cgColors; @@ -423,7 +426,7 @@ - (BOOL)isEqual:(RCTTextAttributes *)textAttributes return RCTTextAttributesCompareObjects(_foregroundColor) && RCTTextAttributesCompareObjects(_backgroundColor) && RCTTextAttributesCompareObjects(_gradientColors) && RCTTextAttributesCompareFloats(_gradientAngle) && - RCTTextAttributesCompareFloats(_opacity) && + RCTTextAttributesCompareFloats(_gradientWidth) && RCTTextAttributesCompareFloats(_opacity) && // Font RCTTextAttributesCompareObjects(_fontFamily) && RCTTextAttributesCompareFloats(_fontSize) && RCTTextAttributesCompareFloats(_fontSizeMultiplier) && RCTTextAttributesCompareFloats(_maxFontSizeMultiplier) && diff --git a/packages/react-native/Libraries/Text/Text.d.ts b/packages/react-native/Libraries/Text/Text.d.ts index 06d0eef5e40f3c..fd8003817f41a3 100644 --- a/packages/react-native/Libraries/Text/Text.d.ts +++ b/packages/react-native/Libraries/Text/Text.d.ts @@ -229,6 +229,11 @@ export interface TextProps */ gradientAngle?: number | undefined; + /** + * Width of the gradient pattern in pixels. Default is 100. + */ + gradientWidth?: number | undefined; + /** * Width of the text stroke (outline). Creates an outer stroke effect. */ diff --git a/packages/react-native/Libraries/Text/TextNativeComponent.js b/packages/react-native/Libraries/Text/TextNativeComponent.js index 57f12efd7d7354..7d8a1bea601df4 100644 --- a/packages/react-native/Libraries/Text/TextNativeComponent.js +++ b/packages/react-native/Libraries/Text/TextNativeComponent.js @@ -49,6 +49,7 @@ const textViewConfig = { lineBreakStrategyIOS: true, gradientColors: true, gradientAngle: true, + gradientWidth: true, textStrokeWidth: true, textStrokeColor: true, }, @@ -67,6 +68,7 @@ const virtualTextViewConfig = { maxFontSizeMultiplier: true, gradientColors: true, gradientAngle: true, + gradientWidth: true, textStrokeWidth: true, textStrokeColor: true, }, 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..d07574c2cc1a79 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 @@ -177,7 +177,8 @@ private static void buildSpannedFromShadowNode( if (textShadowNode.mGradientColors != null && textShadowNode.mGradientColors.length >= 2) { int effectiveFontSize = textAttributes.getEffectiveFontSize(); float gradientAngle = Float.isNaN(textShadowNode.mGradientAngle) ? 0f : textShadowNode.mGradientAngle; - ops.add(new SetSpanOperation(start, end, new LinearGradientSpan(start * effectiveFontSize, textShadowNode.mGradientColors, gradientAngle))); + float gradientWidth = textShadowNode.mGradientWidth; + ops.add(new SetSpanOperation(start, end, new LinearGradientSpan(start * effectiveFontSize, textShadowNode.mGradientColors, gradientAngle, gradientWidth))); } if (textShadowNode.mIsBackgroundColorSet) { ops.add( @@ -351,6 +352,7 @@ protected Spannable spannedFromShadowNode( protected @Nullable int[] mGradientColors = null; protected float mGradientAngle = Float.NaN; + protected float mGradientWidth = Float.NaN; protected @Nullable AccessibilityRole mAccessibilityRole = null; protected @Nullable Role mRole = null; @@ -547,6 +549,12 @@ public void setGradientAngle(float gradientAngle) { markUpdated(); } + @ReactProp(name = "gradientWidth", defaultFloat = Float.NaN) + public void setGradientWidth(float gradientWidth) { + mGradientWidth = gradientWidth; + markUpdated(); + } + @ReactProp(name = ViewProps.BACKGROUND_COLOR, customType = "Color") public void setBackgroundColor(@Nullable Integer color) { // Background color needs to be handled here for virtual nodes so it can be incorporated into diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java index fee5ecaff10661..49f5085790b76f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java @@ -69,6 +69,7 @@ public class TextAttributeProps { public static final short TA_KEY_TEXT_STROKE_WIDTH = 31; public static final short TA_KEY_TEXT_STROKE_COLOR = 32; public static final short TA_KEY_GRADIENT_ANGLE = 33; + public static final short TA_KEY_GRADIENT_WIDTH = 34; public static final int UNSET = -1; @@ -159,6 +160,7 @@ public class TextAttributeProps { protected @Nullable int[] mGradientColors = null; protected float mGradientAngle = Float.NaN; + protected float mGradientWidth = Float.NaN; private TextAttributeProps() {} @@ -255,6 +257,9 @@ public static TextAttributeProps fromMapBuffer(MapBuffer props) { case TA_KEY_GRADIENT_ANGLE: result.setGradientAngle((float) entry.getDoubleValue()); break; + case TA_KEY_GRADIENT_WIDTH: + result.setGradientWidth((float) entry.getDoubleValue()); + break; case TA_KEY_MAX_FONT_SIZE_MULTIPLIER: result.setMaxFontSizeMultiplier((float) entry.getDoubleValue()); break; @@ -303,6 +308,7 @@ public static TextAttributeProps fromReadableMap(ReactStylesDiffMap props) { result.setRole(getStringProp(props, ViewProps.ROLE)); result.setGradientColors(getArrayProp(props, "gradientColors")); result.setGradientAngle(getFloatProp(props, "gradientAngle", Float.NaN)); + result.setGradientWidth(getFloatProp(props, "gradientWidth", Float.NaN)); result.setTextStrokeWidth(getFloatProp(props, "textStrokeWidth", Float.NaN)); if (props.hasKey("textStrokeColor")) { result.setTextStrokeColor(props.getInt("textStrokeColor", 0)); @@ -824,6 +830,14 @@ private void setGradientAngle(float gradientAngle) { mGradientAngle = gradientAngle; } + public float getGradientWidth() { + return mGradientWidth; + } + + private void setGradientWidth(float gradientWidth) { + mGradientWidth = gradientWidth; + } + public static int getTextBreakStrategy(@Nullable String textBreakStrategy) { int androidTextBreakStrategy = DEFAULT_BREAK_STRATEGY; if (textBreakStrategy != 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 735dd1f87488eb..b9b48ccf4f043e 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 @@ -259,11 +259,12 @@ internal object TextLayoutManager { if (textAttributes.gradientColors != null && textAttributes.gradientColors!!.size >= 2) { val effectiveFontSize = textAttributes.effectiveFontSize val gradientAngle = if (textAttributes.gradientAngle.isNaN()) 0f else textAttributes.gradientAngle + val gradientWidth = textAttributes.gradientWidth ops.add( SetSpanOperation( start, end, - LinearGradientSpan(start * effectiveFontSize.toFloat(), textAttributes.gradientColors!!, gradientAngle))) + LinearGradientSpan(start * effectiveFontSize.toFloat(), textAttributes.gradientColors!!, gradientAngle, gradientWidth))) } if (textAttributes.mIsBackgroundColorSet) { ops.add( @@ -418,8 +419,9 @@ internal object TextLayoutManager { if (fragment.props.gradientColors != null && fragment.props.gradientColors!!.size >= 2) { val effectiveFontSize = fragment.props.effectiveFontSize val gradientAngle = if (fragment.props.gradientAngle.isNaN()) 0f else fragment.props.gradientAngle + val gradientWidth = fragment.props.gradientWidth spannable.setSpan( - LinearGradientSpan(start * effectiveFontSize.toFloat(), fragment.props.gradientColors!!, gradientAngle), + LinearGradientSpan(start * effectiveFontSize.toFloat(), fragment.props.gradientColors!!, gradientAngle, gradientWidth), start, end, spanFlags) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt index 2a50bb1557e27e..bd319aae3f379d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt @@ -6,10 +6,19 @@ import android.text.TextPaint import android.text.style.CharacterStyle import android.text.style.UpdateAppearance +/** + * Span that applies a linear gradient to text. + * + * @param start The x-offset for the gradient start position + * @param colors Array of gradient colors + * @param angle Gradient angle in degrees (0 = horizontal) + * @param gradientWidth Width of the gradient pattern in pixels. Default is 100. + */ public class LinearGradientSpan( private val start: Float, private val colors: IntArray, private val angle: Float = 0f, + private val gradientWidth: Float = 100f, ) : CharacterStyle(), ReactSpan, UpdateAppearance { public override fun updateDrawState(tp: TextPaint) { @@ -18,12 +27,11 @@ public class LinearGradientSpan( tp.setColor(colors[0]) val radians = Math.toRadians(angle.toDouble()) - val width = 100.0f val height = tp.textSize - val centerX = start + width / 2 + val centerX = start + gradientWidth / 2 val centerY = height / 2 - val length = Math.sqrt((width * width + height * height).toDouble()).toFloat() / 2 + val length = Math.sqrt((gradientWidth * gradientWidth + height * height).toDouble()).toFloat() / 2 val startX = centerX - length * Math.cos(radians).toFloat() val startY = centerY - length * Math.sin(radians).toFloat() From 51e931a8ab7641178c808db200f091d232275076 Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Thu, 18 Dec 2025 17:58:25 -0800 Subject: [PATCH 2/3] Fixed issue with NaN handling. --- .../react/views/text/internal/span/LinearGradientSpan.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt index bd319aae3f379d..6f8192c1d51158 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt @@ -27,11 +27,12 @@ public class LinearGradientSpan( tp.setColor(colors[0]) val radians = Math.toRadians(angle.toDouble()) + val width = if (gradientWidth.isNaN()) 100f else gradientWidth val height = tp.textSize - - val centerX = start + gradientWidth / 2 + + val centerX = start + width / 2 val centerY = height / 2 - val length = Math.sqrt((gradientWidth * gradientWidth + height * height).toDouble()).toFloat() / 2 + val length = Math.sqrt((width * width + height * height).toDouble()).toFloat() / 2 val startX = centerX - length * Math.cos(radians).toFloat() val startY = centerY - length * Math.sin(radians).toFloat() From f0f6e5d3270419765768cb31aa9ef6a93ca2b99c Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Fri, 19 Dec 2025 10:43:12 -0800 Subject: [PATCH 3/3] Fixing unintended changes. --- .../Libraries/Text/RCTTextAttributes.mm | 18 ++++++++---------- .../text/internal/span/LinearGradientSpan.kt | 4 ++-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/react-native/Libraries/Text/RCTTextAttributes.mm b/packages/react-native/Libraries/Text/RCTTextAttributes.mm index e04ff1a536e928..86fd380b2448bb 100644 --- a/packages/react-native/Libraries/Text/RCTTextAttributes.mm +++ b/packages/react-native/Libraries/Text/RCTTextAttributes.mm @@ -34,7 +34,6 @@ - (instancetype)init _textTransform = RCTTextTransformUndefined; _textStrokeWidth = NAN; _gradientAngle = NAN; - _gradientWidth = NAN; } return self; @@ -170,8 +169,10 @@ - (NSParagraphStyle *)effectiveParagraphStyle } // Colors - if (_foregroundColor || _gradientColors || !isnan(_opacity)) { - attributes[NSForegroundColorAttributeName] = self.effectiveForegroundColor; + UIColor *effectiveForegroundColor = self.effectiveForegroundColor; + + if (_foregroundColor || !isnan(_opacity)) { + attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; } if (_backgroundColor || !isnan(_opacity)) { @@ -204,9 +205,8 @@ - (NSParagraphStyle *)effectiveParagraphStyle } if (_textDecorationColor || isTextDecorationEnabled) { - UIColor *baseForegroundColor = _foregroundColor ?: [UIColor blackColor]; - attributes[NSStrikethroughColorAttributeName] = _textDecorationColor ?: baseForegroundColor; - attributes[NSUnderlineColorAttributeName] = _textDecorationColor ?: baseForegroundColor; + attributes[NSStrikethroughColorAttributeName] = _textDecorationColor ?: effectiveForegroundColor; + attributes[NSUnderlineColorAttributeName] = _textDecorationColor ?: effectiveForegroundColor; } // Shadow @@ -221,12 +221,12 @@ - (NSParagraphStyle *)effectiveParagraphStyle // We don't use NSStrokeWidthAttributeName because it centers the stroke on the text path // Instead, we do custom two-pass rendering to get true outer stroke if (!isnan(_textStrokeWidth) && _textStrokeWidth > 0) { - UIColor *baseForegroundColor = _foregroundColor ?: [UIColor blackColor]; - UIColor *strokeColorToUse = _textStrokeColor ?: baseForegroundColor; + UIColor *strokeColorToUse = _textStrokeColor ?: effectiveForegroundColor; attributes[@"RCTTextStrokeWidth"] = @(_textStrokeWidth); attributes[@"RCTTextStrokeColor"] = strokeColorToUse; } + // Special if (_isHighlighted) { attributes[RCTTextAttributesIsHighlightedAttributeName] = @YES; @@ -324,10 +324,8 @@ - (UIColor *)effectiveForegroundColor if([cgColors count] > 0) { [cgColors addObject:cgColors[0]]; CAGradientLayer *gradient = [CAGradientLayer layer]; - // Use gradientWidth if specified, otherwise default to 100 CGFloat patternWidth = (!isnan(_gradientWidth) && _gradientWidth > 0) ? _gradientWidth : 100; - CGFloat height = _lineHeight * self.effectiveFontSizeMultiplier; gradient.frame = CGRectMake(0, 0, patternWidth, height); gradient.colors = cgColors; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt index 6f8192c1d51158..5628f4ffdee267 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt @@ -18,7 +18,7 @@ public class LinearGradientSpan( private val start: Float, private val colors: IntArray, private val angle: Float = 0f, - private val gradientWidth: Float = 100f, + private val gradientWidth: Float = Float.NaN, ) : CharacterStyle(), ReactSpan, UpdateAppearance { public override fun updateDrawState(tp: TextPaint) { @@ -29,7 +29,7 @@ public class LinearGradientSpan( val radians = Math.toRadians(angle.toDouble()) val width = if (gradientWidth.isNaN()) 100f else gradientWidth val height = tp.textSize - + val centerX = start + width / 2 val centerY = height / 2 val length = Math.sqrt((width * width + height * height).toDouble()).toFloat() / 2