Skip to content

Commit 3199fce

Browse files
committed
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
1 parent bf8c918 commit 3199fce

File tree

6 files changed

+176
-15
lines changed

6 files changed

+176
-15
lines changed

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
8484
initView()
8585
// ViewGroup by default says only its children will draw
8686
setWillNotDraw(false)
87+
// Allow drawing outside bounds for shadows
88+
setClipChildren(false)
89+
setClipToPadding(false)
8790
}
8891

8992
private fun initView() {
@@ -98,16 +101,41 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
98101
overflow = Overflow.HIDDEN
99102
}
100103

104+
override fun dispatchDraw(canvas: Canvas) {
105+
super.dispatchDraw(canvas)
106+
}
107+
101108
override fun onDraw(canvas: Canvas) {
102-
if (overflow != Overflow.VISIBLE) {
109+
val layout = preparedLayout?.layout
110+
val hasShadow = layout?.text?.let { hasTextShadow(it) } ?: false
111+
112+
// Skip clipping if overflow is visible OR if text has shadows (to prevent shadow cutoff)
113+
if (overflow != Overflow.VISIBLE && !hasShadow) {
103114
BackgroundStyleApplicator.clipToPaddingBox(this, canvas)
104115
}
105116

106117
super.onDraw(canvas)
118+
119+
// Calculate shadow offset to compensate for span's horizontal text positioning
120+
// Vertical doesn't need compensation - font metrics already account for vertical shadow space
121+
var shadowLeftOffset = 0f
122+
if (hasShadow && layout != null) {
123+
val shadowSpans = (layout.text as? android.text.Spanned)?.getSpans(
124+
0, layout.text.length, com.facebook.react.views.text.internal.span.ShadowStyleSpan::class.java)
125+
if (shadowSpans != null && shadowSpans.isNotEmpty()) {
126+
// Use the first shadow span to calculate offset
127+
// This keeps text position consistent when shadow is added
128+
val span = shadowSpans[0]
129+
val radius = span.getShadowRadius()
130+
val dx = span.getShadowDx()
131+
shadowLeftOffset = kotlin.math.max(0f, radius - dx)
132+
}
133+
}
134+
107135
canvas.translate(
108-
paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f))
136+
paddingLeft.toFloat() - shadowLeftOffset,
137+
paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f))
109138

110-
val layout = preparedLayout?.layout
111139
if (layout != null) {
112140
if (selection != null) {
113141
selectionPaint.setColor(
@@ -122,6 +150,16 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
122150
}
123151
}
124152

153+
private fun hasTextShadow(text: CharSequence): Boolean {
154+
if (text !is android.text.Spanned) {
155+
return false
156+
}
157+
val spans =
158+
text.getSpans(
159+
0, text.length, com.facebook.react.views.text.internal.span.ShadowStyleSpan::class.java)
160+
return spans.isNotEmpty()
161+
}
162+
125163
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
126164
// No-op
127165
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import com.facebook.react.uimanager.IllegalViewOperationException;
2929
import com.facebook.react.uimanager.LayoutShadowNode;
3030
import com.facebook.react.uimanager.NativeViewHierarchyOptimizer;
31+
import com.facebook.react.uimanager.Spacing;
3132
import com.facebook.react.uimanager.PixelUtil;
3233
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
3334
import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role;

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,11 +360,37 @@ protected void onDraw(Canvas canvas) {
360360
setText(spanned);
361361
}
362362

363-
if (mOverflow != Overflow.VISIBLE) {
363+
// Calculate shadow offset compensation to keep text position stable
364+
// Only compensate horizontally - the span offsets X but not Y in draw()
365+
float shadowLeftOffset = 0f;
366+
boolean hasShadow = false;
367+
368+
if (spanned != null) {
369+
com.facebook.react.views.text.internal.span.ShadowStyleSpan[] shadowSpans =
370+
spanned.getSpans(0, spanned.length(), com.facebook.react.views.text.internal.span.ShadowStyleSpan.class);
371+
372+
if (shadowSpans != null && shadowSpans.length > 0) {
373+
hasShadow = true;
374+
com.facebook.react.views.text.internal.span.ShadowStyleSpan span = shadowSpans[0];
375+
float radius = span.getShadowRadius();
376+
float dx = span.getShadowDx();
377+
shadowLeftOffset = Math.max(0f, radius - dx);
378+
}
379+
}
380+
381+
// Adjust canvas translation to compensate for shadow span's horizontal offset
382+
// This keeps text at the same absolute position when shadow is added
383+
// Only compensate horizontally - vertical doesn't need compensation
384+
canvas.save();
385+
canvas.translate(-shadowLeftOffset, 0);
386+
387+
// Skip clipping when shadows are present to prevent text truncation
388+
if (mOverflow != Overflow.VISIBLE && !hasShadow) {
364389
BackgroundStyleApplicator.clipToPaddingBox(this, canvas);
365390
}
366391

367392
super.onDraw(canvas);
393+
canvas.restore();
368394
}
369395
}
370396

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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(
@@ -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)

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
)

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

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,107 @@
77

