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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading