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..22b509428e87 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.CanvasEffectSpan; 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) { + CanvasEffectSpan[] drawSpans = + spanned.getSpans(0, spanned.length(), CanvasEffectSpan.class); + if (drawSpans.length > 0) { + canvas.save(); + canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop()); + for (CanvasEffectSpan 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..48364cd7b059 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, +) : CanvasEffectSpan(), ReactSpan { + + 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..28f3b54e3a13 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, +) : CanvasEffectSpan(), ReactSpan { + + 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/RCTTextPrimitivesConversions.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h index 1687206f1c29..5c6d1c99f2a0 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,8 @@ inline static NSUnderlineStyle RCTNSUnderlineStyleFromTextDecorationStyle( return NSUnderlineStylePatternDash | NSUnderlineStyleSingle; case facebook::react::TextDecorationStyle::Dotted: return NSUnderlineStylePatternDot | 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 {