Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -234,7 +234,7 @@ private static void buildSpannedFromShadowNode(
new SetSpanOperation(
start,
end,
new ShadowStyleSpan(
new DiscordShadowStyleSpan(
textShadowNode.mTextShadowOffsetDx,
textShadowNode.mTextShadowOffsetDy,
textShadowNode.mTextShadowRadius,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -306,7 +306,7 @@ internal object TextLayoutManager {
SetSpanOperation(
start,
end,
ShadowStyleSpan(
DiscordShadowStyleSpan(
textAttributes.mTextShadowOffsetDx,
textAttributes.mTextShadowOffsetDy,
textAttributes.mTextShadowRadius,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down
Loading