Skip to content

fix(android): honor textDecorationColor on Text decorations#56767

Open
quantizor wants to merge 4 commits into
facebook:mainfrom
quantizor:fix-text-decoration-color-android
Open

fix(android): honor textDecorationColor on Text decorations#56767
quantizor wants to merge 4 commits into
facebook:mainfrom
quantizor:fix-text-decoration-color-android

Conversation

@quantizor
Copy link
Copy Markdown

@quantizor quantizor commented May 11, 2026

Summary:

textDecorationColor is declared on TextStyleAndroid in the public types but has no visible effect on Android: the underline (and strikethrough) always paint in the text's foreground color. iOS honors color correctly via NSUnderlineColorAttributeName. This PR closes the Android gap.

Android's Layout.draw paints the underline produced by setUnderlineText(true) using paint.color, ignoring paint.underlineColor on every API level. The same applies to strikethrough. ReactUnderlineSpan and ReactStrikethroughSpan now extend DrawCommandSpan and paint the decoration themselves in onDraw via Canvas.drawLine, so the requested color actually reaches the paint. The color is threaded through TextAttributeProps (both MapBuffer and ReadableMap ingestion paths) and TextLayoutManager, falling back to the text color when no color is specified.

ReactTextView.onDraw invokes DrawCommandSpan.onDraw after super.onDraw, mirroring what PreparedLayoutTextView.onDraw already did. Without this, the new spans have no effect on the older view class, which is what some Text components on the new architecture still route through.

Resolves the long-standing #4579 (filed 2015), which was closed but never actually fixed at the platform level.

Companion PRs (independent, also targeting main):

Changelog:

[ANDROID] [FIXED] - Text underlines and strikethroughs honor textDecorationColor

Test Plan:

Render a <Text> component with textDecorationLine set to "underline" or "line-through" and textDecorationColor set to a value distinct from the foreground color. On stock 0.85.2 the decoration renders in the text color; with this patch the decoration renders in the specified color. Verified on Android API 36 emulator across single-line and wrapped multi-line cases.

<Text style={{
  color: 'black',
  textDecorationLine: 'underline',
  textDecorationColor: '#ff00aa',
}}>
  Hello
</Text>

quantizor added 2 commits May 11, 2026 10:07
Android's `Layout.draw` paints the underline produced by
`setUnderlineText(true)` using `paint.color`, ignoring
`paint.underlineColor` on all API levels. This caused
`textDecorationColor` to be silently dropped on Android.

Refactor `ReactUnderlineSpan` to extend `DrawCommandSpan` and paint the
underline itself in `onDraw`, falling back to the text color when no
color was specified. Thread the color through `TextAttributeProps` (both
MapBuffer and ReadableMap ingestion paths) and `TextLayoutManager`. Add
`DrawCommandSpan` invocation to `ReactTextView.onDraw`, mirroring the
existing `PreparedLayoutTextView` behavior so both text view classes
honor custom-drawing spans.

## Changelog

[ANDROID] [FIXED] - Text underlines honor `textDecorationColor`

## Test Plan

Render a Text component with `textDecorationColor` set to a value
distinct from the text color; the underline now renders in the specified
color rather than the text color. Verified on Android API 36 emulator.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 11, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label May 11, 2026
@meta-codesync
Copy link
Copy Markdown

meta-codesync Bot commented May 11, 2026

@fabriziocucci has imported this pull request. If you are a Meta employee, you can view this in D104669839.

@fabriziocucci
Copy link
Copy Markdown
Contributor

Hey @quantizor , there's a potential issue we might need to double-check before we land.

The new canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop()) block in ReactTextView.onDraw doesn't replicate the voffsetText that TextView.onDraw adds when vertical gravity isn't TOP. Since RN exposes textAlignVertical (which maps to gravity), the underline/strikethrough should drift away from the text whenever textAlignVertical is center or bottom and the text is taller than its content.

Could you try this repro and see if the underline still tracks the text?

<View style={{ height: 200, backgroundColor: '#eee' }}>
  <Text
    style={{
      height: 200,
      textAlignVertical: 'center', // also try 'bottom'
      color: 'black',
      textDecorationLine: 'underline',
      textDecorationColor: '#ff00aa',
      fontSize: 24,
    }}>
    Hello
  </Text>
</View>

If the magenta underline appears at the top of the gray box while "Hello" is centered/at the bottom, we'll need to add the gravity offset back in. Roughly:

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.translate(getCompoundPaddingLeft(), getExtendedPaddingTop() + voffsetText);

(getVerticalOffset is private in TextView, so we have to recompute it.)

PreparedLayoutTextView doesn't have this problem because it owns its own draw and uses preparedLayout.verticalOffset.

The DrawCommandSpan draw block in `ReactTextView.onDraw` translated by
`getCompoundPaddingLeft(), getExtendedPaddingTop()` only, which works
for the default `Gravity.TOP` text alignment. When `textAlignVertical`
is `center` or `bottom` and the text is shorter than the view, the
glyphs are positioned by `super.onDraw` with an additional gravity
offset that the span draws were missing — so colored
underline/strikethrough rendered at the top of the view while the text
itself was vertically centered or bottom-aligned.

Recompute the offset locally (mirroring TextView.getVerticalOffset(),
which is private upstream) and add it to the translate before invoking
the span draws.

## Changelog

[ANDROID] [FIXED] - Custom text decorations track `textAlignVertical`

## Test Plan

Render a `<Text style={{ height: 200, textAlignVertical: 'center', textDecorationLine: 'underline', textDecorationColor: '#ff00aa', fontSize: 24 }}>Hello</Text>` inside a fixed-height parent and verify the magenta underline sits flush under "Hello" in the vertical center. Repeat with `textAlignVertical: 'bottom'`. Both now track the text; previously the underline stayed at the top of the box. Verified on Android API 36 emulator.
@quantizor
Copy link
Copy Markdown
Author

Good catch, @fabriziocucci. Confirmed the drift on Android API 36 with a tall <Text height={120} textAlignVertical="center">: the magenta underline sat pinned to the top of the box while "Hello" slid to the middle. Pushed 7e00154 with the gravity offset recomputed locally, since getVerticalOffset() is private. The span draws now track the glyphs in all three alignments.

@github-actions
Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

@quantizor
Copy link
Copy Markdown
Author

False positive: the PR diff is five Android Kotlin/Java files only, no .d.ts change. The bot looks to have matched something outside the actual file list.

Upstream facebook#56705 renamed DrawCommandSpan and dropped its `ReactSpan` marker; re-add the interface on the underline/strikethrough subclasses so `SetSpanOperation` still accepts them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants