Skip to content

Commit 2a68552

Browse files
committed
feat(ios): honor textDecorationStyle on Text decorations
`textDecorationStyle` is declared on `TextStyleIOS` in the public types but `wavy` is silently dropped (Fabric's C++ enum doesn't include `Wavy`, and UIKit's `NSUnderlineStyle` has no native wavy pattern bit). This PR closes the gap by adding `TextDecorationStyle::Wavy` to the shared Fabric primitives / conversions and rendering wavy / dotted / dashed decorations with custom Core Graphics paths instead of UIKit pattern bits. Implementation: - Wavy ranges are tagged with a custom `RCTCustomDecorationAttributeName` (storing the line kinds, stroke color, and style key) in `RCTAttributedTextUtils.mm` and painted by `RCTTextLayoutManager.mm` after `drawGlyphsForGlyphRange:`. - Wavy uses an adaptation of WebKit's formula from `Source/WebCore/style/InlineTextBoxStyle.cpp` (`controlPointDistance = thickness * 1.5 + 0.5`, one cubic Bezier per wavelength, control points at the midpoint above and below the y-axis). At iOS point sizes the literal Blink amplitude renders as a very pronounced wave because Core Graphics paints in points (not device pixels), so the constants are dialed back to read as a clear-but-subtle browser-style wave at typical text sizes. - Dotted uses a custom CG path with a zero-length dash + round line caps, producing actual circular dots at `2 * thickness` spacing (UIKit's `NSUnderlineStylePatternDot` does not match browser geometry on iOS). - Dashed uses a custom CG path with `[2 * thickness, thickness]` intervals — short rectangular dashes with a tight gap, closer to Safari's geometry than UIKit's default `NSUnderlineStylePatternDash`. - Solid and double continue to use UIKit's native `NSUnderlineStyle` pattern bits. - The wavy 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). The shared C++ enum addition unblocks the same value on Android (see companion PR). ## Changelog: [IOS] [ADDED] - `textDecorationStyle: 'wavy'` for `<Text>` (custom CG path) [IOS] [CHANGED] - `textDecorationStyle: 'dotted'` and `'dashed'` for `<Text>` render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely ## Test Plan: Side-by-side comparison on iPhone 17 sim (iOS 26.4) of a `<Text>` with `textDecorationLine="underline"` and `textDecorationStyle` cycling through `solid` / `double` / `dotted` / `dashed` / `wavy`, verified against Safari rendering of the same CSS. Trailing periods now fall under the wavy stroke. Verified with `textDecorationColor` set distinct from the foreground color. ```tsx <Text style={{ color: 'black', textDecorationLine: 'underline', textDecorationStyle: 'wavy', textDecorationColor: '#ff00aa', }}> Hello </Text> ```
1 parent e2e6553 commit 2a68552

6 files changed

Lines changed: 197 additions & 20 deletions

File tree

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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ NSString *const RCTAttributedStringEventEmitterKey = @"EventEmitter";
1919
// String representation of either `role` or `accessibilityRole`
2020
NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"AccessibilityRole";
2121

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";
32+
2233
/*
2334
* Creates `NSTextAttributes` from given `facebook::react::TextAttributes`
2435
*/

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

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -240,29 +240,52 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
240240
// Decoration
241241
if (textAttributes.textDecorationLineType.value_or(TextDecorationLineType::None) != TextDecorationLineType::None) {
242242
auto textDecorationLineType = textAttributes.textDecorationLineType.value();
243-
244-
NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle(
245-
textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid));
246-
243+
auto textDecorationStyleValue = textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid);
247244
UIColor *textDecorationColor = RCTUIColorFromSharedColor(textAttributes.textDecorationColor);
248245

