Skip to content

Commit d8540af

Browse files
committed
fix(android): honor textDecorationColor on Text underlines
Android's `Layout.draw` paints the underline produced by `setUnderlineText(true)` using `paint.color`, ignoring `paint.underlineColor` on all API levels. This caused `textDecorationColor` to be silently dropped on Android. Refactor `ReactUnderlineSpan` to extend `DrawCommandSpan` and paint the underline itself in `onDraw`, falling back to the text color when no color was specified. Thread the color through `TextAttributeProps` (both MapBuffer and ReadableMap ingestion paths) and `TextLayoutManager`. Add `DrawCommandSpan` invocation to `ReactTextView.onDraw`, mirroring the existing `PreparedLayoutTextView` behavior so both text view classes honor custom-drawing spans. ## Changelog [ANDROID] [FIXED] - Text underlines honor `textDecorationColor` ## Test Plan Render a Text component with `textDecorationColor` set to a value distinct from the text color; the underline now renders in the specified color rather than the text color. Verified on Android API 36 emulator.
1 parent e2e6553 commit d8540af

4 files changed

Lines changed: 90 additions & 6 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import com.facebook.react.uimanager.style.BorderStyle;
4747
import com.facebook.react.uimanager.style.LogicalEdge;
4848
import com.facebook.react.uimanager.style.Overflow;
49+
import com.facebook.react.views.text.internal.span.DrawCommandSpan;
4950
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan;
5051
import com.facebook.react.views.text.internal.span.ReactTagSpan;
5152
import com.facebook.yoga.YogaMeasureMode;
@@ -213,6 +214,24 @@ protected void onDraw(Canvas canvas) {
213214
}
214215

215216
super.onDraw(canvas);
217+
218+
if (spanned != null) {
219+
Layout layout = getLayout();
220+
if (layout != null) {
221+
DrawCommandSpan[] drawSpans =
222+
spanned.getSpans(0, spanned.length(), DrawCommandSpan.class);
223+
if (drawSpans.length > 0) {
224+
canvas.save();
225+
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
226+
for (DrawCommandSpan span : drawSpans) {
227+
int start = spanned.getSpanStart(span);
228+
int end = spanned.getSpanEnd(span);
229+
span.onDraw(start, end, canvas, layout);
230+
}
231+
canvas.restore();
232+
}
233+
}
234+
}
216235
}
217236
}
218237

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ public class TextAttributeProps private constructor() {
9292
public var isLineThroughTextDecorationSet: Boolean = false
9393
private set
9494

95+
/**
96+
* Underline color. `Color.TRANSPARENT` (the default) means "fall back to
97+
* the text color" so existing call sites that don't pass a value retain
98+
* the prior behavior. Honored by `ReactUnderlineSpan` on API 29+.
99+
*/
100+
public var textDecorationColor: Int = android.graphics.Color.TRANSPARENT
101+
private set
102+
95103
private var includeFontPadding: Boolean = true
96104

97105
public var accessibilityRole: AccessibilityRole? = null
@@ -415,7 +423,7 @@ public class TextAttributeProps private constructor() {
415423
TA_KEY_LINE_HEIGHT -> result.lineHeight = entry.doubleValue.toFloat()
416424
TA_KEY_ALIGNMENT -> {}
417425
TA_KEY_BEST_WRITING_DIRECTION -> {}
418-
TA_KEY_TEXT_DECORATION_COLOR -> {}
426+
TA_KEY_TEXT_DECORATION_COLOR -> result.textDecorationColor = entry.intValue
419427
TA_KEY_TEXT_DECORATION_LINE -> result.setTextDecorationLine(entry.stringValue)
420428
TA_KEY_TEXT_DECORATION_STYLE -> {}
421429
TA_KEY_TEXT_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat()
@@ -462,6 +470,8 @@ public class TextAttributeProps private constructor() {
462470
result.setFontVariant(getArrayProp(props, ViewProps.FONT_VARIANT))
463471
result.includeFontPadding = getBooleanProp(props, ViewProps.INCLUDE_FONT_PADDING, true)
464472
result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE))
473+
result.textDecorationColor =
474+
getIntProp(props, "textDecorationColor", android.graphics.Color.TRANSPARENT)
465475
result.setTextShadowOffset(
466476
if (props.hasKey(PROP_SHADOW_OFFSET)) props.getMap(PROP_SHADOW_OFFSET) else null
467477
)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ internal object TextLayoutManager {
315315
)
316316
}
317317
if (textAttributes.isUnderlineTextDecorationSet) {
318-
ops.add(SetSpanOperation(start, end, ReactUnderlineSpan()))
318+
ops.add(SetSpanOperation(start, end, ReactUnderlineSpan(textAttributes.textDecorationColor)))
319319
}
320320
if (textAttributes.isLineThroughTextDecorationSet) {
321321
ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan()))
@@ -494,7 +494,7 @@ internal object TextLayoutManager {
494494
}
495495

496496
if (fragment.props.isUnderlineTextDecorationSet) {
497-
spannable.setSpan(ReactUnderlineSpan(), start, end, spanFlags)
497+
spannable.setSpan(ReactUnderlineSpan(fragment.props.textDecorationColor), start, end, spanFlags)
498498
}
499499

500500
if (fragment.props.isLineThroughTextDecorationSet) {

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,62 @@
77

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

10-
import android.text.style.UnderlineSpan
10+
import android.graphics.Canvas
11+
import android.graphics.Color
12+
import android.os.Build
13+
import android.text.Layout
14+
import kotlin.math.max
1115

12-
/** Wraps [UnderlineSpan] as a [ReactSpan]. */
13-
internal class ReactUnderlineSpan : UnderlineSpan(), ReactSpan
16+
/**
17+
* Draws an underline whose color may differ from the text color. Subclasses
18+
* [DrawCommandSpan] so [PreparedLayoutTextView] invokes [onDraw] after the
19+
* layout renders its text, ensuring the underline paints on top of any
20+
* descenders. We do NOT extend [android.text.style.UnderlineSpan] here:
21+
* the framework's `Layout.draw` reads `paint.color` for underline color
22+
* regardless of `paint.underlineColor`, so the only way to get a distinct
23+
* underline color is to draw it ourselves.
24+
*
25+
* When [color] is [Color.TRANSPARENT] (the default when no
26+
* `textDecorationColor` prop was passed), the underline is drawn in the
27+
* text's foreground color, matching the platform's prior behavior.
28+
*/
29+
internal class ReactUnderlineSpan(private val color: Int = Color.TRANSPARENT) :
30+
DrawCommandSpan() {
31+
32+
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
33+
val paint = layout.paint
34+
val savedColor = paint.color
35+
val savedStrokeWidth = paint.strokeWidth
36+
val savedStyle = paint.style
37+
val savedAntiAlias = paint.isAntiAlias
38+
val effectiveColor = if (color != Color.TRANSPARENT) color else savedColor
39+
val thickness =
40+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
41+
max(paint.underlineThickness, 1.5f)
42+
} else {
43+
max(paint.fontMetrics.descent * 0.1f, 1.5f)
44+
}
45+
46+
paint.color = effectiveColor
47+
paint.strokeWidth = thickness
48+
paint.style = android.graphics.Paint.Style.STROKE
49+
paint.isAntiAlias = true
50+
51+
val startLine = layout.getLineForOffset(start)
52+
val endLine = layout.getLineForOffset(end)
53+
for (line in startLine..endLine) {
54+
val baseline = layout.getLineBaseline(line).toFloat()
55+
val x1 =
56+
if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line)
57+
val x2 =
58+
if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
59+
val y = baseline + thickness + 1f
60+
canvas.drawLine(x1, y, x2, y, paint)
61+
}
62+
63+
paint.color = savedColor
64+
paint.strokeWidth = savedStrokeWidth
65+
paint.style = savedStyle
66+
paint.isAntiAlias = savedAntiAlias
67+
}
68+
}

0 commit comments

Comments
 (0)