Skip to content

Commit 7bf5142

Browse files
committed
fix(ios,android): polish wavy/dotted/dashed text decoration rendering
Iterations on the textDecorationStyle implementation that landed in PR #56748, based on visual comparison with Chrome / Safari and side-by-side testing of the two platforms. iOS: - Wavy: thickness divisor relaxed from `fontSize / 8` to `fontSize / 12` and control-point distance multiplier halved (`1.5 * thickness + 0.5` vs Blink's literal `3 * thickness + 0.5`). At iOS point sizes the literal Blink amplitude renders as a very pronounced wave; the dialed- back values read as a clear-but-subtle browser-style wave. - Dotted: switched from UIKit's `NSUnderlineStylePatternDot` (which doesn't match browser geometry) to a custom CG path with a zero-length dash + round line caps, producing actual circular dots at `2 * thickness` spacing. - Dashed: switched from UIKit's `NSUnderlineStylePatternDash` to custom CG path with `[2 * thickness, thickness]` intervals — short rectangular dashes with a tight gap, closer to Safari's geometry than UIKit's default. - The custom decoration attribute (formerly `RCTWavyDecorationAttributeName`) is now `RCTCustomDecorationAttributeName` and carries a `style` key so the same drawing pipeline handles wavy + dotted + dashed. Cross-platform: - Wavy drawing loop now iterates `while x < x2` instead of `while x + wavelength <= x2`, so the final cycle continues through the last character (including trailing punctuation). Previously a trailing period could be visually uncovered when the run width was not an integer multiple of the wavelength. ## Changelog: [IOS] [CHANGED] - Wavy, dotted, and dashed text decorations render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely [GENERAL] [FIXED] - Wavy underline / strikethrough now extends through the final character of the run, including trailing punctuation ## Test Plan: Side-by-side comparison on Android API 36 emulator and iPhone 17 sim (iOS 26.4) of a `<Text>` with `textDecorationLine="underline"` and `textDecorationStyle` cycling through `wavy` / `dotted` / `dashed`, verified against Chrome (Android view) and Safari (iOS view) rendering of the same CSS. Trailing periods now fall under the wavy stroke on both platforms.
1 parent 1a8cbda commit 7bf5142

7 files changed

Lines changed: 178 additions & 165 deletions

File tree

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

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
package com.facebook.react.views.text
99

1010
import android.graphics.Canvas
11+
import android.graphics.Color
1112
import android.graphics.DashPathEffect
1213
import android.graphics.Paint
1314
import android.graphics.Path
15+
import android.os.Build
16+
import android.text.Layout
1417
import kotlin.math.max
1518
import kotlin.math.roundToInt
1619

@@ -95,23 +98,78 @@ internal fun drawDecorationLine(
9598
val clamped = max(1f, thickness)
9699
val wavelength = 1f + 2f * (2f * clamped + 0.5f).roundToInt()
97100
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")
101101
val path = Path()
102102
path.moveTo(x1, y)
103103
var x = x1
104-
while (x + wavelength <= x2) {
105-
val cp1x = x + wavelength / 2f
106-
val cp2x = x + wavelength / 2f
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
107110
val endX = x + wavelength
108111
// Two control points at the midpoint, one above (y - cp) and
109112
// one below (y + cp). Produces an oscillating S-curve per
110113
// wavelength, matching Chromium/Blink's wavy underline.
111-
path.cubicTo(cp1x, y + cpDistance, cp2x, y - cpDistance, endX, y)
114+
path.cubicTo(midX, y + cpDistance, midX, y - cpDistance, endX, y)
112115
x = endX
113116
}
114117
canvas.drawPath(path, paint)
115118
}
116119
}
117120
}
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/internal/span/ReactStrikethroughSpan.kt

Lines changed: 13 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,78 +9,33 @@ package com.facebook.react.views.text.internal.span
99

