From 2a6855220f8328fe200c09ab825552da23a1c8be Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 11 May 2026 10:09:05 -0400 Subject: [PATCH 1/2] 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). ## Changelog: [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 ## Test Plan: 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 ``` --- .../renderer/attributedstring/conversions.h | 4 + .../renderer/attributedstring/primitives.h | 2 +- .../RCTAttributedTextUtils.h | 11 ++ .../RCTAttributedTextUtils.mm | 57 +++++--- .../textlayoutmanager/RCTTextLayoutManager.mm | 131 ++++++++++++++++++ .../RCTTextPrimitivesConversions.h | 12 +- 6 files changed, 197 insertions(+), 20 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h index 331e2019338a..faa09c1dbffa 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -916,6 +916,8 @@ inline void fromRawValue(const PropsParserContext &context, const RawValue &valu result = TextDecorationStyle::Dotted; } else if (string == "dashed") { result = TextDecorationStyle::Dashed; + } else if (string == "wavy") { + result = TextDecorationStyle::Wavy; } else { LOG(ERROR) << "Unsupported TextDecorationStyle value: " << string; react_native_expect(false); @@ -941,6 +943,8 @@ inline std::string toString(const TextDecorationStyle &textDecorationStyle) return "dotted"; case TextDecorationStyle::Dashed: return "dashed"; + case TextDecorationStyle::Wavy: + return "wavy"; } LOG(ERROR) << "Unsupported TextDecorationStyle value"; diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/primitives.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/primitives.h index d23104518c74..459bbcf632c2 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/primitives.h @@ -134,7 +134,7 @@ enum class LineBreakMode { enum class TextDecorationLineType { None, Underline, Strikethrough, UnderlineStrikethrough }; -enum class TextDecorationStyle { Solid, Double, Dotted, Dashed }; +enum class TextDecorationStyle { Solid, Double, Dotted, Dashed, Wavy }; enum class TextTransform { None, 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 1687206f1c29..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,10 +106,18 @@ 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: + return NSUnderlineStyleSingle; } } From c88d139dd188cff4bd63f2da47c788806ae34912 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 11 May 2026 13:04:59 -0400 Subject: [PATCH 2/2] chore(cxx-api): regenerate snapshots for TextDecorationStyle::Wavy The `feat(ios): honor textDecorationStyle` commit added `Wavy` to the shared `facebook::react::TextDecorationStyle` enum but the C++ API snapshots under `scripts/cxx-api/api-snapshots/` weren't regenerated. CI's `validate_cxx_api_snapshots` job flagged the divergence across all six snapshot files (Android Debug/Release, Apple Debug/Release, Common Debug/Release). No code or behavior change. --- scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api | 1 + 6 files changed, 6 insertions(+) diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 54d994781de4..035b05379bb3 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -6505,6 +6505,7 @@ enum facebook::react::TextDecorationStyle { Dotted, Double, Solid, + Wavy, } enum facebook::react::TextTransform { diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 6a0a48343467..c06e0d7e76d1 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -6496,6 +6496,7 @@ enum facebook::react::TextDecorationStyle { Dotted, Double, Solid, + Wavy, } enum facebook::react::TextTransform { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index bb3a74b3522e..402e690327c0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -9073,6 +9073,7 @@ enum facebook::react::TextDecorationStyle { Dotted, Double, Solid, + Wavy, } enum facebook::react::TextInputAccessoryVisibilityMode { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 53f03430fc4b..78421dd8cee9 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -9064,6 +9064,7 @@ enum facebook::react::TextDecorationStyle { Dotted, Double, Solid, + Wavy, } enum facebook::react::TextInputAccessoryVisibilityMode { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index bcfb19940fae..bfe05b44d040 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -4856,6 +4856,7 @@ enum facebook::react::TextDecorationStyle { Dotted, Double, Solid, + Wavy, } enum facebook::react::TextTransform { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 53c14ca18d22..299bfc88a95e 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -4847,6 +4847,7 @@ enum facebook::react::TextDecorationStyle { Dotted, Double, Solid, + Wavy, } enum facebook::react::TextTransform {