Skip to content

Commit 1a8cbda

Browse files
committed
feat(android): honor textDecorationStyle on Text decorations
1 parent 9c849cd commit 1a8cbda

11 files changed

Lines changed: 339 additions & 53 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ public class TextAttributeProps private constructor() {
100100
public var textDecorationColor: Int = android.graphics.Color.TRANSPARENT
101101
private set
102102

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+
103111
private var includeFontPadding: Boolean = true
104112

105113
public var accessibilityRole: AccessibilityRole? = null
@@ -425,7 +433,8 @@ public class TextAttributeProps private constructor() {
425433
TA_KEY_BEST_WRITING_DIRECTION -> {}
426434
TA_KEY_TEXT_DECORATION_COLOR -> result.textDecorationColor = entry.intValue
427435
TA_KEY_TEXT_DECORATION_LINE -> result.setTextDecorationLine(entry.stringValue)
428-
TA_KEY_TEXT_DECORATION_STYLE -> {}
436+
TA_KEY_TEXT_DECORATION_STYLE ->
437+
result.textDecorationStyle = TextDecorationStyle.fromString(entry.stringValue)
429438
TA_KEY_TEXT_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat()
430439
TA_KEY_TEXT_SHADOW_COLOR -> result.textShadowColor = entry.intValue
431440
TA_KEY_TEXT_SHADOW_OFFSET_DX -> result.textShadowOffsetDx = entry.doubleValue.toFloat()
@@ -472,6 +481,8 @@ public class TextAttributeProps private constructor() {
472481
result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE))
473482
result.textDecorationColor =
474483
getIntProp(props, "textDecorationColor", android.graphics.Color.TRANSPARENT)
484+
result.textDecorationStyle =
485+
TextDecorationStyle.fromString(getStringProp(props, "textDecorationStyle"))
475486
result.setTextShadowOffset(
476487
if (props.hasKey(PROP_SHADOW_OFFSET)) props.getMap(PROP_SHADOW_OFFSET) else null
477488
)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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.DashPathEffect
12+
import android.graphics.Paint
13+
import android.graphics.Path
14+
import kotlin.math.max
15+
import kotlin.math.roundToInt
16+
17+
/**
18+
* Styles supported by the CSS `text-decoration-style` property, surfaced
19+
* end-to-end by Fabric (see `TextAttributes::textDecorationStyle`).
20+
*/
21+
internal enum class TextDecorationStyle {
22+
SOLID,
23+
DOUBLE,
24+
DOTTED,
25+
DASHED,
26+
WAVY;
27+
28+
internal companion object {
29+
@JvmStatic
30+
fun fromString(value: String?): TextDecorationStyle =
31+
when (value) {
32+
"double" -> DOUBLE
33+
"dotted" -> DOTTED
34+
"dashed" -> DASHED
35+
"wavy" -> WAVY
36+
else -> SOLID
37+
}
38+
}
39+
}
40+
41+
/**
42+
* Draws a horizontal decoration line between `x1` and `x2` at `y`,
43+
* applying the requested CSS `text-decoration-style`. The caller is
44+
* expected to have already configured `paint.color`, `paint.strokeWidth`,
45+
* `paint.style = STROKE`, and `paint.isAntiAlias = true`, and to restore
46+
* those after the call returns. The `paint.pathEffect` is saved and
47+
* restored internally because dotted/dashed need to set it temporarily.
48+
*
49+
* Constants match Chromium/Blink's decoration_line_painter.cc so the
50+
* visual rendering is consistent with what users see in Chrome on
51+
* Android:
52+
* - DOUBLE: center-to-center distance is `thickness + 1`.
53+
* - WAVY: wavelength = `1 + 2 * round(2 * thickness + 0.5)`,
54+
* controlPointDistance = `0.5 + round(3 * thickness + 0.5)`.
55+
* One cubic Bezier per wavelength with both control points at the
56+
* midpoint, one above and one below the y-axis.
57+
*/
58+
internal fun drawDecorationLine(
59+
canvas: Canvas,
60+
paint: Paint,
61+
x1: Float,
62+
x2: Float,
63+
y: Float,
64+
thickness: Float,
65+
style: TextDecorationStyle,
66+
) {
67+
when (style) {
68+
TextDecorationStyle.SOLID -> canvas.drawLine(x1, y, x2, y, paint)
69+
TextDecorationStyle.DOUBLE -> {
70+
// Center-to-center distance such that the visible gap between the
71+
// top and bottom strokes (= gap - thickness) is 2 px regardless of
72+
// stroke width. Blink renders with a 1 px gap, but with
73+
// antialiasing that often reads as a single fat line; the wider
74+
// gap keeps both strokes legible.
75+
val gap = thickness + 2f
76+
canvas.drawLine(x1, y, x2, y, paint)
77+
canvas.drawLine(x1, y + gap, x2, y + gap, paint)
78+
}
79+
TextDecorationStyle.DOTTED,
80+
TextDecorationStyle.DASHED -> {
81+
val intervals =
82+
if (style == TextDecorationStyle.DOTTED) floatArrayOf(thickness, thickness * 2f)
83+
else floatArrayOf(thickness * 4f, thickness * 2f)
84+
val savedEffect = paint.pathEffect
85+
paint.pathEffect = DashPathEffect(intervals, 0f)
86+
// `Canvas.drawLine` ignores `pathEffect`; draw the line as a Path
87+
// so the dash intervals are honored.
88+
val path = Path()
89+
path.moveTo(x1, y)
90+
path.lineTo(x2, y)
91+
canvas.drawPath(path, paint)
92+
paint.pathEffect = savedEffect
93+
}
94+
TextDecorationStyle.WAVY -> {
95+
val clamped = max(1f, thickness)
96+
val wavelength = 1f + 2f * (2f * clamped + 0.5f).roundToInt()
97+
val cpDistance = 0.5f + (3f * clamped + 0.5f).roundToInt()
98+
Log.d(
99+
"ReactWavyDecoration",
100+
"wavelength=$wavelength cpDistance=$cpDistance thickness=$thickness x1=$x1 x2=$x2 y=$y")
101+
val path = Path()
102+
path.moveTo(x1, y)
103+
var x = x1
104+
while (x + wavelength <= x2) {
105+
val cp1x = x + wavelength / 2f
106+
val cp2x = x + wavelength / 2f
107+
val endX = x + wavelength
108+
// Two control points at the midpoint, one above (y - cp) and
109+
// one below (y + cp). Produces an oscillating S-curve per
110+
// wavelength, matching Chromium/Blink's wavy underline.
111+
path.cubicTo(cp1x, y + cpDistance, cp2x, y - cpDistance, endX, y)
112+
x = endX
113+
}
114+
canvas.drawPath(path, paint)
115+
}
116+
}
117+
}

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(textAttributes.textDecorationColor)))
318+
ops.add(SetSpanOperation(start, end, ReactUnderlineSpan(textAttributes.textDecorationColor, textAttributes.textDecorationStyle)))
319319
}
320320
if (textAttributes.isLineThroughTextDecorationSet) {
321-
ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan(textAttributes.textDecorationColor)))
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(fragment.props.textDecorationColor), 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(fragment.props.textDecorationColor), 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: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,28 @@ import android.graphics.Canvas
1111
import android.graphics.Color
1212
import android.os.Build
1313
import android.text.Layout
14+
import com.facebook.react.views.text.TextDecorationStyle
15+
import com.facebook.react.views.text.drawDecorationLine
1416
import kotlin.math.max
1517

