diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index a23e1d3d59db..6c3e4b8238a8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -46,6 +46,7 @@ import com.facebook.react.uimanager.style.BorderStyle; import com.facebook.react.uimanager.style.LogicalEdge; import com.facebook.react.uimanager.style.Overflow; +import com.facebook.react.views.text.internal.span.DrawCommandSpan; import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan; import com.facebook.react.views.text.internal.span.ReactTagSpan; import com.facebook.yoga.YogaMeasureMode; @@ -213,6 +214,24 @@ protected void onDraw(Canvas canvas) { } super.onDraw(canvas); + + if (spanned != null) { + Layout layout = getLayout(); + if (layout != null) { + DrawCommandSpan[] drawSpans = + spanned.getSpans(0, spanned.length(), DrawCommandSpan.class); + if (drawSpans.length > 0) { + canvas.save(); + canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop()); + for (DrawCommandSpan span : drawSpans) { + int start = spanned.getSpanStart(span); + int end = spanned.getSpanEnd(span); + span.onDraw(start, end, canvas, layout); + } + canvas.restore(); + } + } + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt index 45c8bec4c2b8..2fbf5dbbd02e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt @@ -92,6 +92,22 @@ public class TextAttributeProps private constructor() { public var isLineThroughTextDecorationSet: Boolean = false private set + /** + * Underline color. `Color.TRANSPARENT` (the default) means "fall back to + * the text color" so existing call sites that don't pass a value retain + * the prior behavior. Honored by `ReactUnderlineSpan` on API 29+. + */ + public var textDecorationColor: Int = android.graphics.Color.TRANSPARENT + private set + + /** + * CSS `text-decoration-style`. Defaults to `SOLID` so existing call + * sites retain the prior visual behavior. Honored by + * `ReactUnderlineSpan` and `ReactStrikethroughSpan`. + */ + internal var textDecorationStyle: TextDecorationStyle = TextDecorationStyle.SOLID + private set + private var includeFontPadding: Boolean = true public var accessibilityRole: AccessibilityRole? = null @@ -415,9 +431,10 @@ public class TextAttributeProps private constructor() { TA_KEY_LINE_HEIGHT -> result.lineHeight = entry.doubleValue.toFloat() TA_KEY_ALIGNMENT -> {} TA_KEY_BEST_WRITING_DIRECTION -> {} - TA_KEY_TEXT_DECORATION_COLOR -> {} + TA_KEY_TEXT_DECORATION_COLOR -> result.textDecorationColor = entry.intValue TA_KEY_TEXT_DECORATION_LINE -> result.setTextDecorationLine(entry.stringValue) - TA_KEY_TEXT_DECORATION_STYLE -> {} + TA_KEY_TEXT_DECORATION_STYLE -> + result.textDecorationStyle = TextDecorationStyle.fromString(entry.stringValue) TA_KEY_TEXT_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat() TA_KEY_TEXT_SHADOW_COLOR -> result.textShadowColor = entry.intValue TA_KEY_TEXT_SHADOW_OFFSET_DX -> result.textShadowOffsetDx = entry.doubleValue.toFloat() @@ -462,6 +479,10 @@ public class TextAttributeProps private constructor() { result.setFontVariant(getArrayProp(props, ViewProps.FONT_VARIANT)) result.includeFontPadding = getBooleanProp(props, ViewProps.INCLUDE_FONT_PADDING, true) result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE)) + result.textDecorationColor = + getIntProp(props, "textDecorationColor", android.graphics.Color.TRANSPARENT) + result.textDecorationStyle = + TextDecorationStyle.fromString(getStringProp(props, "textDecorationStyle")) result.setTextShadowOffset( if (props.hasKey(PROP_SHADOW_OFFSET)) props.getMap(PROP_SHADOW_OFFSET) else null ) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt new file mode 100644 index 000000000000..ec7fdfcc15ff --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.os.Build +import android.text.Layout +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Styles supported by the CSS `text-decoration-style` property, surfaced + * end-to-end by Fabric (see `TextAttributes::textDecorationStyle`). + */ +internal enum class TextDecorationStyle { + SOLID, + DOUBLE, + DOTTED, + DASHED, + WAVY; + + internal companion object { + @JvmStatic + fun fromString(value: String?): TextDecorationStyle = + when (value) { + "double" -> DOUBLE + "dotted" -> DOTTED + "dashed" -> DASHED + "wavy" -> WAVY + else -> SOLID + } + } +} + +/** + * Draws a horizontal decoration line between `x1` and `x2` at `y`, + * applying the requested CSS `text-decoration-style`. The caller is + * expected to have already configured `paint.color`, `paint.strokeWidth`, + * `paint.style = STROKE`, and `paint.isAntiAlias = true`, and to restore + * those after the call returns. The `paint.pathEffect` is saved and + * restored internally because dotted/dashed need to set it temporarily. + * + * Constants match Chromium/Blink's decoration_line_painter.cc so the + * visual rendering is consistent with what users see in Chrome on + * Android: + * - DOUBLE: center-to-center distance is `thickness + 1`. + * - WAVY: wavelength = `1 + 2 * round(2 * thickness + 0.5)`, + * controlPointDistance = `0.5 + round(3 * thickness + 0.5)`. + * One cubic Bezier per wavelength with both control points at the + * midpoint, one above and one below the y-axis. + */ +internal fun drawDecorationLine( + canvas: Canvas, + paint: Paint, + x1: Float, + x2: Float, + y: Float, + thickness: Float, + style: TextDecorationStyle, +) { + when (style) { + TextDecorationStyle.SOLID -> canvas.drawLine(x1, y, x2, y, paint) + TextDecorationStyle.DOUBLE -> { + // Center-to-center distance such that the visible gap between the + // top and bottom strokes (= gap - thickness) is 2 px regardless of + // stroke width. Blink renders with a 1 px gap, but with + // antialiasing that often reads as a single fat line; the wider + // gap keeps both strokes legible. + val gap = thickness + 2f + canvas.drawLine(x1, y, x2, y, paint) + canvas.drawLine(x1, y + gap, x2, y + gap, paint) + } + TextDecorationStyle.DOTTED, + TextDecorationStyle.DASHED -> { + val intervals = + if (style == TextDecorationStyle.DOTTED) floatArrayOf(thickness, thickness * 2f) + else floatArrayOf(thickness * 4f, thickness * 2f) + val savedEffect = paint.pathEffect + paint.pathEffect = DashPathEffect(intervals, 0f) + // `Canvas.drawLine` ignores `pathEffect`; draw the line as a Path + // so the dash intervals are honored. + val path = Path() + path.moveTo(x1, y) + path.lineTo(x2, y) + canvas.drawPath(path, paint) + paint.pathEffect = savedEffect + } + TextDecorationStyle.WAVY -> { + val clamped = max(1f, thickness) + val wavelength = 1f + 2f * (2f * clamped + 0.5f).roundToInt() + val cpDistance = 0.5f + (3f * clamped + 0.5f).roundToInt() + val path = Path() + path.moveTo(x1, y) + var x = x1 + // Loop while `x < x2` (not `x + wavelength <= x2`) so the wave + // continues through the final character (including trailing + // punctuation). The last cycle may extend a hair past the run, + // which reads as a natural underline trailer. + while (x < x2) { + val midX = x + wavelength / 2f + val endX = x + wavelength + // Two control points at the midpoint, one above (y - cp) and + // one below (y + cp). Produces an oscillating S-curve per + // wavelength, matching Chromium/Blink's wavy underline. + path.cubicTo(midX, y + cpDistance, midX, y - cpDistance, endX, y) + x = endX + } + canvas.drawPath(path, paint) + } + } +} + +/** + * Shared decoration drawing entry point used by [ReactUnderlineSpan] and + * [ReactStrikethroughSpan]. Computes a density-aware stroke thickness, + * sets up the paint, iterates the visible lines of the run, and delegates + * each line to [drawDecorationLine]. The caller-supplied [yOffsetForLine] + * computes the vertical position of the decoration line on each visible + * line of text (underline vs strikethrough being the only difference). + */ +internal inline fun drawSpannedDecoration( + start: Int, + end: Int, + canvas: Canvas, + layout: Layout, + color: Int, + style: TextDecorationStyle, + yOffsetForLine: (paint: Paint, baseline: Float, thickness: Float) -> Float, +) { + val paint = layout.paint + val savedColor = paint.color + val savedStrokeWidth = paint.strokeWidth + val savedStyle = paint.style + val savedAntiAlias = paint.isAntiAlias + val effectiveColor = if (color != Color.TRANSPARENT) color else savedColor + // Density-aware minimum so the decoration reads consistently across + // display densities (`paint.density` is the px-per-dp ratio). + val minThickness = 1.5f * paint.density + val thickness = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + max(paint.underlineThickness, minThickness) + } else { + max(paint.fontMetrics.descent * 0.1f, minThickness) + } + + paint.color = effectiveColor + paint.strokeWidth = thickness + paint.style = Paint.Style.STROKE + paint.isAntiAlias = true + + val startLine = layout.getLineForOffset(start) + val endLine = layout.getLineForOffset(end) + for (line in startLine..endLine) { + val baseline = layout.getLineBaseline(line).toFloat() + val x1 = + if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line) + val x2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line) + val y = yOffsetForLine(paint, baseline, thickness) + drawDecorationLine(canvas, paint, x1, x2, y, thickness, style) + } + + paint.color = savedColor + paint.strokeWidth = savedStrokeWidth + paint.style = savedStyle + paint.isAntiAlias = savedAntiAlias +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index eacec697670d..41a286b847d1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -315,10 +315,10 @@ internal object TextLayoutManager { ) } if (textAttributes.isUnderlineTextDecorationSet) { - ops.add(SetSpanOperation(start, end, ReactUnderlineSpan())) + ops.add(SetSpanOperation(start, end, ReactUnderlineSpan(textAttributes.textDecorationColor, textAttributes.textDecorationStyle))) } if (textAttributes.isLineThroughTextDecorationSet) { - ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan())) + ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan(textAttributes.textDecorationColor, textAttributes.textDecorationStyle))) } if ( (textAttributes.textShadowOffsetDx != 0f || @@ -494,11 +494,11 @@ internal object TextLayoutManager { } if (fragment.props.isUnderlineTextDecorationSet) { - spannable.setSpan(ReactUnderlineSpan(), start, end, spanFlags) + spannable.setSpan(ReactUnderlineSpan(fragment.props.textDecorationColor, fragment.props.textDecorationStyle), start, end, spanFlags) } if (fragment.props.isLineThroughTextDecorationSet) { - spannable.setSpan(ReactStrikethroughSpan(), start, end, spanFlags) + spannable.setSpan(ReactStrikethroughSpan(fragment.props.textDecorationColor, fragment.props.textDecorationStyle), start, end, spanFlags) } if ( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactStrikethroughSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactStrikethroughSpan.kt index 56cf633de69f..1004591604f7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactStrikethroughSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactStrikethroughSpan.kt @@ -7,7 +7,35 @@ package com.facebook.react.views.text.internal.span -import android.text.style.StrikethroughSpan +import android.graphics.Canvas +import android.graphics.Color +import android.text.Layout +import com.facebook.react.views.text.TextDecorationStyle +import com.facebook.react.views.text.drawSpannedDecoration -/** Wraps [StrikethroughSpan] as a [ReactSpan]. */ -internal class ReactStrikethroughSpan : StrikethroughSpan(), ReactSpan +/** + * Draws a strikethrough whose color and style may differ from the text. + * The line is painted in `onDraw` after the layout renders its text. We + * do NOT extend [android.text.style.StrikethroughSpan] here: the + * framework's `Layout.draw` paints the strikethrough using `paint.color` + * with no field to override, so painting it ourselves is the only way to + * get a distinct color or non-solid style. + * + * `color == Color.TRANSPARENT` falls back to the text foreground color. + */ +internal class ReactStrikethroughSpan( + private val color: Int = Color.TRANSPARENT, + private val style: TextDecorationStyle = TextDecorationStyle.SOLID, +) : DrawCommandSpan() { + + override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) { + drawSpannedDecoration(start, end, canvas, layout, color, style) { paint, baseline, _ -> + // Strikethrough sits near the x-height midline. `fontMetrics.ascent` + // is negative and `descent` is positive, so the sum / 2 gives a + // small negative offset from the baseline; the trailing `+ 1f` + // nudges it down to match the visual position users expect. + val fm = paint.fontMetrics + baseline + (fm.ascent + fm.descent) / 2f + 1f + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactUnderlineSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactUnderlineSpan.kt index 4cdc7c17978c..339b48fdf429 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactUnderlineSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactUnderlineSpan.kt @@ -7,7 +7,31 @@ package com.facebook.react.views.text.internal.span -import android.text.style.UnderlineSpan +import android.graphics.Canvas +import android.graphics.Color +import android.text.Layout +import com.facebook.react.views.text.TextDecorationStyle +import com.facebook.react.views.text.drawSpannedDecoration -/** Wraps [UnderlineSpan] as a [ReactSpan]. */ -internal class ReactUnderlineSpan : UnderlineSpan(), ReactSpan +/** + * Draws an underline whose color and style may differ from the text. The + * underline is painted in `onDraw` (after the layout renders its text) so + * it lands on top of any descenders. We do NOT extend + * [android.text.style.UnderlineSpan] here: the framework's `Layout.draw` + * reads `paint.color` for underline color regardless of + * `paint.underlineColor`, so painting it ourselves is the only way to get + * a distinct color or non-solid style. + * + * `color == Color.TRANSPARENT` falls back to the text foreground color. + */ +internal class ReactUnderlineSpan( + private val color: Int = Color.TRANSPARENT, + private val style: TextDecorationStyle = TextDecorationStyle.SOLID, +) : DrawCommandSpan() { + + override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) { + drawSpannedDecoration(start, end, canvas, layout, color, style) { _, baseline, thickness -> + baseline + thickness + 1f + } + } +} 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; } }