Skip to content
Open
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.CanvasEffectSpan;
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) {
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();
}
}
}
}
}

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,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
}
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,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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading