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,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();
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
)
Expand Down
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)))
}
if (textAttributes.isLineThroughTextDecorationSet) {
ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan()))
ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan(textAttributes.textDecorationColor)))
}
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), start, end, spanFlags)
}

if (fragment.props.isLineThroughTextDecorationSet) {
spannable.setSpan(ReactStrikethroughSpan(), start, end, spanFlags)
spannable.setSpan(ReactStrikethroughSpan(fragment.props.textDecorationColor), start, end, spanFlags)
}

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