Skip to content

Commit 9d4f0af

Browse files
committed
feat(android): honor textDecorationStyle on Text decorations
`textDecorationStyle` is declared on `TextStyleAndroid` in the public types but `TA_KEY_TEXT_DECORATION_STYLE` was a no-op handler: every value silently rendered as a solid line. This PR wires the prop through the existing C++ → Kotlin pipeline and implements `solid`, `double`, `dotted`, `dashed`, and `wavy` for both underlines and strikethroughs. Background: Android's `Layout.draw` paints the underline produced by `setUnderlineText(true)` using `paint.color`, ignoring `paint.underlineColor` on every API level, and offers no native way to draw a dotted / dashed / wavy decoration. The same applies to strikethrough. `ReactUnderlineSpan` and `ReactStrikethroughSpan` now extend `DrawCommandSpan` and paint the decoration themselves in `onDraw` via `Canvas.drawLine` / `Canvas.drawPath`, dispatching by style. This also makes `textDecorationColor` reach the paint as a side effect, closing a separate long-standing gap (see #4579 from 2015). `TextDecorationStyle::Wavy` is added to the Fabric C++ primitives / conversions so the JS value flows through instead of being rejected with an `Unsupported value` log; the same enum is shared with iOS. The wavy curve uses Chromium/Blink's formula from `decoration_line_painter.cc` (`wavelength = 1 + 2 * round(2 * thickness + 0.5)`, `controlPointDistance = 0.5 + round(3 * thickness + 0.5)`, one cubic Bezier per wavelength with both control points at the midpoint, one above and one below the y-axis). The minimum stroke thickness is density-aware (1.5 dp) so decorations read consistently across display densities. The drawing loop iterates `while x < x2` so the final cycle continues through the last character (including trailing punctuation that would otherwise be visually uncovered when the run width is not an integer multiple of the wavelength). `ReactTextView.onDraw` invokes `DrawCommandSpan.onDraw` after `super.onDraw`, mirroring what `PreparedLayoutTextView.onDraw` already did. Without this, the new spans have no effect on the older view class, which is what some Text components on the new architecture still route through. ## Changelog: [GENERAL] [ADDED] - `textDecorationStyle: 'wavy'` for `<Text>` (see corresponding iOS PR for the iOS counterpart) [ANDROID] [ADDED] - Text decorations honor `textDecorationStyle` (`solid`, `double`, `dotted`, `dashed`, `wavy`) ## Test Plan: Rendered `<Text>` components with `textDecorationLine` set to `"underline"` or `"line-through"` and `textDecorationStyle` cycling through `solid` / `double` / `dotted` / `dashed` / `wavy`. On stock 0.85.2 every value renders as a solid line and `wavy` logs an `Unsupported value` warning; with this patch each style renders with the requested stroke geometry. Verified single-line and wrapped multi-line cases on an Android API 36 emulator: each visual line within a wrapped block receives its own correctly-styled decoration that starts and ends at the line's content boundaries. ```tsx <Text style={{ color: 'black', textDecorationLine: 'underline', textDecorationStyle: 'wavy', }}> Hello </Text> ```
1 parent e2e6553 commit 9d4f0af

8 files changed

Lines changed: 284 additions & 13 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: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ 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+
103+
/**
104+
* CSS `text-decoration-style`. Defaults to `SOLID` so existing call
105+
* sites retain the prior visual behavior. Honored by
106+
* `ReactUnderlineSpan` and `ReactStrikethroughSpan`.
107+
*/
108+
internal var textDecorationStyle: TextDecorationStyle = TextDecorationStyle.SOLID
109+
private set
110+
95111
private var includeFontPadding: Boolean = true
96112

97113
public var accessibilityRole: AccessibilityRole? = null
@@ -415,9 +431,10 @@ public class TextAttributeProps private constructor() {
415431
TA_KEY_LINE_HEIGHT -> result.lineHeight = entry.doubleValue.toFloat()
416432
TA_KEY_ALIGNMENT -> {}
417433
TA_KEY_BEST_WRITING_DIRECTION -> {}
418-
TA_KEY_TEXT_DECORATION_COLOR -> {}
434+
TA_KEY_TEXT_DECORATION_COLOR -> result.textDecorationColor = entry.intValue
419435
TA_KEY_TEXT_DECORATION_LINE -> result.setTextDecorationLine(entry.stringValue)
420-
TA_KEY_TEXT_DECORATION_STYLE -> {}
436+
TA_KEY_TEXT_DECORATION_STYLE ->
437+
result.textDecorationStyle = TextDecorationStyle.fromString(entry.stringValue)
421438
TA_KEY_TEXT_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat()
422439
TA_KEY_TEXT_SHADOW_COLOR -> result.textShadowColor = entry.intValue
423440
TA_KEY_TEXT_SHADOW_OFFSET_DX -> result.textShadowOffsetDx = entry.doubleValue.toFloat()
@@ -462,6 +479,10 @@ public class TextAttributeProps private constructor() {
462479
result.setFontVariant(getArrayProp(props, ViewProps.FONT_VARIANT))
463480
result.includeFontPadding = getBooleanProp(props, ViewProps.INCLUDE_FONT_PADDING, true)
464481
result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE))
482+
result.textDecorationColor =
483+
getIntProp(props, "textDecorationColor", android.graphics.Color.TRANSPARENT)
484+
result.textDecorationStyle =
485+
TextDecorationStyle.fromString(getStringProp(props, "textDecorationStyle"))
465486
result.setTextShadowOffset(
466487
if (props.hasKey(PROP_SHADOW_OFFSET)) props.getMap(PROP_SHADOW_OFFSET) else null
467488
)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Copyright (c) Meta Platforms, 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
9+
10+
import android.graphics.Canvas
11+
import android.graphics.Color
12+
import android.graphics.DashPathEffect
13+
import android.graphics.Paint
14+
import android.graphics.Path
15+
import android.os.Build
16+
import android.text.Layout
17+
import kotlin.math.max
18+
import kotlin.math.roundToInt
19+
20+
/**
21+
* Styles supported by the CSS `text-decoration-style` property, surfaced
22+
* end-to-end by Fabric (see `TextAttributes::textDecorationStyle`).
23+
*/
24+
internal enum class TextDecorationStyle {
25+
SOLID,
26+
DOUBLE,
27+
DOTTED,
28+
DASHED,
29+
WAVY;
30+
31+
internal companion object {
32+
@JvmStatic
33+
fun fromString(value: String?): TextDecorationStyle =
34+
when (value) {
35+
"double" -> DOUBLE
36+
"dotted" -> DOTTED
37+
"dashed" -> DASHED
38+
"wavy" -> WAVY
39+
else -> SOLID
40+
}
41+
}
42+
}
43+
44+
/**
45+
* Draws a horizontal decoration line between `x1` and `x2` at `y`,
46+
* applying the requested CSS `text-decoration-style`. The caller is
47+
* expected to have already configured `paint.color`, `paint.strokeWidth`,
48+
* `paint.style = STROKE`, and `paint.isAntiAlias = true`, and to restore
49+
* those after the call returns. The `paint.pathEffect` is saved and
50+
* restored internally because dotted/dashed need to set it temporarily.
51+
*
52+
* Constants match Chromium/Blink's decoration_line_painter.cc so the
53+
* visual rendering is consistent with what users see in Chrome on
54+
* Android:
55+
* - DOUBLE: center-to-center distance is `thickness + 1`.
56+
* - WAVY: wavelength = `1 + 2 * round(2 * thickness + 0.5)`,
57+
* controlPointDistance = `0.5 + round(3 * thickness + 0.5)`.
58+
* One cubic Bezier per wavelength with both control points at the
59+
* midpoint, one above and one below the y-axis.
60+
*/
61+
internal fun drawDecorationLine(
62+
canvas: Canvas,
63+
paint: Paint,
64+
x1: Float,
65+
x2: Float,
66+
y: Float,
67+
thickness: Float,
68+
style: TextDecorationStyle,
69+
) {
70+
when (style) {
71+
TextDecorationStyle.SOLID -> canvas.drawLine(x1, y, x2, y, paint)
72+
TextDecorationStyle.DOUBLE -> {
73+
// Center-to-center distance such that the visible gap between the
74+
// top and bottom strokes (= gap - thickness) is 2 px regardless of
75+
// stroke width. Blink renders with a 1 px gap, but with
76+
// antialiasing that often reads as a single fat line; the wider
77+
// gap keeps both strokes legible.
78+
val gap = thickness + 2f
79+
canvas.drawLine(x1, y, x2, y, paint)
80+
canvas.drawLine(x1, y + gap, x2, y + gap, paint)
81+
}
82+
TextDecorationStyle.DOTTED,
83+
TextDecorationStyle.DASHED -> {
84+
val intervals =
85+
if (style == TextDecorationStyle.DOTTED) floatArrayOf(thickness, thickness * 2f)
86+
else floatArrayOf(thickness * 4f, thickness * 2f)
87+
val savedEffect = paint.pathEffect
88+
paint.pathEffect = DashPathEffect(intervals, 0f)
89+
// `Canvas.drawLine` ignores `pathEffect`; draw the line as a Path
90+
// so the dash intervals are honored.
91+
val path = Path()
92+
path.moveTo(x1, y)
93+
path.lineTo(x2, y)
94+
canvas.drawPath(path, paint)
95+
paint.pathEffect = savedEffect
96+
}
97+
TextDecorationStyle.WAVY -> {
98+
val clamped = max(1f, thickness)
99+
val wavelength = 1f + 2f * (2f * clamped + 0.5f).roundToInt()
100+
val cpDistance = 0.5f + (3f * clamped + 0.5f).roundToInt()
101+
val path = Path()
102+
path.moveTo(x1, y)
103+
var x = x1
104+
// Loop while `x < x2` (not `x + wavelength <= x2`) so the wave
105+
// continues through the final character (including trailing
106+
// punctuation). The last cycle may extend a hair past the run,
107+
// which reads as a natural underline trailer.
108+
while (x < x2) {
109+
val midX = x + wavelength / 2f
110+
val endX = x + wavelength
111+
// Two control points at the midpoint, one above (y - cp) and
112+
// one below (y + cp). Produces an oscillating S-curve per
113+
// wavelength, matching Chromium/Blink's wavy underline.
114+
path.cubicTo(midX, y + cpDistance, midX, y - cpDistance, endX, y)
115+
x = endX
116+
}
117+
canvas.drawPath(path, paint)
118+
}
119+
}
120+
}
121+
122+
/**
123+
* Shared decoration drawing entry point used by [ReactUnderlineSpan] and
124+
* [ReactStrikethroughSpan]. Computes a density-aware stroke thickness,
125+
* sets up the paint, iterates the visible lines of the run, and delegates
126+
* each line to [drawDecorationLine]. The caller-supplied [yOffsetForLine]
127+
* computes the vertical position of the decoration line on each visible
128+
* line of text (underline vs strikethrough being the only difference).
129+
*/
130+
internal inline fun drawSpannedDecoration(
131+
start: Int,
132+
end: Int,
133+
canvas: Canvas,
134+
layout: Layout,
135+
color: Int,
136+
style: TextDecorationStyle,
137+
yOffsetForLine: (paint: Paint, baseline: Float, thickness: Float) -> Float,
138+
) {
139+
val paint = layout.paint
140+
val savedColor = paint.color
141+
val savedStrokeWidth = paint.strokeWidth
142+
val savedStyle = paint.style
143+
val savedAntiAlias = paint.isAntiAlias
144+
val effectiveColor = if (color != Color.TRANSPARENT) color else savedColor
145+
// Density-aware minimum so the decoration reads consistently across
146+
// display densities (`paint.density` is the px-per-dp ratio).
147+
val minThickness = 1.5f * paint.density
148+
val thickness =
149+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
150+
max(paint.underlineThickness, minThickness)
151+
} else {
152+
max(paint.fontMetrics.descent * 0.1f, minThickness)
153+
}
154+
155+
paint.color = effectiveColor
156+
paint.strokeWidth = thickness
157+
paint.style = Paint.Style.STROKE
158+
paint.isAntiAlias = true
159+
160+
val startLine = layout.getLineForOffset(start)
161+
val endLine = layout.getLineForOffset(end)
162+
for (line in startLine..endLine) {
163+
val baseline = layout.getLineBaseline(line).toFloat()
164+
val x1 =
165+
if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line)
166+
val x2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
167+
val y = yOffsetForLine(paint, baseline, thickness)
168+
drawDecorationLine(canvas, paint, x1, x2, y, thickness, style)
169+
}
170+
171+
paint.color = savedColor
172+
paint.strokeWidth = savedStrokeWidth
173+
paint.style = savedStyle
174+
paint.isAntiAlias = savedAntiAlias
175+
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,10 @@ 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, textAttributes.textDecorationStyle)))
319319
}
320320
if (textAttributes.isLineThroughTextDecorationSet) {
321-
ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan()))
321+
ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan(textAttributes.textDecorationColor, textAttributes.textDecorationStyle)))
322322
}
323323
if (
324324
(textAttributes.textShadowOffsetDx != 0f ||
@@ -494,11 +494,11 @@ internal object TextLayoutManager {
494494
}
495495

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

500500
if (fragment.props.isLineThroughTextDecorationSet) {
501-
spannable.setSpan(ReactStrikethroughSpan(), start, end, spanFlags)
501+
spannable.setSpan(ReactStrikethroughSpan(fragment.props.textDecorationColor, fragment.props.textDecorationStyle), start, end, spanFlags)
502502
}
503503

504504
if (

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

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

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

10-
import android.text.style.StrikethroughSpan
10+
import android.graphics.Canvas
11+
import android.graphics.Color
12+
import android.text.Layout
13+
import com.facebook.react.views.text.TextDecorationStyle
14+
import com.facebook.react.views.text.drawSpannedDecoration
1115

12-
/** Wraps [StrikethroughSpan] as a [ReactSpan]. */
13-
internal class ReactStrikethroughSpan : StrikethroughSpan(), ReactSpan
16+
/**
17+
* Draws a strikethrough whose color and style may differ from the text.
18+
* The line is painted in `onDraw` after the layout renders its text. We
19+
* do NOT extend [android.text.style.StrikethroughSpan] here: the
20+
* framework's `Layout.draw` paints the strikethrough using `paint.color`
21+
* with no field to override, so painting it ourselves is the only way to
22+
* get a distinct color or non-solid style.
23+
*
24+
* `color == Color.TRANSPARENT` falls back to the text foreground color.
25+
*/
26+
internal class ReactStrikethroughSpan(
27+
private val color: Int = Color.TRANSPARENT,
28+
private val style: TextDecorationStyle = TextDecorationStyle.SOLID,
29+
) : DrawCommandSpan() {
30+
31+
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
32+
drawSpannedDecoration(start, end, canvas, layout, color, style) { paint, baseline, _ ->
33+
// Strikethrough sits near the x-height midline. `fontMetrics.ascent`
34+
// is negative and `descent` is positive, so the sum / 2 gives a
35+
// small negative offset from the baseline; the trailing `+ 1f`
36+
// nudges it down to match the visual position users expect.
37+
val fm = paint.fontMetrics
38+
baseline + (fm.ascent + fm.descent) / 2f + 1f
39+
}
40+
}
41+
}

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,31 @@
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.text.Layout
13+
import com.facebook.react.views.text.TextDecorationStyle
14+
import com.facebook.react.views.text.drawSpannedDecoration
1115

12-
/** Wraps [UnderlineSpan] as a [ReactSpan]. */
13-
internal class ReactUnderlineSpan : UnderlineSpan(), ReactSpan
16+
/**
17+
* Draws an underline whose color and style may differ from the text. The
18+
* underline is painted in `onDraw` (after the layout renders its text) so
19+
* it lands on top of any descenders. We do NOT extend
20+
* [android.text.style.UnderlineSpan] here: the framework's `Layout.draw`
21+
* reads `paint.color` for underline color regardless of
22+
* `paint.underlineColor`, so painting it ourselves is the only way to get
23+
* a distinct color or non-solid style.
24+
*
25+
* `color == Color.TRANSPARENT` falls back to the text foreground color.
26+
*/
27+
internal class ReactUnderlineSpan(
28+
private val color: Int = Color.TRANSPARENT,
29+
private val style: TextDecorationStyle = TextDecorationStyle.SOLID,
30+
) : DrawCommandSpan() {
31+
32+
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
33+
drawSpannedDecoration(start, end, canvas, layout, color, style) { _, baseline, thickness ->
34+
baseline + thickness + 1f
35+
}
36+
}
37+
}

packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,8 @@ inline void fromRawValue(const PropsParserContext &context, const RawValue &valu
916916
result = TextDecorationStyle::Dotted;
917917
} else if (string == "dashed") {
918918
result = TextDecorationStyle::Dashed;
919+
} else if (string == "wavy") {
920+
result = TextDecorationStyle::Wavy;
919921
} else {
920922
LOG(ERROR) << "Unsupported TextDecorationStyle value: " << string;
921923
react_native_expect(false);
@@ -941,6 +943,8 @@ inline std::string toString(const TextDecorationStyle &textDecorationStyle)
941943
return "dotted";
942944
case TextDecorationStyle::Dashed:
943945
return "dashed";
946+
case TextDecorationStyle::Wavy:
947+
return "wavy";
944948
}
945949

946950
LOG(ERROR) << "Unsupported TextDecorationStyle value";

packages/react-native/ReactCommon/react/renderer/attributedstring/primitives.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ enum class LineBreakMode {
134134

135135
enum class TextDecorationLineType { None, Underline, Strikethrough, UnderlineStrikethrough };
136136

137-
enum class TextDecorationStyle { Solid, Double, Dotted, Dashed };
137+
enum class TextDecorationStyle { Solid, Double, Dotted, Dashed, Wavy };
138138

139139
enum class TextTransform {
140140
None,

0 commit comments

Comments
 (0)