249-
// Underline
250-
if (textDecorationLineType == TextDecorationLineType::Underline ||
251-
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
252-
attributes[NSUnderlineStyleAttributeName] = @(style);
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) {
253+
UIColor *strokeColor = textDecorationColor ?: RCTUIColorFromSharedColor(textAttributes.foregroundColor);
254+
NSMutableArray<NSString *> *lines = [NSMutableArray array];
255+
if (textDecorationLineType == TextDecorationLineType::Underline ||
256+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
257+
[lines addObject:@"underline"];
258+
}
259+
if (textDecorationLineType == TextDecorationLineType::Strikethrough ||
260+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
261+
[lines addObject:@"line-through"];
262+
}
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};
268+
} else {
269+
NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle(textDecorationStyleValue);
270+
271+
// Underline
272+
if (textDecorationLineType == TextDecorationLineType::Underline ||
273+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
274+
attributes[NSUnderlineStyleAttributeName] = @(style);
253275

254-
if (textDecorationColor) {
255-
attributes[NSUnderlineColorAttributeName] = textDecorationColor;
276+
if (textDecorationColor) {
277+
attributes[NSUnderlineColorAttributeName] = textDecorationColor;
278+
}
256279
}
257-
}
258280

259-
// Strikethrough
260-
if (textDecorationLineType == TextDecorationLineType::Strikethrough ||
261-
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
262-
attributes[NSStrikethroughStyleAttributeName] = @(style);
281+
// Strikethrough
282+
if (textDecorationLineType == TextDecorationLineType::Strikethrough ||
283+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
284+
attributes[NSStrikethroughStyleAttributeName] = @(style);
263285

264-
if (textDecorationColor) {
265-
attributes[NSStrikethroughColorAttributeName] = textDecorationColor;
286+
if (textDecorationColor) {
287+
attributes[NSStrikethroughColorAttributeName] = textDecorationColor;
288+
}
266289
}
267290
}
268291
}

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

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

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).
101+
{
102+
CGContextRef ctx = UIGraphicsGetCurrentContext();
103+
if (ctx != nullptr) {
104+
NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr];
105+
[textStorage
106+
enumerateAttribute:RCTCustomDecorationAttributeName
107+
inRange:charRange
108+
options:0
109+
usingBlock:^(NSDictionary *_Nullable attrs, NSRange attrRange, __unused BOOL *stop) {
110+
if (attrs == nil) {
111+
return;
112+
}
113+
NSArray<NSString *> *lines = attrs[@"lines"];
114+
UIColor *strokeColor = attrs[@"color"];
115+
NSString *style = attrs[@"style"];
116+
UIFont *font = [textStorage attribute:NSFontAttributeName
117+
atIndex:attrRange.location
118+
effectiveRange:nullptr];
119+
if (font == nil || strokeColor == nil || style == nil) {
120+
return;
121+
}
122+
123+
CGFloat fontSize = font.pointSize;
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];
138+
139+
CGContextSaveGState(ctx);
140+
CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor);
141+
CGContextSetLineWidth(ctx, thickness);
142+
CGContextSetShouldAntialias(ctx, YES);
143+
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+
160+
[layoutManager
161+
enumerateLineFragmentsForGlyphRange:targetGlyphRange
162+
usingBlock:^(
163+
CGRect lineRect,
164+
__unused CGRect usedRect,
165+
NSTextContainer *_Nonnull container,
166+
NSRange lineGlyphRange,
167+
__unused BOOL *_Nonnull innerStop) {
168+
NSRange intersection =
169+
NSIntersectionRange(targetGlyphRange, lineGlyphRange);
170+
if (intersection.length == 0) {
171+
return;
172+
}
173+
CGRect firstGlyphRect =
174+
[layoutManager boundingRectForGlyphRange:NSMakeRange(
175+
intersection.location,
176+
1)
177+
inTextContainer:container];
178+
CGRect lastGlyphRect =
179+
[layoutManager boundingRectForGlyphRange:NSMakeRange(
180+
NSMaxRange(intersection) -
181+
1,
182+
1)
183+
inTextContainer:container];
184+
CGFloat x1 = firstGlyphRect.origin.x + frame.origin.x;
185+
CGFloat x2 = CGRectGetMaxX(lastGlyphRect) + frame.origin.x;
186+
CGFloat baseline = lineRect.origin.y + font.ascender + frame.origin.y;
187+
188+
for (NSString *line in lines) {
189+
CGFloat y;
190+
if ([line isEqualToString:@"underline"]) {
191+
y = baseline + thickness + 1.0f;
192+
} else {
193+
y = baseline + (font.descender - font.ascender) / 2.0f + 1.0f;
194+
}
195+
CGContextBeginPath(ctx);
196+
CGContextMoveToPoint(ctx, x1, 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);
219+
}
220+
CGContextStrokePath(ctx);
221+
}
222+
}];
223+
224+
CGContextRestoreGState(ctx);
225+
}];
226+
}
227+
}
228+
98229
if (block != nil) {
99230
__block UIBezierPath *highlightPath = nil;
100231
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,18 @@ 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;
119+
case facebook::react::TextDecorationStyle::Wavy:
120+
return NSUnderlineStyleSingle;
113121
}
114122
}
115123

0 commit comments

Comments
 (0)