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; } } 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 {