Skip to content

Commit 8f42f81

Browse files
authored
Fix text shadow cutoff on android (#126)
* Fix the shadow cut off on android and update gradient Attemp to fix text shadow cutoff Update the text shadow on android to prevent cutoff Expand the shadow on if padding is provided Add the missing import Add some logging Make shadow span use padding Attempt to fix text shadow cutoff on android Another attempt at fixing the text shadow cutoff Another attempt at fixing the text shadow cutoff issue on Android. Add more logging Add more logs Add more and more logs Implement shadow offset compensation in ReactTextView Make sure to keep the text position stable when the shadow is added. Update linear gradient span to match iOS behavior. Address gradient issues Another attempt to fix the text stroke width Revert some of the gradient changes Update shader mode Update the implementation once more Revert to width expansion approach - shadow cutoff confirmed without it Add more logging to the ReactTextView Remove debug logging Remove remaining debug logs from text rendering files Remove unused padding parameters from ShadowStyleSpan - Remove padding constructor parameters that were never used in getSize() or draw() - Remove updatePadding() method that was never called - Remove padding retrieval code from ReactBaseTextShadowNode - Both old and new architectures now consistently use 4-parameter constructor Align new arch (Fabric) shadow handling with old arch (Paper) - Remove shadowTopOffset from PreparedLayoutTextView - Vertical shadow space is already handled via font metrics adjustment - Both architectures now only compensate horizontally - Matches old arch behavior and comment: 'vertical doesn't need compensation' Remove unused getShadowDy() method and min import - getShadowDy() was only used for vertical compensation which we removed - kotlin.math.min import is not used anywhere in the file * Remove no longer needed changes * Remove the unnecessary clipping changes * Decouple discord shadow style span from react native * Use the custom discord shadow style span in the text layout manager * Remove unnecessary import * Fixes the api visibility * Add more public visibility to the discord shadow style span * Remove unused getShadowDy() and getShadowColor() from DiscordShadowStyleSpan These getters are not used anywhere in the codebase. Only getShadowRadius() and getShadowDx() are used by getShadowAdjustment(). * Fix truncation issues with text shadows
1 parent bf8c918 commit 8f42f81

File tree

6 files changed

+194
-14
lines changed

6 files changed

+194
-14
lines changed

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.facebook.proguard.annotations.DoNotStrip
2727
import com.facebook.react.uimanager.BackgroundStyleApplicator
2828
import com.facebook.react.uimanager.ReactCompoundView
2929
import com.facebook.react.uimanager.style.Overflow
30+
import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan
3031
import com.facebook.react.views.text.internal.span.ReactTagSpan
3132
import kotlin.collections.ArrayList
3233
import kotlin.math.roundToInt
@@ -99,15 +100,22 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
99100
}
100101

101102
override fun onDraw(canvas: Canvas) {
102-
if (overflow != Overflow.VISIBLE) {
103+
val layout = preparedLayout?.layout
104+
105+
// Get shadow adjustment from custom span if configured
106+
val spanned = layout?.text as? Spanned
107+
val shadowAdj = DiscordShadowStyleSpan.getShadowAdjustment(spanned)
108+
109+
if (overflow != Overflow.VISIBLE && !shadowAdj.hasShadow) {
103110
BackgroundStyleApplicator.clipToPaddingBox(this, canvas)
104111
}
105112

106113
super.onDraw(canvas)
114+
107115
canvas.translate(
108-
paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f))
116+
paddingLeft.toFloat() + shadowAdj.leftOffset,
117+
paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f))
109118

110-
val layout = preparedLayout?.layout
111119
if (layout != null) {
112120
if (selection != null) {
113121
selectionPaint.setColor(

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
import com.facebook.react.views.text.internal.span.ReactTagSpan;
4949
import com.facebook.react.views.text.internal.span.ReactUnderlineSpan;
5050
import com.facebook.react.views.text.internal.span.SetSpanOperation;
51-
import com.facebook.react.views.text.internal.span.ShadowStyleSpan;
51+
import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan;
5252
import com.facebook.react.views.text.internal.span.StrokeStyleSpan;
5353
import com.facebook.react.views.text.internal.span.TextInlineImageSpan;
5454
import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan;
@@ -234,7 +234,7 @@ private static void buildSpannedFromShadowNode(
234234
new SetSpanOperation(
235235
start,
236236
end,
237-
new ShadowStyleSpan(
237+
new DiscordShadowStyleSpan(
238238
textShadowNode.mTextShadowOffsetDx,
239239
textShadowNode.mTextShadowOffsetDy,
240240
textShadowNode.mTextShadowRadius,

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import com.facebook.react.uimanager.style.BorderStyle;
5353
import com.facebook.react.uimanager.style.LogicalEdge;
5454
import com.facebook.react.uimanager.style.Overflow;
55+
import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan;
5556
import com.facebook.react.views.text.internal.span.ReactTagSpan;
5657
import com.facebook.react.views.text.internal.span.TextInlineImageSpan;
5758
import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan;
@@ -360,11 +361,19 @@ protected void onDraw(Canvas canvas) {
360361
setText(spanned);
361362
}
362363

363-
if (mOverflow != Overflow.VISIBLE) {
364+
// Get shadow adjustment from custom span if configured
365+
DiscordShadowStyleSpan.ShadowAdjustment shadowAdj =
366+
DiscordShadowStyleSpan.getShadowAdjustment(spanned);
367+
368+
canvas.save();
369+
canvas.translate(shadowAdj.getLeftOffset(), 0);
370+
371+
if (mOverflow != Overflow.VISIBLE && !shadowAdj.getHasShadow()) {
364372
BackgroundStyleApplicator.clipToPaddingBox(this, canvas);
365373
}
366374

367375
super.onDraw(canvas);
376+
canvas.restore();
368377
}
369378
}
370379

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ import com.facebook.react.views.text.internal.span.ReactOpacitySpan
4747
import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan
4848
import com.facebook.react.views.text.internal.span.ReactTagSpan
4949
import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan
50+
import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan
5051
import com.facebook.react.views.text.internal.span.ReactUnderlineSpan
5152
import com.facebook.react.views.text.internal.span.SetSpanOperation
52-
import com.facebook.react.views.text.internal.span.ShadowStyleSpan
5353
import com.facebook.react.views.text.internal.span.StrokeStyleSpan
5454
import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan
5555
import com.facebook.yoga.YogaMeasureMode
@@ -306,7 +306,7 @@ internal object TextLayoutManager {
306306
SetSpanOperation(
307307
start,
308308
end,
309-
ShadowStyleSpan(
309+
DiscordShadowStyleSpan(
310310
textAttributes.mTextShadowOffsetDx,
311311
textAttributes.mTextShadowOffsetDy,
312312
textAttributes.mTextShadowRadius,
@@ -315,7 +315,7 @@ internal object TextLayoutManager {
315315
if (!textAttributes.textStrokeWidth.isNaN() &&
316316
textAttributes.textStrokeWidth > 0 &&
317317
textAttributes.isTextStrokeColorSet) {
318-
val strokeWidth = textAttributes.textStrokeWidth
318+
val strokeWidth = PixelUtil.toPixelFromDIP(textAttributes.textStrokeWidth.toDouble()).toFloat()
319319
val strokeColor = textAttributes.textStrokeColor
320320
ops.add(
321321
SetSpanOperation(
@@ -470,7 +470,7 @@ internal object TextLayoutManager {
470470
fragment.props.textShadowRadius != 0f) &&
471471
Color.alpha(fragment.props.textShadowColor) != 0) {
472472
spannable.setSpan(
473-
ShadowStyleSpan(
473+
DiscordShadowStyleSpan(
474474
fragment.props.textShadowOffsetDx,
475475
fragment.props.textShadowOffsetDy,
476476
fragment.props.textShadowRadius,
@@ -483,9 +483,9 @@ internal object TextLayoutManager {
483483
if (!fragment.props.textStrokeWidth.isNaN() &&
484484
fragment.props.textStrokeWidth > 0 &&
485485
fragment.props.isTextStrokeColorSet) {
486-
System.out.println("[TextLayoutManager] NEW ARCH - Adding StrokeStyleSpan: width=${fragment.props.textStrokeWidth}, color=${Integer.toHexString(fragment.props.textStrokeColor)}, start=$start, end=$end")
486+
val strokeWidth = PixelUtil.toPixelFromDIP(fragment.props.textStrokeWidth.toDouble()).toFloat()
487487
spannable.setSpan(
488-
StrokeStyleSpan(fragment.props.textStrokeWidth, fragment.props.textStrokeColor),
488+
StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor),
489489
start,
490490
end,
491491
spanFlags)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright (c) Discord, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text.internal.span
9+
10+
import android.graphics.Canvas
11+
import android.graphics.Paint
12+
import android.text.Spanned
13+
import android.text.style.ReplacementSpan
14+
import kotlin.math.max
15+
16+
/**
17+
* A span that applies text shadow with proper bounds calculation.
18+
* Extends ReplacementSpan to control measurement and drawing, ensuring shadows render correctly.
19+
* This is Discord's custom implementation that contains all shadow logic.
20+
*/
21+
public class DiscordShadowStyleSpan(
22+
private val dx: Float,
23+
private val dy: Float,
24+
private val radius: Float,
25+
private val color: Int
26+
) : ReplacementSpan(), ReactSpan {
27+
28+
// Getters for shadow properties (used by getShadowAdjustment)
29+
public fun getShadowRadius(): Float = radius
30+
public fun getShadowDx(): Float = dx
31+
32+
override fun getSize(
33+
paint: Paint,
34+
text: CharSequence?,
35+
start: Int,
36+
end: Int,
37+
fm: Paint.FontMetricsInt?
38+
): Int {
39+
val width = paint.measureText(text, start, end)
40+
41+
if (fm != null) {
42+
paint.getFontMetricsInt(fm)
43+
44+
val shadowTopNeeded = max(0f, radius - dy)
45+
val shadowBottomNeeded = max(0f, radius + dy)
46+
47+
val topExpansion = shadowTopNeeded.toInt()
48+
val bottomExpansion = shadowBottomNeeded.toInt()
49+
50+
// Adjust font metrics to account for shadow
51+
fm.top -= topExpansion
52+
fm.ascent -= topExpansion
53+
fm.descent += bottomExpansion
54+
fm.bottom += bottomExpansion
55+
}
56+
57+
val shadowLeftNeeded = max(0f, radius - dx)
58+
val shadowRightNeeded = max(0f, radius + dx)
59+
60+
// Subtract 1 pixel to prevent TextView ellipsization while keeping shadow mostly intact
61+
return (width + shadowLeftNeeded + shadowRightNeeded).toInt() - 1
62+
}
63+
64+
override fun draw(
65+
canvas: Canvas,
66+
text: CharSequence?,
67+
start: Int,
68+
end: Int,
69+
x: Float,
70+
top: Int,
71+
y: Int,
72+
bottom: Int,
73+
paint: Paint
74+
) {
75+
if (text == null) return
76+
77+
val textToDraw = text.subSequence(start, end).toString()
78+
79+
// Offset text to keep shadow in positive coordinates
80+
val shadowLeftNeeded = max(0f, radius - dx)
81+
82+
// Store original shadow settings
83+
val originalShadowRadius = paint.shadowLayerRadius
84+
val originalShadowDx = paint.shadowLayerDx
85+
val originalShadowDy = paint.shadowLayerDy
86+
val originalShadowColor = paint.shadowLayerColor
87+
88+
paint.setShadowLayer(radius, dx, dy, color)
89+
90+
if (text is Spanned && paint is android.text.TextPaint) {
91+
val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java)
92+
for (span in spans) {
93+
if (span !is DiscordShadowStyleSpan) {
94+
span.updateDrawState(paint)
95+
}
96+
}
97+
}
98+
99+
// Offset text by shadowLeftNeeded to keep shadow in positive coordinates
100+
// The view will compensate with canvas translation
101+
canvas.drawText(textToDraw, x + shadowLeftNeeded, y.toFloat(), paint)
102+
103+
// Restore original shadow settings
104+
if (originalShadowRadius > 0f) {
105+
paint.setShadowLayer(
106+
originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor)
107+
} else {
108+
paint.clearShadowLayer()
109+
}
110+
}
111+
112+
/**
113+
* Result class for shadow adjustment calculation.
114+
* Contains the horizontal offset needed to compensate for shadow positioning
115+
* and whether a shadow is present.
116+
*/
117+
public data class ShadowAdjustment(
118+
val leftOffset: Float,
119+
val hasShadow: Boolean
120+
) {
121+
public companion object {
122+
@JvmStatic
123+
public val NONE: ShadowAdjustment = ShadowAdjustment(0f, false)
124+
}
125+
}
126+
127+
public companion object {
128+
/**
129+
* Helper method for ReactTextView and PreparedLayoutTextView to get shadow adjustment values.
130+
* Calculates the horizontal offset needed to compensate for shadow positioning
131+
* when the span offsets text to keep shadows in positive coordinates.
132+
*
133+
* @param spanned The text to check for shadow spans, or null if no text
134+
* @return ShadowAdjustment with negative leftOffset (ready to use in canvas.translate)
135+
*/
136+
@JvmStatic
137+
public fun getShadowAdjustment(spanned: Spanned?): ShadowAdjustment {
138+
if (spanned == null) {
139+
return ShadowAdjustment.NONE
140+
}
141+
142+
val spans = spanned.getSpans(0, spanned.length, DiscordShadowStyleSpan::class.java)
143+
if (spans.isEmpty()) {
144+
return ShadowAdjustment.NONE
145+
}
146+
147+
// Use the first shadow span to calculate offset
148+
val span = spans[0]
149+
val radius = span.getShadowRadius()
150+
val dx = span.getShadowDx()
151+
// Return negative offset so views can use it directly in canvas.translate
152+
val shadowLeftOffset = -max(0f, radius - dx)
153+
154+
return ShadowAdjustment(shadowLeftOffset, true)
155+
}
156+
}
157+
}
158+

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/LinearGradientSpan.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public class LinearGradientSpan(
1818
tp.setColor(colors[0])
1919

2020
val radians = Math.toRadians(angle.toDouble())
21-
val width = 150.0f
21+
val width = 100.0f
2222
val height = tp.textSize
2323

2424
val centerX = start + width / 2
@@ -30,13 +30,18 @@ public class LinearGradientSpan(
3030
val endX = centerX + length * Math.cos(radians).toFloat()
3131
val endY = centerY + length * Math.sin(radians).toFloat()
3232

33+
// Match iOS: duplicate first color at end (RCTTextAttributes.mm:324)
34+
val adjustedColors = IntArray(colors.size + 1)
35+
System.arraycopy(colors, 0, adjustedColors, 0, colors.size)
36+
adjustedColors[colors.size] = colors[0]
37+
3338
val textShader: Shader =
3439
LinearGradient(
3540
startX,
3641
startY,
3742
endX,
3843
endY,
39-
colors,
44+
adjustedColors,
4045
null,
4146
Shader.TileMode.MIRROR,
4247
)

0 commit comments

Comments
 (0)