1010
import android.graphics.Canvas
1111
import android.graphics.Color
12-
import android.os.Build
1312
import android.text.Layout
1413
import com.facebook.react.views.text.TextDecorationStyle
15-
import com.facebook.react.views.text.drawDecorationLine
16-
import kotlin.math.max
14+
import com.facebook.react.views.text.drawSpannedDecoration
1715

1816
/**
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
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
2319
* do NOT extend [android.text.style.StrikethroughSpan] here: the
2420
* 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.
21+
* with no field to override, so painting it ourselves is the only way to
22+
* get a distinct color or non-solid style.
2723
*
28-
* When [color] is [Color.TRANSPARENT] (the default when no
29-
* `textDecorationColor` prop was passed), the strikethrough is drawn in
30-
* the text's foreground color, matching the platform's prior behavior.
24+
* `color == Color.TRANSPARENT` falls back to the text foreground color.
3125
*/
3226
internal class ReactStrikethroughSpan(
3327
private val color: Int = Color.TRANSPARENT,
3428
private val style: TextDecorationStyle = TextDecorationStyle.SOLID,
3529
) : DrawCommandSpan() {
3630

3731
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
38-
val paint = layout.paint
39-
val savedColor = paint.color
40-
val savedStrokeWidth = paint.strokeWidth
41-
val savedStyle = paint.style
42-
val savedAntiAlias = paint.isAntiAlias
43-
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
48-
val thickness =
49-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
50-
max(paint.underlineThickness, minThickness)
51-
} else {
52-
max(paint.fontMetrics.descent * 0.1f, minThickness)
53-
}
54-
55-
paint.color = effectiveColor
56-
paint.strokeWidth = thickness
57-
paint.style = android.graphics.Paint.Style.STROKE
58-
paint.isAntiAlias = true
59-
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.
66-
val fm = paint.fontMetrics
67-
val offset = (fm.ascent + fm.descent) / 2f + 1f
68-
69-
val startLine = layout.getLineForOffset(start)
70-
val endLine = layout.getLineForOffset(end)
71-
for (line in startLine..endLine) {
72-
val baseline = layout.getLineBaseline(line).toFloat()
73-
val x1 =
74-
if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line)
75-
val x2 =
76-
if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
77-
val y = baseline + offset
78-
drawDecorationLine(canvas, paint, x1, x2, y, thickness, style)
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
7939
}
80-
81-
paint.color = savedColor
82-
paint.strokeWidth = savedStrokeWidth
83-
paint.style = savedStyle
84-
paint.isAntiAlias = savedAntiAlias
8540
}
8641
}

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

Lines changed: 10 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,70 +9,29 @@ package com.facebook.react.views.text.internal.span
99

1010
import android.graphics.Canvas
1111
import android.graphics.Color
12-
import android.os.Build
1312
import android.text.Layout
1413
import com.facebook.react.views.text.TextDecorationStyle
15-
import com.facebook.react.views.text.drawDecorationLine
16-
import kotlin.math.max
14+
import com.facebook.react.views.text.drawSpannedDecoration
1715

1816
/**
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
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
2420
* [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.
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.
2824
*
29-
* When [color] is [Color.TRANSPARENT] (the default when no
30-
* `textDecorationColor` prop was passed), the underline is drawn in the
31-
* text's foreground color, matching the platform's prior behavior.
25+
* `color == Color.TRANSPARENT` falls back to the text foreground color.
3226
*/
3327
internal class ReactUnderlineSpan(
3428
private val color: Int = Color.TRANSPARENT,
3529
private val style: TextDecorationStyle = TextDecorationStyle.SOLID,
3630
) : DrawCommandSpan() {
3731

3832
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
39-
val paint = layout.paint
40-
val savedColor = paint.color
41-
val savedStrokeWidth = paint.strokeWidth
42-
val savedStyle = paint.style
43-
val savedAntiAlias = paint.isAntiAlias
44-
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
49-
val thickness =
50-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
51-
max(paint.underlineThickness, minThickness)
52-
} else {
53-
max(paint.fontMetrics.descent * 0.1f, minThickness)
54-
}
55-
56-
paint.color = effectiveColor
57-
paint.strokeWidth = thickness
58-
paint.style = android.graphics.Paint.Style.STROKE
59-
paint.isAntiAlias = true
60-
61-
val startLine = layout.getLineForOffset(start)
62-
val endLine = layout.getLineForOffset(end)
63-
for (line in startLine..endLine) {
64-
val baseline = layout.getLineBaseline(line).toFloat()
65-
val x1 =
66-
if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line)
67-
val x2 =
68-
if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
69-
val y = baseline + thickness + 1f
70-
drawDecorationLine(canvas, paint, x1, x2, y, thickness, style)
33+
drawSpannedDecoration(start, end, canvas, layout, color, style) { _, baseline, thickness ->
34+
baseline + thickness + 1f
7135
}
72-
73-
paint.color = savedColor
74-
paint.strokeWidth = savedStrokeWidth
75-
paint.style = savedStyle
76-
paint.isAntiAlias = savedAntiAlias
7736
}
7837
}

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +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";
22+
// Custom attribute key for ranges whose decoration line cannot be rendered
23+
// faithfully via UIKit's `NSUnderlineStyle` pattern bits (wavy has no native
24+
// equivalent; dotted/dashed don't match the geometry browsers use). These
25+
// ranges are painted by `RCTTextLayoutManager`'s drawing pass.
26+
//
27+
// Stored as an NSDictionary:
28+
// @"lines": NSArray of @"underline" / @"line-through"
29+
// @"color": UIColor stroke color
30+
// @"style": NSString — @"wavy" | @"dotted" | @"dashed"
31+
NSString *const RCTCustomDecorationAttributeName = @"RCTCustomDecoration";
3132

3233
/*
3334
* Creates `NSTextAttributes` from given `facebook::react::TextAttributes`

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,13 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
243243
auto textDecorationStyleValue = textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid);
244244
UIColor *textDecorationColor = RCTUIColorFromSharedColor(textAttributes.textDecorationColor);
245245

246-
if (textDecorationStyleValue == TextDecorationStyle::Wavy) {
247-
// UIKit's `NSUnderlineStyle` has no native wavy. Suppress the
248-
// framework-drawn line and tag the range so `RCTTextLayoutManager`
249-
// can paint a WebKit-style wavy stroke in its drawing pass.
246+
// Custom drawing for styles UIKit can't render faithfully: wavy (no
247+
// native value), and dotted/dashed (UIKit's pattern bits don't match
248+
// browser geometry). The other styles continue to use NSUnderlineStyle.
249+
bool needsCustomDrawing = textDecorationStyleValue == TextDecorationStyle::Wavy ||
250+
textDecorationStyleValue == TextDecorationStyle::Dotted ||
251+
textDecorationStyleValue == TextDecorationStyle::Dashed;
252+
if (needsCustomDrawing) {
250253
UIColor *strokeColor = textDecorationColor ?: RCTUIColorFromSharedColor(textAttributes.foregroundColor);
251254
NSMutableArray<NSString *> *lines = [NSMutableArray array];
252255
if (textDecorationLineType == TextDecorationLineType::Underline ||
@@ -257,7 +260,11 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
257260
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
258261
[lines addObject:@"line-through"];
259262
}
260-
attributes[RCTWavyDecorationAttributeName] = @{@"lines" : lines, @"color" : strokeColor ?: [UIColor labelColor]};
263+
NSString *styleKey = textDecorationStyleValue == TextDecorationStyle::Wavy
264+
? @"wavy"
265+
: (textDecorationStyleValue == TextDecorationStyle::Dotted ? @"dotted" : @"dashed");
266+
attributes[RCTCustomDecorationAttributeName] =
267+
@{@"lines" : lines, @"color" : strokeColor ?: [UIColor labelColor], @"style" : styleKey};
261268
} else {
262269
NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle(textDecorationStyleValue);
263270

0 commit comments

Comments
 (0)