88
package com.facebook.react.views.text.internal.span
99

10-
import android.text.TextPaint
11-
import android.text.style.CharacterStyle
10+
import android.graphics.Canvas
11+
import android.graphics.Paint
12+
import android.text.style.ReplacementSpan
13+
import kotlin.math.max
1214

15+
/**
16+
* A span that applies text shadow with proper bounds calculation.
17+
* Extends ReplacementSpan to control measurement and drawing, ensuring shadows render correctly.
18+
*/
1319
internal class ShadowStyleSpan(
1420
private val dx: Float,
1521
private val dy: Float,
1622
private val radius: Float,
17-
val color: Int
18-
) : CharacterStyle(), ReactSpan {
19-
override fun updateDrawState(textPaint: TextPaint) {
20-
textPaint.setShadowLayer(radius, dx, dy, color)
23+
private val color: Int
24+
) : ReplacementSpan(), ReactSpan {
25+
26+
// Getters for shadow properties (used by PreparedLayoutTextView and ReactTextView)
27+
fun getShadowRadius(): Float = radius
28+
fun getShadowDx(): Float = dx
29+
30+
override fun getSize(
31+
paint: Paint,
32+
text: CharSequence?,
33+
start: Int,
34+
end: Int,
35+
fm: Paint.FontMetricsInt?
36+
): Int {
37+
val width = paint.measureText(text, start, end)
38+
39+
if (fm != null) {
40+
paint.getFontMetricsInt(fm)
41+
42+
// Calculate shadow bounds needed and always expand
43+
val shadowTopNeeded = max(0f, radius - dy)
44+
val shadowBottomNeeded = max(0f, radius + dy)
45+
46+
// Always expand for shadow - view padding will naturally prevent clipping
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+
// Calculate horizontal shadow expansion - always expand for shadow
58+
val shadowLeftNeeded = max(0f, radius - dx)
59+
val shadowRightNeeded = max(0f, radius + dx)
60+
61+
return (width + shadowLeftNeeded + shadowRightNeeded).toInt()
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+
// Apply shadow
89+
paint.setShadowLayer(radius, dx, dy, color)
90+
91+
// Apply other character styles from the spanned text
92+
if (text is android.text.Spanned && paint is android.text.TextPaint) {
93+
val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java)
94+
for (span in spans) {
95+
if (span !is ShadowStyleSpan) {
96+
span.updateDrawState(paint)
97+
}
98+
}
99+
}
100+
101+
// Offset text by shadowLeftNeeded to keep shadow in positive coordinates
102+
// PreparedLayoutTextView will compensate with canvas translation
103+
canvas.drawText(textToDraw, x + shadowLeftNeeded, y.toFloat(), paint)
104+
105+
// Restore original shadow settings
106+
if (originalShadowRadius > 0f) {
107+
paint.setShadowLayer(
108+
originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor)
109+
} else {
110+
paint.clearShadowLayer()
111+
}
21112
}
22113
}

0 commit comments

Comments
 (0)