1618
/**
17-
* Draws a strikethrough whose color may differ from the text color. Subclasses
18-
* [DrawCommandSpan] so [PreparedLayoutTextView] and [ReactTextView] invoke
19-
* [onDraw] after the layout renders its text. We do NOT extend
20-
* [android.text.style.StrikethroughSpan] here: the framework's `Layout.draw`
21-
* paints the strikethrough using `paint.color` with no field to override,
22-
* so the only way to get a distinct color is to draw it ourselves.
19+
* Draws a strikethrough whose color may differ from the text color and
20+
* whose stroke style may be `solid`, `double`, `dotted`, or `dashed`.
21+
* Subclasses [DrawCommandSpan] so [PreparedLayoutTextView] and
22+
* [ReactTextView] invoke [onDraw] after the layout renders its text. We
23+
* do NOT extend [android.text.style.StrikethroughSpan] here: the
24+
* framework's `Layout.draw` paints the strikethrough using `paint.color`
25+
* with no field to override, so the only way to get a distinct color (or
26+
* style) is to draw it ourselves.
2327
*
2428
* When [color] is [Color.TRANSPARENT] (the default when no
25-
* `textDecorationColor` prop was passed), the strikethrough is drawn in the
26-
* text's foreground color, matching the platform's prior behavior.
29+
* `textDecorationColor` prop was passed), the strikethrough is drawn in
30+
* the text's foreground color, matching the platform's prior behavior.
2731
*/
28-
internal class ReactStrikethroughSpan(private val color: Int = Color.TRANSPARENT) :
29-
DrawCommandSpan() {
32+
internal class ReactStrikethroughSpan(
33+
private val color: Int = Color.TRANSPARENT,
34+
private val style: TextDecorationStyle = TextDecorationStyle.SOLID,
35+
) : DrawCommandSpan() {
3036

3137
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
3238
val paint = layout.paint
@@ -35,24 +41,30 @@ internal class ReactStrikethroughSpan(private val color: Int = Color.TRANSPARENT
3541
val savedStyle = paint.style
3642
val savedAntiAlias = paint.isAntiAlias
3743
val effectiveColor = if (color != Color.TRANSPARENT) color else savedColor
44+
// Density-aware minimum so the strikethrough reads consistently
45+
// across display densities. `paint.density` is the px-per-dp ratio
46+
// at the current paint setup, so `1.5f * paint.density` gives ~1.5 dp.
47+
val minThickness = 1.5f * paint.density
3848
val thickness =
3949
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
40-
max(paint.underlineThickness, 1.5f)
50+
max(paint.underlineThickness, minThickness)
4151
} else {
42-
max(paint.fontMetrics.descent * 0.1f, 1.5f)
52+
max(paint.fontMetrics.descent * 0.1f, minThickness)
4353
}
4454

4555
paint.color = effectiveColor
4656
paint.strokeWidth = thickness
4757
paint.style = android.graphics.Paint.Style.STROKE
4858
paint.isAntiAlias = true
4959

50-
// Position the strikethrough at the midpoint between the line's top
51-
// and baseline so it sits near the x-height midline like the platform
52-
// default. `fontMetrics.ascent` is negative and `descent` is positive,
53-
// so the sum / 2 gives a small negative offset from the baseline.
60+
// Position the strikethrough slightly below the midpoint between
61+
// the line's top and baseline so it sits near the x-height midline
62+
// like the platform default. `fontMetrics.ascent` is negative and
63+
// `descent` is positive, so the sum / 2 gives a small negative
64+
// offset from the baseline; the trailing `+ 1f` nudges it down to
65+
// match the visual position users expect.
5466
val fm = paint.fontMetrics
55-
val offset = (fm.ascent + fm.descent) / 2f
67+
val offset = (fm.ascent + fm.descent) / 2f + 1f
5668

5769
val startLine = layout.getLineForOffset(start)
5870
val endLine = layout.getLineForOffset(end)
@@ -63,7 +75,7 @@ internal class ReactStrikethroughSpan(private val color: Int = Color.TRANSPARENT
6375
val x2 =
6476
if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
6577
val y = baseline + offset
66-
canvas.drawLine(x1, y, x2, y, paint)
78+
drawDecorationLine(canvas, paint, x1, x2, y, thickness, style)
6779
}
6880

6981
paint.color = savedColor

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,29 @@ import android.graphics.Canvas
1111
import android.graphics.Color
1212
import android.os.Build
1313
import android.text.Layout
14+
import com.facebook.react.views.text.TextDecorationStyle
15+
import com.facebook.react.views.text.drawDecorationLine
1416
import kotlin.math.max
1517

1618
/**
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.
19+
* Draws an underline whose color may differ from the text color and
20+
* whose stroke style may be `solid`, `double`, `dotted`, or `dashed`.
21+
* Subclasses [DrawCommandSpan] so [PreparedLayoutTextView] invokes
22+
* [onDraw] after the layout renders its text, ensuring the underline
23+
* paints on top of any descenders. We do NOT extend
24+
* [android.text.style.UnderlineSpan] here: the framework's `Layout.draw`
25+
* reads `paint.color` for the underline color regardless of
26+
* `paint.underlineColor`, so the only way to get a distinct underline
27+
* color (or style) is to draw it ourselves.
2428
*
2529
* When [color] is [Color.TRANSPARENT] (the default when no
2630
* `textDecorationColor` prop was passed), the underline is drawn in the
2731
* text's foreground color, matching the platform's prior behavior.
2832
*/
29-
internal class ReactUnderlineSpan(private val color: Int = Color.TRANSPARENT) :
30-
DrawCommandSpan() {
33+
internal class ReactUnderlineSpan(
34+
private val color: Int = Color.TRANSPARENT,
35+
private val style: TextDecorationStyle = TextDecorationStyle.SOLID,
36+
) : DrawCommandSpan() {
3137

3238
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
3339
val paint = layout.paint
@@ -36,11 +42,15 @@ internal class ReactUnderlineSpan(private val color: Int = Color.TRANSPARENT) :
3642
val savedStyle = paint.style
3743
val savedAntiAlias = paint.isAntiAlias
3844
val effectiveColor = if (color != Color.TRANSPARENT) color else savedColor
45+
// Density-aware minimum so the underline reads consistently across
46+
// display densities. `paint.density` is the px-per-dp ratio at the
47+
// current paint setup, so `1.5f * paint.density` gives ~1.5 dp.
48+
val minThickness = 1.5f * paint.density
3949
val thickness =
4050
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
41-
max(paint.underlineThickness, 1.5f)
51+
max(paint.underlineThickness, minThickness)
4252
} else {
43-
max(paint.fontMetrics.descent * 0.1f, 1.5f)
53+
max(paint.fontMetrics.descent * 0.1f, minThickness)
4454
}
4555

4656
paint.color = effectiveColor
@@ -57,7 +67,7 @@ internal class ReactUnderlineSpan(private val color: Int = Color.TRANSPARENT) :
5767
val x2 =
5868
if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
5969
val y = baseline + thickness + 1f
60-
canvas.drawLine(x1, y, x2, y, paint)
70+
drawDecorationLine(canvas, paint, x1, x2, y, thickness, style)
6171
}
6272

6373
paint.color = savedColor

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,

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ NSString *const RCTAttributedStringEventEmitterKey = @"EventEmitter";
1919
// String representation of either `role` or `accessibilityRole`
2020
NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"AccessibilityRole";
2121

22+
// Custom attribute key for ranges that should render a wavy decoration line.
23+
// UIKit's `NSUnderlineStyle` enum has no native wavy value, so we suppress the
24+
// framework-drawn underline / strikethrough for these ranges and paint the
25+
// wave ourselves in `RCTTextLayoutManager`'s drawing pass using WebKit's
26+
// formula (`controlPointDistance = fontSize * 1.5 / 16`, `step = fontSize / 4.5`).
27+
// Stored as an NSDictionary with @"line" -> @"underline" or @"line-through"
28+
// and @"color" -> UIColor (the decoration color, falling back to the
29+
// foreground color when no `textDecorationColor` was specified).
30+
NSString *const RCTWavyDecorationAttributeName = @"RCTWavyDecoration";
31+
2232
/*
2333
* Creates `NSTextAttributes` from given `facebook::react::TextAttributes`
2434
*/

0 commit comments

Comments
 (0)