Skip to content

Commit 9d03750

Browse files
quantizormeta-codesync[bot]
authored andcommitted
feat(ios): honor textDecorationStyle on Text decorations (#56769)
Summary: `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. Separately, `dotted` and `dashed` map to `NSUnderlineStylePatternDot` / `NSUnderlineStylePatternDash` which don't match browser geometry on iOS. This PR adds `TextDecorationStyle::Wavy` to the shared Fabric primitives / conversions (also unblocks the same value on Android, see companion PR #56768) and renders wavy / dotted / dashed decorations with custom Core Graphics paths. **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. - 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. - Solid and double continue to use UIKit's native `NSUnderlineStyle` pattern bits, so this PR does not touch the long-standing iOS Arial+bold solid-underline rendering bug tracked in #53935. - 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). Companion PRs (independent, also targeting `main`): - #56767 — fix(android): textDecorationColor on underlines + strikethroughs. Resolves #4579 (2015). - #56768 — feat(android): textDecorationStyle solid/double/dotted/dashed/wavy. Shares the `TextDecorationStyle::Wavy` enum addition; whichever lands first leaves the other with a trivial conflict to resolve. ## Changelog: [IOS] [ADDED] - `textDecorationStyle: 'wavy'` for `<Text>` (custom CoreGraphics path) [IOS] [CHANGED] - `textDecorationStyle: 'dotted'` and `'dashed'` for `<Text>` render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely Pull Request resolved: #56769 Test Plan: See the screenshot comparisons here: https://www.internalfb.com/compare-screenshots-from-diff/D104680636 {F1990979243} ---- 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> ``` Reviewed By: cipolleschi Differential Revision: D104680636 Pulled By: cortinico fbshipit-source-id: ac96e5b36530f7d243a4b85a67c576b62fe99866
1 parent d205267 commit 9d03750

5 files changed

Lines changed: 239 additions & 22 deletions

File tree

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: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -240,29 +240,56 @@ 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 != nil) ? textDecorationColor
254+
: RCTUIColorFromSharedColor(textAttributes.foregroundColor);
255+
NSMutableArray<NSString *> *lines = [NSMutableArray array];
256+
if (textDecorationLineType == TextDecorationLineType::Underline ||
257+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
258+
[lines addObject:@"underline"];
259+
}
260+
if (textDecorationLineType == TextDecorationLineType::Strikethrough ||
261+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
262+
[lines addObject:@"line-through"];
263+
}
264+
NSString *styleKey = textDecorationStyleValue == TextDecorationStyle::Wavy
265+
? @"wavy"
266+
: (textDecorationStyleValue == TextDecorationStyle::Dotted ? @"dotted" : @"dashed");
267+
attributes[RCTCustomDecorationAttributeName] = @{
268+
@"lines" : lines,
269+
@"color" : (strokeColor != nil) ? strokeColor : [UIColor labelColor],
270+
@"style" : styleKey
271+
};
272+
} else {
273+
NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle(textDecorationStyleValue);
274+
275+
// Underline
276+
if (textDecorationLineType == TextDecorationLineType::Underline ||
277+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
278+
attributes[NSUnderlineStyleAttributeName] = @(style);
253279

254-
if (textDecorationColor) {
255-
attributes[NSUnderlineColorAttributeName] = textDecorationColor;
280+
if (textDecorationColor != nil) {
281+
attributes[NSUnderlineColorAttributeName] = textDecorationColor;
282+
}
256283
}
257-
}
258284

259-
// Strikethrough
260-
if (textDecorationLineType == TextDecorationLineType::Strikethrough ||
261-
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
262-
attributes[NSStrikethroughStyleAttributeName] = @(style);
285+
// Strikethrough
286+
if (textDecorationLineType == TextDecorationLineType::Strikethrough ||
287+
textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) {
288+
attributes[NSStrikethroughStyleAttributeName] = @(style);
263289

264-
if (textDecorationColor) {
265-
attributes[NSStrikethroughColorAttributeName] = textDecorationColor;
290+
if (textDecorationColor != nil) {
291+
attributes[NSStrikethroughColorAttributeName] = textDecorationColor;
292+
}
266293
}
267294
}
268295
}

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
#import "RCTTextLayoutManager.h"
99

10+
#import <array>
11+
1012
#import "RCTAttributedTextUtils.h"
1113

1214
#import <React/NSTextStorage+FontScaling.h>
@@ -95,6 +97,137 @@ - (void)drawAttributedString:(AttributedString)attributedString
9597
CGContextRestoreGState(context);
9698
#endif
9799

100+
// Custom decoration pass: enumerate `RCTCustomDecorationAttributeName`
101+
// ranges and paint each one ourselves. Covers wavy (no UIKit equivalent),
102+
// dotted, and dashed (UIKit's pattern bits don't match browser geometry).
103+
{
104+
CGContextRef ctx = UIGraphicsGetCurrentContext();
105+
if (ctx != nullptr) {
106+
NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr];
107+
[textStorage
108+
enumerateAttribute:RCTCustomDecorationAttributeName
109+
inRange:charRange
110+
options:0
111+
usingBlock:^(NSDictionary *_Nullable attrs, NSRange attrRange, __unused BOOL *stop) {
112+
if (attrs == nil) {
113+
return;
114+
}
115+
NSArray<NSString *> *lines = attrs[@"lines"];
116+
UIColor *strokeColor = attrs[@"color"];
117+
NSString *style = attrs[@"style"];
118+
UIFont *font = [textStorage attribute:NSFontAttributeName
119+
atIndex:attrRange.location
120+
effectiveRange:nullptr];
121+
if (font == nil || strokeColor == nil || style == nil) {
122+
return;
123+
}
124+
125+
CGFloat fontSize = font.pointSize;
126+
// Thickness scales with the type size so the decoration
127+
// remains visible at small sizes and proportionate at
128+
// large ones. ~`fontSize / 12` plus a 1.5pt floor.
129+
CGFloat thickness = MAX(fontSize / 12.0f, 1.5f);
130+
CGFloat wavyWavelength = 1.0f + 2.0f * round(2.0f * thickness + 0.5f);
131+
CGFloat wavyCpDistance = 0.5f + round(3.0f * thickness + 0.5f);
132+
133+
NSRange targetGlyphRange = [layoutManager glyphRangeForCharacterRange:attrRange
134+
actualCharacterRange:nullptr];
135+
136+
CGContextSaveGState(ctx);
137+
CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor);
138+
CGContextSetLineWidth(ctx, thickness);
139+
CGContextSetShouldAntialias(ctx, YES);
140+
141+
if ([style isEqualToString:@"dotted"]) {
142+
const std::array<CGFloat, 2> dotIntervals = {0.0f, thickness * 2.0f};
143+
CGContextSetLineDash(ctx, 0, dotIntervals.data(), dotIntervals.size());
144+
CGContextSetLineCap(ctx, kCGLineCapRound);
145+
} else if ([style isEqualToString:@"dashed"]) {
146+
const std::array<CGFloat, 2> dashIntervals = {thickness * 2.0f, thickness};
147+
CGContextSetLineDash(ctx, 0, dashIntervals.data(), dashIntervals.size());
148+
CGContextSetLineCap(ctx, kCGLineCapButt);
149+
} else {
150+
CGContextSetLineCap(ctx, kCGLineCapRound);
151+
}
152+
153+
[layoutManager
154+
enumerateLineFragmentsForGlyphRange:targetGlyphRange
155+
usingBlock:^(
156+
CGRect lineRect,
157+
__unused CGRect usedRect,
158+
NSTextContainer *_Nonnull container,
159+
NSRange lineGlyphRange,
160+
__unused BOOL *_Nonnull innerStop) {
161+
NSRange intersection =
162+
NSIntersectionRange(targetGlyphRange, lineGlyphRange);
163+
if (intersection.length == 0) {
164+
return;
165+
}
166+
CGRect firstGlyphRect = [layoutManager
167+
boundingRectForGlyphRange:NSMakeRange(intersection.location, 1)
168+
inTextContainer:container];
169+
CGRect lastGlyphRect = [layoutManager
170+
boundingRectForGlyphRange:NSMakeRange(
171+
NSMaxRange(intersection) - 1, 1)
172+
inTextContainer:container];
173+
CGFloat x1 = firstGlyphRect.origin.x + frame.origin.x;
174+
CGFloat x2 = CGRectGetMaxX(lastGlyphRect) + frame.origin.x;
175+
CGFloat baseline =
176+
lineRect.origin.y + font.ascender + frame.origin.y;
177+
178+
// NOLINTNEXTLINE(cppcoreguidelines-init-variables)
179+
for (NSString *line in lines) {
180+
CGFloat y = 0.0f;
181+
if ([line isEqualToString:@"underline"]) {
182+
if ([style isEqualToString:@"wavy"]) {
183+
y = baseline + 1.0f;
184+
} else {
185+
y = baseline + thickness + 1.0f;
186+
}
187+
} else {
188+
y = baseline - (font.ascender + font.descender) / 2.0f + 1.0f;
189+
}
190+
if ([style isEqualToString:@"wavy"]) {
191+
CGContextSaveGState(ctx);
192+
CGContextClipToRect(
193+
ctx,
194+
CGRectMake(
195+
x1,
196+
y - wavyCpDistance - thickness,
197+
x2 - x1,
198+
2 * wavyCpDistance + 2 * thickness));
199+
CGContextBeginPath(ctx);
200+
CGContextMoveToPoint(ctx, x1, y);
201+
CGFloat step = wavyWavelength / 2.0f;
202+
CGFloat wx = x1;
203+
while (wx < x2) {
204+
CGFloat midX = wx + step;
205+
CGContextAddCurveToPoint(
206+
ctx,
207+
midX,
208+
y + wavyCpDistance,
209+
midX,
210+
y - wavyCpDistance,
211+
wx + wavyWavelength,
212+
y);
213+
wx += wavyWavelength;
214+
}
215+
CGContextStrokePath(ctx);
216+
CGContextRestoreGState(ctx);
217+
} else {
218+
CGContextBeginPath(ctx);
219+
CGContextMoveToPoint(ctx, x1, y);
220+
CGContextAddLineToPoint(ctx, x2, y);
221+
CGContextStrokePath(ctx);
222+
}
223+
}
224+
}];
225+
226+
CGContextRestoreGState(ctx);
227+
}];
228+
}
229+
}
230+
98231
if (block != nil) {
99232
__block UIBezierPath *highlightPath = nil;
100233
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +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-
// No native NSUnderlineStyle for wavy; fall back to solid.
115120
return NSUnderlineStyleSingle;
116121
}
117122
}

packages/rn-tester/js/examples/Text/TextExample.ios.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,14 @@ const examples = [
881881
}}>
882882
Dotted underline with custom color
883883
</Text>
884+
<Text
885+
style={{
886+
textDecorationLine: 'underline',
887+
textDecorationStyle: 'wavy',
888+
textDecorationColor: 'red',
889+
}}>
890+
Wavy underline (red)
891+
</Text>
884892
<Text style={{textDecorationLine: 'none'}}>None textDecoration</Text>
885893
<Text
886894
style={{
@@ -913,8 +921,41 @@ const examples = [
913921
}}>
914922
Dotted line-through with custom color
915923
</Text>
916-
<Text style={{textDecorationLine: 'underline line-through'}}>
917-
Both underline and line-through
924+
<Text
925+
style={{
926+
textDecorationLine: 'line-through',
927+
textDecorationStyle: 'wavy',
928+
textDecorationColor: 'red',
929+
}}>
930+
Wavy line-through (red)
931+
</Text>
932+
<Text
933+
style={{
934+
textDecorationLine: 'underline line-through',
935+
textDecorationStyle: 'wavy',
936+
}}>
937+
Both underline and line-through (wavy)
938+
</Text>
939+
<Text>
940+
Mixed text with{' '}
941+
<Text style={{textDecorationLine: 'underline'}}>underline</Text> and{' '}
942+
<Text style={{textDecorationLine: 'line-through'}}>
943+
line-through
944+
</Text>{' '}
945+
text nodes
946+
</Text>
947+
<Text>
948+
Unstyled text{' '}
949+
<Text
950+
style={{
951+
textDecorationLine: 'line-through',
952+
textDecorationStyle: 'wavy',
953+
textDecorationColor: 'red',
954+
}}>
955+
with wavy strike-through that wraps across multiple lines to
956+
verify continuity
957+
</Text>{' '}
958+
normal text
918959
</Text>
919960
</View>
920961
);

0 commit comments

Comments
 (0)