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..ab407446772f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt @@ -0,0 +1,117 @@ +/* + * 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.DashPathEffect +import android.graphics.Paint +import android.graphics.Path +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() + Log.d( + "ReactWavyDecoration", + "wavelength=$wavelength cpDistance=$cpDistance thickness=$thickness x1=$x1 x2=$x2 y=$y") + val path = Path() + path.moveTo(x1, y) + var x = x1 + while (x + wavelength <= x2) { + val cp1x = x + wavelength / 2f + val cp2x = 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(cp1x, y + cpDistance, cp2x, y - cpDistance, endX, y) + x = endX + } + canvas.drawPath(path, paint) + } + } +} 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..0b5886ea379c 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,80 @@ package com.facebook.react.views.text.internal.span -import android.text.style.StrikethroughSpan +import android.graphics.Canvas +import android.graphics.Color +import android.os.Build +import android.text.Layout +import com.facebook.react.views.text.TextDecorationStyle +import com.facebook.react.views.text.drawDecorationLine +import kotlin.math.max -/** Wraps [StrikethroughSpan] as a [ReactSpan]. */ -internal class ReactStrikethroughSpan : StrikethroughSpan(), ReactSpan +/** + * Draws a strikethrough whose color may differ from the text color and + * whose stroke style may be `solid`, `double`, `dotted`, or `dashed`. + * Subclasses [DrawCommandSpan] so [PreparedLayoutTextView] and + * [ReactTextView] invoke [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 the only way to get a distinct color (or + * style) is to draw it ourselves. + * + * When [color] is [Color.TRANSPARENT] (the default when no + * `textDecorationColor` prop was passed), the strikethrough is drawn in + * the text's foreground color, matching the platform's prior behavior. + */ +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) { + 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 strikethrough reads consistently + // across display densities. `paint.density` is the px-per-dp ratio + // at the current paint setup, so `1.5f * paint.density` gives ~1.5 dp. + 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 = android.graphics.Paint.Style.STROKE + paint.isAntiAlias = true + + // Position the strikethrough slightly below the midpoint between + // the line's top and baseline so it sits near the x-height midline + // like the platform default. `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 + val offset = (fm.ascent + fm.descent) / 2f + 1f + + 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 = baseline + offset + 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/internal/span/ReactUnderlineSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactUnderlineSpan.kt index 4cdc7c17978c..1e9d59ae5199 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,72 @@ package com.facebook.react.views.text.internal.span -import android.text.style.UnderlineSpan +import android.graphics.Canvas +import android.graphics.Color +import android.os.Build +import android.text.Layout +import com.facebook.react.views.text.TextDecorationStyle +import com.facebook.react.views.text.drawDecorationLine +import kotlin.math.max -/** Wraps [UnderlineSpan] as a [ReactSpan]. */ -internal class ReactUnderlineSpan : UnderlineSpan(), ReactSpan +/** + * Draws an underline whose color may differ from the text color and + * whose stroke style may be `solid`, `double`, `dotted`, or `dashed`. + * Subclasses [DrawCommandSpan] so [PreparedLayoutTextView] invokes + * [onDraw] after the layout renders its text, ensuring the underline + * paints on top of any descenders. We do NOT extend + * [android.text.style.UnderlineSpan] here: the framework's `Layout.draw` + * reads `paint.color` for the underline color regardless of + * `paint.underlineColor`, so the only way to get a distinct underline + * color (or style) is to draw it ourselves. + * + * When [color] is [Color.TRANSPARENT] (the default when no + * `textDecorationColor` prop was passed), the underline is drawn in the + * text's foreground color, matching the platform's prior behavior. + */ +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) { + 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 underline reads consistently across + // display densities. `paint.density` is the px-per-dp ratio at the + // current paint setup, so `1.5f * paint.density` gives ~1.5 dp. + 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 = android.graphics.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 = baseline + thickness + 1f + 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/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..524ea0518393 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,16 @@ NSString *const RCTAttributedStringEventEmitterKey = @"EventEmitter"; // String representation of either `role` or `accessibilityRole` NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"AccessibilityRole"; +// Custom attribute key for ranges that should render a wavy decoration line. +// UIKit's `NSUnderlineStyle` enum has no native wavy value, so we suppress the +// framework-drawn underline / strikethrough for these ranges and paint the +// wave ourselves in `RCTTextLayoutManager`'s drawing pass using WebKit's +// formula (`controlPointDistance = fontSize * 1.5 / 16`, `step = fontSize / 4.5`). +// Stored as an NSDictionary with @"line" -> @"underline" or @"line-through" +// and @"color" -> UIColor (the decoration color, falling back to the +// foreground color when no `textDecorationColor` was specified). +NSString *const RCTWavyDecorationAttributeName = @"RCTWavyDecoration"; + /* * 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..7076524f4587 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,45 @@ 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); + if (textDecorationStyleValue == TextDecorationStyle::Wavy) { + // UIKit's `NSUnderlineStyle` has no native wavy. Suppress the + // framework-drawn line and tag the range so `RCTTextLayoutManager` + // can paint a WebKit-style wavy stroke in its drawing pass. + 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"]; + } + attributes[RCTWavyDecorationAttributeName] = @{@"lines" : lines, @"color" : strokeColor ?: [UIColor labelColor]}; + } 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..1a434f54814f 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,102 @@ - (void)drawAttributedString:(AttributedString)attributedString CGContextRestoreGState(context); #endif + // Wavy decoration pass: enumerate `RCTWavyDecorationAttributeName` ranges + // and paint each one ourselves using WebKit's cubic-Bezier wave (UIKit's + // `NSUnderlineStyle` has no native wavy value). + { + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (ctx != nullptr) { + NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr]; + [textStorage + enumerateAttribute:RCTWavyDecorationAttributeName + 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"]; + UIFont *font = [textStorage attribute:NSFontAttributeName + atIndex:attrRange.location + effectiveRange:nullptr]; + if (font == nil || strokeColor == nil) { + return; + } + + CGFloat fontSize = font.pointSize; + // WebKit constants from Source/WebCore/style/InlineTextBoxStyle.cpp: + // controlPointDistance = fontSize * 1.5 / 16 + // step = fontSize / 4.5 (half-wavelength) + CGFloat cpDistance = fontSize * 1.5f / 16.0f; + CGFloat step = fontSize / 4.5f; + CGFloat wavelength = 2.0f * step; + CGFloat thickness = MAX(fontSize / 16.0f, 1.0f); + + NSRange wavyGlyphRange = [layoutManager glyphRangeForCharacterRange:attrRange + actualCharacterRange:nullptr]; + + CGContextSaveGState(ctx); + CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor); + CGContextSetLineWidth(ctx, thickness); + CGContextSetLineCap(ctx, kCGLineCapRound); + CGContextSetShouldAntialias(ctx, YES); + + [layoutManager + enumerateLineFragmentsForGlyphRange:wavyGlyphRange + usingBlock:^( + CGRect lineRect, + __unused CGRect usedRect, + NSTextContainer *_Nonnull container, + NSRange lineGlyphRange, + __unused BOOL *_Nonnull innerStop) { + NSRange intersection = + NSIntersectionRange(wavyGlyphRange, 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 { + // line-through: position near the x-height midline + y = baseline + (font.descender - font.ascender) / 2.0f + 1.0f; + } + CGContextBeginPath(ctx); + CGContextMoveToPoint(ctx, x1, y); + for (CGFloat x = x1; x + wavelength <= x2; x += wavelength) { + CGFloat midX = x + step; + // Two control points at the midpoint, one above and one + // below the y-axis, matching WebKit's wave shape. + CGContextAddCurveToPoint( + ctx, midX, y + cpDistance, midX, y - cpDistance, x + wavelength, 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..3264eebfd7a4 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 @@ -110,6 +110,16 @@ inline static NSUnderlineStyle RCTNSUnderlineStyleFromTextDecorationStyle( return NSUnderlineStylePatternDash | NSUnderlineStyleSingle; case facebook::react::TextDecorationStyle::Dotted: return NSUnderlineStylePatternDot | NSUnderlineStyleSingle; + case facebook::react::TextDecorationStyle::Wavy: + // UIKit's `NSUnderlineStyle` has no native wavy. Wavy ranges are + // tagged with `RCTWavyDecorationAttributeName` in + // `RCTAttributedTextUtils.mm` and painted in + // `RCTTextLayoutManager.mm`'s drawing pass using WebKit's + // `controlPointDistance = fontSize * 1.5 / 16` / + // `step = fontSize / 4.5` formula, so this branch is unreachable + // in normal flow; the `NSUnderlineStyleSingle` here keeps the + // switch exhaustive. + return NSUnderlineStyleSingle; } }