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 b3c5aeacad4e40..c0a9d3e147aab5 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,6 +27,7 @@ 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 kotlin.collections.ArrayList import kotlin.math.roundToInt @@ -99,15 +100,22 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } override fun onDraw(canvas: Canvas) { - if (overflow != Overflow.VISIBLE) { + val layout = preparedLayout?.layout + + // Get shadow adjustment from custom span if configured + val spanned = layout?.text as? Spanned + val shadowAdj = DiscordShadowStyleSpan.getShadowAdjustment(spanned) + + if (overflow != Overflow.VISIBLE && !shadowAdj.hasShadow) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } super.onDraw(canvas) + canvas.translate( - paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) + paddingLeft.toFloat() + shadowAdj.leftOffset, + paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) - val layout = preparedLayout?.layout if (layout != null) { if (selection != null) { selectionPaint.setColor( 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..f97dbed7a34ffd 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.ShadowStyleSpan; +import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan; 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; @@ -234,7 +234,7 @@ private static void buildSpannedFromShadowNode( new SetSpanOperation( start, end, - new ShadowStyleSpan( + new DiscordShadowStyleSpan( 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 906dfbce04905d..05ab157865940d 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 @@ -52,6 +52,7 @@ 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.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; @@ -360,11 +361,19 @@ protected void onDraw(Canvas canvas) { setText(spanned); } - if (mOverflow != Overflow.VISIBLE) { + // Get shadow adjustment from custom span if configured + DiscordShadowStyleSpan.ShadowAdjustment shadowAdj = + DiscordShadowStyleSpan.getShadowAdjustment(spanned); + + canvas.save(); + canvas.translate(shadowAdj.getLeftOffset(), 0); + + if (mOverflow != Overflow.VISIBLE && !shadowAdj.getHasShadow()) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } super.onDraw(canvas); + 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 d2df5400b9a4be..735dd1f87488eb 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,9 +47,9 @@ 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.SetSpanOperation -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.TextInlineViewPlaceholderSpan import com.facebook.yoga.YogaMeasureMode @@ -306,7 +306,7 @@ internal object TextLayoutManager { SetSpanOperation( start, end, - ShadowStyleSpan( + DiscordShadowStyleSpan( textAttributes.mTextShadowOffsetDx, textAttributes.mTextShadowOffsetDy, textAttributes.mTextShadowRadius, @@ -315,7 +315,7 @@ internal object TextLayoutManager { if (!textAttributes.textStrokeWidth.isNaN() && textAttributes.textStrokeWidth > 0 && textAttributes.isTextStrokeColorSet) { - val strokeWidth = textAttributes.textStrokeWidth + val strokeWidth = PixelUtil.toPixelFromDIP(textAttributes.textStrokeWidth.toDouble()).toFloat() val strokeColor = textAttributes.textStrokeColor ops.add( SetSpanOperation( @@ -470,7 +470,7 @@ internal object TextLayoutManager { fragment.props.textShadowRadius != 0f) && Color.alpha(fragment.props.textShadowColor) != 0) { spannable.setSpan( - ShadowStyleSpan( + DiscordShadowStyleSpan( fragment.props.textShadowOffsetDx, fragment.props.textShadowOffsetDy, fragment.props.textShadowRadius, @@ -483,9 +483,9 @@ internal object TextLayoutManager { if (!fragment.props.textStrokeWidth.isNaN() && fragment.props.textStrokeWidth > 0 && fragment.props.isTextStrokeColorSet) { - System.out.println("[TextLayoutManager] NEW ARCH - Adding StrokeStyleSpan: width=${fragment.props.textStrokeWidth}, color=${Integer.toHexString(fragment.props.textStrokeColor)}, start=$start, end=$end") + val strokeWidth = PixelUtil.toPixelFromDIP(fragment.props.textStrokeWidth.toDouble()).toFloat() spannable.setSpan( - StrokeStyleSpan(fragment.props.textStrokeWidth, fragment.props.textStrokeColor), + StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor), start, end, spanFlags) 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 new file mode 100644 index 00000000000000..76afe866758b15 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) Discord, 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. + */ + +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 + +/** + * 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. + */ +public class DiscordShadowStyleSpan( + private val dx: Float, + private val dy: Float, + private val radius: Float, + private val color: Int +) : ReplacementSpan(), 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) + } + } + + 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 + } + + 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) + } + } +} + 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 dc2477d49d5129..2a50bb1557e27e 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( tp.setColor(colors[0]) val radians = Math.toRadians(angle.toDouble()) - val width = 150.0f + val width = 100.0f val height = tp.textSize val centerX = start + width / 2 @@ -30,13 +30,18 @@ public class LinearGradientSpan( val endX = centerX + length * Math.cos(radians).toFloat() val endY = centerY + length * Math.sin(radians).toFloat() + // Match iOS: duplicate first color at end (RCTTextAttributes.mm:324) + val adjustedColors = IntArray(colors.size + 1) + System.arraycopy(colors, 0, adjustedColors, 0, colors.size) + adjustedColors[colors.size] = colors[0] + val textShader: Shader = LinearGradient( startX, startY, endX, endY, - colors, + adjustedColors, null, Shader.TileMode.MIRROR, )