From e0e11b9044894ef448a8ea190f55ee10cf598994 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 11 May 2026 10:09:05 -0400 Subject: [PATCH] feat(ios): honor textDecorationStyle on Text decorations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). [IOS] [ADDED] - `textDecorationStyle: 'wavy'` for `` (custom CG path) [IOS] [CHANGED] - `textDecorationStyle: 'dotted'` and `'dashed'` for `` render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely Side-by-side comparison on iPhone 17 sim (iOS 26.4) of a `` 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 Hello ``` --- .../RCTAttributedTextUtils.h | 11 ++ .../RCTAttributedTextUtils.mm | 57 +++++--- .../textlayoutmanager/RCTTextLayoutManager.mm | 131 ++++++++++++++++++ .../RCTTextPrimitivesConversions.h | 11 +- 4 files changed, 190 insertions(+), 20 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index 902912e6f208..6ceec3a01674 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -19,6 +19,17 @@ NSString *const RCTAttributedStringEventEmitterKey = @"EventEmitter"; // String representation of either `role` or `accessibilityRole` NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"AccessibilityRole"; +// Custom attribute key for ranges whose decoration line cannot be rendered +// faithfully via UIKit's `NSUnderlineStyle` pattern bits (wavy has no native +// equivalent; dotted/dashed don't match the geometry browsers use). These +// ranges are painted by `RCTTextLayoutManager`'s drawing pass. +// +// Stored as an NSDictionary: +// @"lines": NSArray of @"underline" / @"line-through" +// @"color": UIColor stroke color +// @"style": NSString — @"wavy" | @"dotted" | @"dashed" +NSString *const RCTCustomDecorationAttributeName = @"RCTCustomDecoration"; + /* * Creates `NSTextAttributes` from given `facebook::react::TextAttributes` */ diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index f96a0494000b..5953448eba3e 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -240,29 +240,52 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex // Decoration if (textAttributes.textDecorationLineType.value_or(TextDecorationLineType::None) != TextDecorationLineType::None) { auto textDecorationLineType = textAttributes.textDecorationLineType.value(); - - NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle( - textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid)); - + auto textDecorationStyleValue = textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid); UIColor *textDecorationColor = RCTUIColorFromSharedColor(textAttributes.textDecorationColor); - // Underline - if (textDecorationLineType == TextDecorationLineType::Underline || - textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { - attributes[NSUnderlineStyleAttributeName] = @(style); + // Custom drawing for styles UIKit can't render faithfully: wavy (no + // native value), and dotted/dashed (UIKit's pattern bits don't match + // browser geometry). The other styles continue to use NSUnderlineStyle. + bool needsCustomDrawing = textDecorationStyleValue == TextDecorationStyle::Wavy || + textDecorationStyleValue == TextDecorationStyle::Dotted || + textDecorationStyleValue == TextDecorationStyle::Dashed; + if (needsCustomDrawing) { + UIColor *strokeColor = textDecorationColor ?: RCTUIColorFromSharedColor(textAttributes.foregroundColor); + NSMutableArray *lines = [NSMutableArray array]; + if (textDecorationLineType == TextDecorationLineType::Underline || + textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { + [lines addObject:@"underline"]; + } + if (textDecorationLineType == TextDecorationLineType::Strikethrough || + textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { + [lines addObject:@"line-through"]; + } + NSString *styleKey = textDecorationStyleValue == TextDecorationStyle::Wavy + ? @"wavy" + : (textDecorationStyleValue == TextDecorationStyle::Dotted ? @"dotted" : @"dashed"); + attributes[RCTCustomDecorationAttributeName] = + @{@"lines" : lines, @"color" : strokeColor ?: [UIColor labelColor], @"style" : styleKey}; + } else { + NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle(textDecorationStyleValue); + + // Underline + if (textDecorationLineType == TextDecorationLineType::Underline || + textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { + attributes[NSUnderlineStyleAttributeName] = @(style); - if (textDecorationColor) { - attributes[NSUnderlineColorAttributeName] = textDecorationColor; + if (textDecorationColor) { + attributes[NSUnderlineColorAttributeName] = textDecorationColor; + } } - } - // Strikethrough - if (textDecorationLineType == TextDecorationLineType::Strikethrough || - textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { - attributes[NSStrikethroughStyleAttributeName] = @(style); + // Strikethrough + if (textDecorationLineType == TextDecorationLineType::Strikethrough || + textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { + attributes[NSStrikethroughStyleAttributeName] = @(style); - if (textDecorationColor) { - attributes[NSStrikethroughColorAttributeName] = textDecorationColor; + if (textDecorationColor) { + attributes[NSStrikethroughColorAttributeName] = textDecorationColor; + } } } } diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index ac553045a9c0..67e966af1399 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -95,6 +95,137 @@ - (void)drawAttributedString:(AttributedString)attributedString CGContextRestoreGState(context); #endif + // Custom decoration pass: enumerate `RCTCustomDecorationAttributeName` + // ranges and paint each one ourselves. Covers wavy (no UIKit equivalent), + // dotted, and dashed (UIKit's pattern bits don't match browser geometry). + { + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (ctx != nullptr) { + NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr]; + [textStorage + enumerateAttribute:RCTCustomDecorationAttributeName + inRange:charRange + options:0 + usingBlock:^(NSDictionary *_Nullable attrs, NSRange attrRange, __unused BOOL *stop) { + if (attrs == nil) { + return; + } + NSArray *lines = attrs[@"lines"]; + UIColor *strokeColor = attrs[@"color"]; + NSString *style = attrs[@"style"]; + UIFont *font = [textStorage attribute:NSFontAttributeName + atIndex:attrRange.location + effectiveRange:nullptr]; + if (font == nil || strokeColor == nil || style == nil) { + return; + } + + CGFloat fontSize = font.pointSize; + // Thickness scales with the type size so the decoration + // remains visible at small sizes and proportionate at + // large ones. ~`fontSize / 12` plus a 1.5pt floor. + CGFloat thickness = MAX(fontSize / 12.0f, 1.5f); + // Wavelength = Blink's; control-point distance halved + // so the iOS rendering reads as a subtle wave (Blink's + // literal `0.5 + round(3 * t + 0.5)` is too pronounced + // at iOS point sizes since the path is already drawn + // in points, not device pixels). + CGFloat wavyWavelength = 1.0f + 2.0f * round(2.0f * thickness + 0.5f); + CGFloat wavyCpDistance = 0.5f + round(1.5f * thickness + 0.5f); + + NSRange targetGlyphRange = [layoutManager glyphRangeForCharacterRange:attrRange + actualCharacterRange:nullptr]; + + CGContextSaveGState(ctx); + CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor); + CGContextSetLineWidth(ctx, thickness); + CGContextSetShouldAntialias(ctx, YES); + + if ([style isEqualToString:@"dotted"]) { + // Zero-length dash with round caps = circular dots. + // Gap of ~2 * thickness between dot centers. + CGFloat dotIntervals[2] = {0.0f, thickness * 2.0f}; + CGContextSetLineDash(ctx, 0, dotIntervals, 2); + CGContextSetLineCap(ctx, kCGLineCapRound); + } else if ([style isEqualToString:@"dashed"]) { + // Short rectangular dashes with a tight gap. + CGFloat dashIntervals[2] = {thickness * 2.0f, thickness}; + CGContextSetLineDash(ctx, 0, dashIntervals, 2); + CGContextSetLineCap(ctx, kCGLineCapButt); + } else { + // wavy + CGContextSetLineCap(ctx, kCGLineCapRound); + } + + [layoutManager + enumerateLineFragmentsForGlyphRange:targetGlyphRange + usingBlock:^( + CGRect lineRect, + __unused CGRect usedRect, + NSTextContainer *_Nonnull container, + NSRange lineGlyphRange, + __unused BOOL *_Nonnull innerStop) { + NSRange intersection = + NSIntersectionRange(targetGlyphRange, lineGlyphRange); + if (intersection.length == 0) { + return; + } + CGRect firstGlyphRect = + [layoutManager boundingRectForGlyphRange:NSMakeRange( + intersection.location, + 1) + inTextContainer:container]; + CGRect lastGlyphRect = + [layoutManager boundingRectForGlyphRange:NSMakeRange( + NSMaxRange(intersection) - + 1, + 1) + inTextContainer:container]; + CGFloat x1 = firstGlyphRect.origin.x + frame.origin.x; + CGFloat x2 = CGRectGetMaxX(lastGlyphRect) + frame.origin.x; + CGFloat baseline = lineRect.origin.y + font.ascender + frame.origin.y; + + for (NSString *line in lines) { + CGFloat y; + if ([line isEqualToString:@"underline"]) { + y = baseline + thickness + 1.0f; + } else { + y = baseline + (font.descender - font.ascender) / 2.0f + 1.0f; + } + CGContextBeginPath(ctx); + CGContextMoveToPoint(ctx, x1, y); + if ([style isEqualToString:@"wavy"]) { + // Draw enough whole cycles to cover the run. + // Looping while `x < x2` (rather than + // `x + wavelength <= x2`) ensures the wave + // continues through the final character + // (including trailing punctuation) — the last + // cycle may extend a hair past the text bound, + // which reads as a natural underline trailer. + CGFloat step = wavyWavelength / 2.0f; + for (CGFloat x = x1; x < x2; x += wavyWavelength) { + CGFloat midX = x + step; + CGContextAddCurveToPoint( + ctx, + midX, + y + wavyCpDistance, + midX, + y - wavyCpDistance, + x + wavyWavelength, + y); + } + } else { + CGContextAddLineToPoint(ctx, x2, y); + } + CGContextStrokePath(ctx); + } + }]; + + CGContextRestoreGState(ctx); + }]; + } + } + if (block != nil) { __block UIBezierPath *highlightPath = nil; NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h index 0ec5e4c5c776..8166d69f919c 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h @@ -106,12 +106,17 @@ inline static NSUnderlineStyle RCTNSUnderlineStyleFromTextDecorationStyle( return NSUnderlineStyleSingle; case facebook::react::TextDecorationStyle::Double: return NSUnderlineStyleDouble; + // Dotted, dashed, and wavy are tagged with + // `RCTCustomDecorationAttributeName` in `RCTAttributedTextUtils.mm` and + // painted by `RCTTextLayoutManager.mm`'s drawing pass; UIKit's pattern + // bits don't match the geometry browsers use, and there is no native + // wavy value at all. These branches are unreachable in normal flow; the + // returned values keep the switch exhaustive. case facebook::react::TextDecorationStyle::Dashed: - return NSUnderlineStylePatternDash | NSUnderlineStyleSingle; + return NSUnderlineStyleSingle; case facebook::react::TextDecorationStyle::Dotted: - return NSUnderlineStylePatternDot | NSUnderlineStyleSingle; + return NSUnderlineStyleSingle; case facebook::react::TextDecorationStyle::Wavy: - // No native NSUnderlineStyle for wavy; fall back to solid. return NSUnderlineStyleSingle; } }