Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *> *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;
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *> *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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -6505,6 +6505,7 @@ enum facebook::react::TextDecorationStyle {
Dotted,
Double,
Solid,
Wavy,
}

enum facebook::react::TextTransform {
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -6496,6 +6496,7 @@ enum facebook::react::TextDecorationStyle {
Dotted,
Double,
Solid,
Wavy,
}

enum facebook::react::TextTransform {
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -9073,6 +9073,7 @@ enum facebook::react::TextDecorationStyle {
Dotted,
Double,
Solid,
Wavy,
}

enum facebook::react::TextInputAccessoryVisibilityMode {
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -9064,6 +9064,7 @@ enum facebook::react::TextDecorationStyle {
Dotted,
Double,
Solid,
Wavy,
}

enum facebook::react::TextInputAccessoryVisibilityMode {
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -4856,6 +4856,7 @@ enum facebook::react::TextDecorationStyle {
Dotted,
Double,
Solid,
Wavy,
}

enum facebook::react::TextTransform {
Expand Down
1 change: 1 addition & 0 deletions scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api
Original file line number Diff line number Diff line change
Expand Up @@ -4847,6 +4847,7 @@ enum facebook::react::TextDecorationStyle {
Dotted,
Double,
Solid,
Wavy,
}

enum facebook::react::TextTransform {
Expand Down