Skip to content

Commit 5898e8f

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 5898e8f

5 files changed

Lines changed: 95 additions & 50 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,11 @@ internal fun drawDecorationLine(
101101
val path = Path()
102102
path.moveTo(x1, y)
103103
var x = x1
104-
while (x + wavelength <= x2) {
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) {
105109
val cp1x = x + wavelength / 2f
106110
val cp2x = x + wavelength / 2f
107111
val endX = x + wavelength

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

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

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,15 @@ - (void)drawAttributedString:(AttributedString)attributedString
9595
CGContextRestoreGState(context);
9696
#endif
9797

98-
// Wavy decoration pass: enumerate `RCTWavyDecorationAttributeName` ranges
99-
// and paint each one ourselves using WebKit's cubic-Bezier wave (UIKit's
100-
// `NSUnderlineStyle` has no native wavy value).
98+
// Custom decoration pass: enumerate `RCTCustomDecorationAttributeName`
99+
// ranges and paint each one ourselves. Covers wavy (no UIKit equivalent),
100+
// dotted, and dashed (UIKit's pattern bits don't match browser geometry).
101101
{
102102
CGContextRef ctx = UIGraphicsGetCurrentContext();
103103
if (ctx != nullptr) {
104104
NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr];
105105
[textStorage
106-
enumerateAttribute:RCTWavyDecorationAttributeName
106+
enumerateAttribute:RCTCustomDecorationAttributeName
107107
inRange:charRange
108108
options:0
109109
usingBlock:^(NSDictionary *_Nullable attrs, NSRange attrRange, __unused BOOL *stop) {
@@ -112,41 +112,61 @@ - (void)drawAttributedString:(AttributedString)attributedString
112112
}
113113
NSArray<NSString *> *lines = attrs[@"lines"];
114114
UIColor *strokeColor = attrs[@"color"];
115+
NSString *style = attrs[@"style"];
115116
UIFont *font = [textStorage attribute:NSFontAttributeName
116117
atIndex:attrRange.location
117118
effectiveRange:nullptr];
118-
if (font == nil || strokeColor == nil) {
119+
if (font == nil || strokeColor == nil || style == nil) {
119120
return;
120121
}
121122

122123
CGFloat fontSize = font.pointSize;
123-
// WebKit constants from Source/WebCore/style/InlineTextBoxStyle.cpp:
124-
// controlPointDistance = fontSize * 1.5 / 16
125-
// step = fontSize / 4.5 (half-wavelength)
126-
CGFloat cpDistance = fontSize * 1.5f / 16.0f;
127-
CGFloat step = fontSize / 4.5f;
128-
CGFloat wavelength = 2.0f * step;
129-
CGFloat thickness = MAX(fontSize / 16.0f, 1.0f);
130-
131-
NSRange wavyGlyphRange = [layoutManager glyphRangeForCharacterRange:attrRange
132-
actualCharacterRange:nullptr];
124+
// Thickness scales with the type size so the decoration
125+
// remains visible at small sizes and proportionate at
126+
// large ones. ~`fontSize / 12` plus a 1.5pt floor.
127+
CGFloat thickness = MAX(fontSize / 12.0f, 1.5f);
128+
// Wavelength = Blink's; control-point distance halved
129+
// so the iOS rendering reads as a subtle wave (Blink's
130+
// literal `0.5 + round(3 * t + 0.5)` is too pronounced
131+
// at iOS point sizes since the path is already drawn
132+
// in points, not device pixels).
133+
CGFloat wavyWavelength = 1.0f + 2.0f * round(2.0f * thickness + 0.5f);
134+
CGFloat wavyCpDistance = 0.5f + round(1.5f * thickness + 0.5f);
135+
136+
NSRange targetGlyphRange = [layoutManager glyphRangeForCharacterRange:attrRange
137+
actualCharacterRange:nullptr];
133138

134139
CGContextSaveGState(ctx);
135140
CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor);
136141
CGContextSetLineWidth(ctx, thickness);
137-
CGContextSetLineCap(ctx, kCGLineCapRound);
138142
CGContextSetShouldAntialias(ctx, YES);
139143

144+
if ([style isEqualToString:@"dotted"]) {
145+
// Zero-length dash with round caps = circular dots.
146+
// Gap of ~2 * thickness between dot centers.
147+
CGFloat dotIntervals[2] = {0.0f, thickness * 2.0f};
148+
CGContextSetLineDash(ctx, 0, dotIntervals, 2);
149+
CGContextSetLineCap(ctx, kCGLineCapRound);
150+
} else if ([style isEqualToString:@"dashed"]) {
151+
// Short rectangular dashes with a tight gap.
152+
CGFloat dashIntervals[2] = {thickness * 2.0f, thickness};
153+
CGContextSetLineDash(ctx, 0, dashIntervals, 2);
154+
CGContextSetLineCap(ctx, kCGLineCapButt);
155+
} else {
156+
// wavy
157+
CGContextSetLineCap(ctx, kCGLineCapRound);
158+
}
159+
140160
[layoutManager
141-
enumerateLineFragmentsForGlyphRange:wavyGlyphRange
161+
enumerateLineFragmentsForGlyphRange:targetGlyphRange
142162
usingBlock:^(
143163
CGRect lineRect,
144164
__unused CGRect usedRect,
145165
NSTextContainer *_Nonnull container,
146166
NSRange lineGlyphRange,
147167
__unused BOOL *_Nonnull innerStop) {
148168
NSRange intersection =
149-
NSIntersectionRange(wavyGlyphRange, lineGlyphRange);
169+
NSIntersectionRange(targetGlyphRange, lineGlyphRange);
150170
if (intersection.length == 0) {
151171
return;
152172
}
@@ -170,17 +190,32 @@ - (void)drawAttributedString:(AttributedString)attributedString
170190
if ([line isEqualToString:@"underline"]) {
171191
y = baseline + thickness + 1.0f;
172192
} else {
173-
// line-through: position near the x-height midline
174193
y = baseline + (font.descender - font.ascender) / 2.0f + 1.0f;
175194
}
176195
CGContextBeginPath(ctx);
177196
CGContextMoveToPoint(ctx, x1, y);
178-
for (CGFloat x = x1; x + wavelength <= x2; x += wavelength) {
179-
CGFloat midX = x + step;
180-
// Two control points at the midpoint, one above and one
181-
// below the y-axis, matching WebKit's wave shape.
182-
CGContextAddCurveToPoint(
183-
ctx, midX, y + cpDistance, midX, y - cpDistance, x + wavelength, y);
197+
if ([style isEqualToString:@"wavy"]) {
198+
// Draw enough whole cycles to cover the run.
199+
// Looping while `x < x2` (rather than
200+
// `x + wavelength <= x2`) ensures the wave
201+
// continues through the final character
202+
// (including trailing punctuation) — the last
203+
// cycle may extend a hair past the text bound,
204+
// which reads as a natural underline trailer.
205+
CGFloat step = wavyWavelength / 2.0f;
206+
for (CGFloat x = x1; x < x2; x += wavyWavelength) {
207+
CGFloat midX = x + step;
208+
CGContextAddCurveToPoint(
209+
ctx,
210+
midX,
211+
y + wavyCpDistance,
212+
midX,
213+
y - wavyCpDistance,
214+
x + wavyWavelength,
215+
y);
216+
}
217+
} else {
218+
CGContextAddLineToPoint(ctx, x2, y);
184219
}
185220
CGContextStrokePath(ctx);
186221
}

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,17 @@ inline static NSUnderlineStyle RCTNSUnderlineStyleFromTextDecorationStyle(
106106
return NSUnderlineStyleSingle;
107107
case facebook::react::TextDecorationStyle::Double:
108108
return NSUnderlineStyleDouble;
109+
// Dotted, dashed, and wavy are tagged with
110+
// `RCTCustomDecorationAttributeName` in `RCTAttributedTextUtils.mm` and
111+
// painted by `RCTTextLayoutManager.mm`'s drawing pass; UIKit's pattern
112+
// bits don't match the geometry browsers use, and there is no native
113+
// wavy value at all. These branches are unreachable in normal flow; the
114+
// returned values keep the switch exhaustive.
109115
case facebook::react::TextDecorationStyle::Dashed:
110-
return NSUnderlineStylePatternDash | NSUnderlineStyleSingle;
116+
return NSUnderlineStyleSingle;
111117
case facebook::react::TextDecorationStyle::Dotted:
112-
return NSUnderlineStylePatternDot | NSUnderlineStyleSingle;
118+
return NSUnderlineStyleSingle;
113119
case facebook::react::TextDecorationStyle::Wavy:
114-
// UIKit's `NSUnderlineStyle` has no native wavy. Wavy ranges are
115-
// tagged with `RCTWavyDecorationAttributeName` in
116-
// `RCTAttributedTextUtils.mm` and painted in
117-
// `RCTTextLayoutManager.mm`'s drawing pass using WebKit's
118-
// `controlPointDistance = fontSize * 1.5 / 16` /
119-
// `step = fontSize / 4.5` formula, so this branch is unreachable
120-
// in normal flow; the `NSUnderlineStyleSingle` here keeps the
121-
// switch exhaustive.
122120
return NSUnderlineStyleSingle;
123121
}
124122
}

0 commit comments

Comments
 (0)