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 a23e1d3d59d..5212e22eea7 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,38 @@ 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) { + // TextView.getVerticalOffset() is private; recompute it here. + int voffsetText = 0; + if ((getGravity() & Gravity.VERTICAL_GRAVITY_MASK) != Gravity.TOP) { + int boxHeight = + getMeasuredHeight() - getExtendedPaddingTop() - getExtendedPaddingBottom(); + int textHeight = layout.getHeight(); + if (textHeight < boxHeight) { + int v = getGravity() & Gravity.VERTICAL_GRAVITY_MASK; + voffsetText = + (v == Gravity.BOTTOM) + ? (boxHeight - textHeight) + : (boxHeight - textHeight) / 2; + } + } + canvas.save(); + canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop() + voffsetText); + 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 45c8bec4c2b..9bcd6abedbd 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,14 @@ 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 + private var includeFontPadding: Boolean = true public var accessibilityRole: AccessibilityRole? = null @@ -415,7 +423,7 @@ 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_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat() @@ -462,6 +470,8 @@ 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.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/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index eacec697670..fa1241b9e0a 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))) } if (textAttributes.isLineThroughTextDecorationSet) { - ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan())) + ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan(textAttributes.textDecorationColor))) } 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), start, end, spanFlags) } if (fragment.props.isLineThroughTextDecorationSet) { - spannable.setSpan(ReactStrikethroughSpan(), start, end, spanFlags) + spannable.setSpan(ReactStrikethroughSpan(fragment.props.textDecorationColor), 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 56cf633de69..c99959d8dc4 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,68 @@ 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 kotlin.math.max -/** Wraps [StrikethroughSpan] as a [ReactSpan]. */ -internal class ReactStrikethroughSpan : StrikethroughSpan(), ReactSpan +/** + * Draws a strikethrough whose color may differ from the text color. Subclasses + * [CanvasEffectSpan] 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 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) : + CanvasEffectSpan(), ReactSpan { + + 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 + val thickness = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + max(paint.underlineThickness, 1.5f) + } else { + max(paint.fontMetrics.descent * 0.1f, 1.5f) + } + + paint.color = effectiveColor + paint.strokeWidth = thickness + paint.style = android.graphics.Paint.Style.STROKE + paint.isAntiAlias = true + + // Position the strikethrough at 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. + val fm = paint.fontMetrics + val offset = (fm.ascent + fm.descent) / 2f + + 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 + canvas.drawLine(x1, y, x2, y, paint) + } + + 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 4cdc7c17978..9a41af9e267 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,62 @@ 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 kotlin.math.max -/** Wraps [UnderlineSpan] as a [ReactSpan]. */ -internal class ReactUnderlineSpan : UnderlineSpan(), ReactSpan +/** + * Draws an underline whose color may differ from the text color. Subclasses + * [CanvasEffectSpan] 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 underline color + * regardless of `paint.underlineColor`, so the only way to get a distinct + * underline color 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) : + CanvasEffectSpan(), ReactSpan { + + 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 + val thickness = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + max(paint.underlineThickness, 1.5f) + } else { + max(paint.fontMetrics.descent * 0.1f, 1.5f) + } + + 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 + canvas.drawLine(x1, y, x2, y, paint) + } + + paint.color = savedColor + paint.strokeWidth = savedStrokeWidth + paint.style = savedStyle + paint.isAntiAlias = savedAntiAlias + } +}