Skip to content

fix(android): track textAlignVertical in custom decoration draw offset#56767

Open
quantizor wants to merge 1 commit into
react:mainfrom
quantizor:fix-text-decoration-color-android
Open

fix(android): track textAlignVertical in custom decoration draw offset#56767
quantizor wants to merge 1 commit into
react:mainfrom
quantizor:fix-text-decoration-color-android

Conversation

@quantizor

@quantizor quantizor commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary:

The textDecorationColor work this PR started as has shipped: #56768 moved underlines and strikethroughs to a custom span-painting path (ReactUnderlineSpan / ReactStrikethroughSpan now extend CanvasEffectSpan and paint their own line), which carries the decoration color through as a side effect and closes #4579. So this PR is now scoped to the one piece that did not come along: keeping those custom decoration draws aligned with textAlignVertical.

ReactTextView.onDraw paints the CanvasEffectSpan decorations in their own pass, translating by getExtendedPaddingTop() only. That matches the default Gravity.TOP, but when textAlignVertical is center or bottom and the text is shorter than the view, super.onDraw shifts the glyphs down by a gravity offset the span draws were missing, so the underline/strikethrough rendered at the top of the box while the text sat centered or at the bottom. This is exactly the drift @fabriziocucci predicted in review.

The offset (mirroring the private TextView.getVerticalOffset()) is now computed by a package-private verticalGravityOffset() helper and applied to both the onPreDraw and onDraw translate passes.

Changelog:

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

Test Plan:

ReactTextViewTest covers verticalGravityOffset across top, center, bottom, exact-fit, and overflow cases (6 cases, all green locally).

Visually, on an Android API 36 emulator: a fixed-height <Text> with textAlignVertical="center" (and "bottom"), textDecorationLine="underline", and a distinct textDecorationColor inside a taller parent now renders the underline flush under the glyphs. Previously the underline stayed pinned to the top of the box while the text moved.

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

@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

meta-codesync Bot commented May 11, 2026

Copy link
Copy Markdown

@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.

@quantizor

Copy link
Copy Markdown
Contributor 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.

@quantizor

Copy link
Copy Markdown
Contributor 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.

@quantizor

Copy link
Copy Markdown
Contributor Author

@fabriziocucci hi, just following up here! Is there anything else I need to do?

@quantizor

Copy link
Copy Markdown
Contributor Author

@fabriziocucci following up, is any further action needed here? What's the typical merge timeline for RN fixes?

meta-codesync Bot pushed a commit that referenced this pull request Jun 4, 2026
Summary:
`textDecorationStyle` is declared on `TextStyleAndroid` in the public types but `TA_KEY_TEXT_DECORATION_STYLE` was a no-op handler: every value silently rendered as a solid line, and `wavy` was additionally rejected at the Fabric C++ enum boundary with an `Unsupported value` log. This PR wires the prop through the existing C++ → Kotlin pipeline and implements `solid`, `double`, `dotted`, `dashed`, and `wavy` for both underlines and strikethroughs.

Android's `Layout.draw` paints the underline produced by `setUnderlineText(true)` using `paint.color` only and offers no native way to draw a dotted / dashed / wavy decoration. `ReactUnderlineSpan` and `ReactStrikethroughSpan` now extend `CanvasEffectSpan` and paint the decoration themselves in `onDraw` via `Canvas.drawLine` / `Canvas.drawPath`, dispatching by style. As a side effect this also makes `textDecorationColor` reach the paint, closing the separate long-standing gap filed as #4579 in 2015 — the companion color-focused PR #56767 isolates that fix for reviewers who only want the color change.

`TextDecorationStyle::Wavy` is added to the Fabric C++ primitives / conversions so the `wavy` JS value flows through; the same enum is shared with iOS (see companion iOS PR #56769).

The wavy curve uses Chromium/Blink's formula from `decoration_line_painter.cc` (`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). The minimum stroke thickness is density-aware (1.5 dp) so decorations read consistently across display densities. The drawing loop iterates `while x < x2` so the final cycle continues through the last character (including trailing punctuation that would otherwise be visually uncovered when the run width is not an integer multiple of the wavelength).

`ReactTextView.onDraw` invokes `CanvasEffectSpan.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.

Companion PRs (independent, also targeting `main`):
- #56767 — fix(android): textDecorationColor on underlines + strikethroughs. Resolves #4579.
- #56769 — feat(ios): textDecorationStyle wavy/dotted/dashed via custom CG paths. Shares the `TextDecorationStyle::Wavy` enum addition; whichever lands first leaves the other with a trivial conflict to resolve.

## Changelog:

[GENERAL] [ADDED] - `textDecorationStyle: 'wavy'` for `<Text>` (see corresponding iOS PR for the iOS counterpart)
[ANDROID] [ADDED] - Text decorations honor `textDecorationStyle` (`solid`, `double`, `dotted`, `dashed`, `wavy`)

Pull Request resolved: #56768

Test Plan:
Rendered `<Text>` components with `textDecorationLine` set to `"underline"` or `"line-through"` and `textDecorationStyle` cycling through `solid` / `double` / `dotted` / `dashed` / `wavy`. On stock 0.85.2 every value renders as a solid line and `wavy` logs an `Unsupported value` warning; with this patch each style renders with the requested stroke geometry. Verified single-line and wrapped multi-line cases on an Android API 36 emulator: each visual line within a wrapped block receives its own correctly-styled decoration that starts and ends at the line's content boundaries.

```tsx
<Text style={{
  color: 'black',
  textDecorationLine: 'underline',
  textDecorationStyle: 'wavy',
}}>
  Hello
</Text>
```

Unit tests added for `TextDecorationStyle.fromString()` covering all five styles plus null, empty, and unknown inputs. Run with `buck2 test //xplat/js/react-native-github/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views:views_text_TextDecorationStyleTestAndroid` — all 8 tests pass.

Fantom integration tests added to `Text-itest.js` verifying all five `textDecorationStyle` values propagate through the Fabric pipeline for both underline, strikethrough, and multi-line wrapped text.

Installed RNTester on a Pixel 8 Pro (Android API 36) and verified all five `textDecorationStyle` values render correctly for both underline and line-through decorations, including multi-line text with inline decoration spanning line breaks. Screenshot: https://pxl.cl/b3c4f

jest-e2e screenshot test updated with new Android screenshot hashes reflecting the updated decoration rendering.

Reviewed By: javache

Differential Revision: D104680895

Pulled By: cortinico

fbshipit-source-id: 7d057326af3809869fdd8394d51aa50ff328ed6f
ReactTextView.onDraw paints CanvasEffectSpan decorations (underline,
strikethrough) in their own pass, translating by getExtendedPaddingTop()
only. That matches the default Gravity.TOP, but when textAlignVertical is
center or bottom and the text is shorter than the view, super.onDraw shifts
the glyphs down by a gravity offset the span draws were missing, so the
decoration rendered at the top of the box while the text sat centered or at
the bottom.

The offset (mirroring the private TextView.getVerticalOffset()) is now
computed by a package-private verticalGravityOffset() helper and applied to
both the onPreDraw and onDraw translate passes.

## Changelog:

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

## Test Plan:

ReactTextViewTest covers verticalGravityOffset across top, center, bottom,
exact-fit, and overflow cases. Visually: a fixed-height <Text> with
textAlignVertical "center" (and "bottom"), textDecorationLine "underline",
and a distinct textDecorationColor inside a taller parent now renders the
decoration flush under the glyphs; previously it stayed pinned to the top of
the box while the text moved. Verified on Android API 36.
@quantizor quantizor force-pushed the fix-text-decoration-color-android branch from bbe2e8c to 21d1669 Compare June 9, 2026 17:30
Copilot AI review requested due to automatic review settings June 9, 2026 17:30
@quantizor quantizor changed the title fix(android): honor textDecorationColor on Text decorations fix(android): track textAlignVertical in custom decoration draw offset Jun 9, 2026
@quantizor

Copy link
Copy Markdown
Contributor Author

Closing the loop here: the color half landed in #56768, which moved underlines and strikethroughs onto the custom CanvasEffectSpan paint path. That carries textDecorationColor through for free and closes #4579, so I've narrowed this PR down to the one thing that didn't come along: the drift you caught.

ReactTextView.onDraw now recomputes the vertical gravity offset (since TextView.getVerticalOffset() is private) and applies it to both span passes, so the decoration tracks the glyphs at center and bottom. Pulled the math into a verticalGravityOffset() helper with ReactTextViewTest covering top/center/bottom/exact-fit/overflow, and retitled the PR to match its new scope.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a shared vertical gravity offset calculation so CanvasEffectSpan decorations (underline/strikethrough) stay aligned with glyphs when textAlignVertical shifts text within a taller ReactTextView.

Changes:

  • Adjust ReactTextView.onDraw to apply the same vertical translation to CanvasEffectSpan drawing passes as the glyph/layout pass.
  • Introduce ReactTextView.verticalGravityOffset(...) mirroring TextView’s internal vertical offset logic.
  • Add Robolectric unit tests for the new offset behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java Adds verticalGravityOffset helper and applies offset to span drawing translations.
packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt New Robolectric tests validating offset behavior for TOP/CENTER/BOTTOM and overflow cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

meta-codesync Bot pushed a commit that referenced this pull request Jun 9, 2026
Summary:
`textDecorationStyle` is declared on `TextStyleIOS` in the public types but `wavy` is silently dropped: Fabric's C++ enum doesn't include `Wavy`, and UIKit's `NSUnderlineStyle` has no native wavy pattern bit. Separately, `dotted` and `dashed` map to `NSUnderlineStylePatternDot` / `NSUnderlineStylePatternDash` which don't match browser geometry on iOS.

This PR adds `TextDecorationStyle::Wavy` to the shared Fabric primitives / conversions (also unblocks the same value on Android, see companion PR #56768) and renders wavy / dotted / dashed decorations with custom Core Graphics paths.

**Implementation:**
- Wavy ranges are tagged with a custom `RCTCustomDecorationAttributeName` (storing the line kinds, stroke color, and style key) in `RCTAttributedTextUtils.mm` and painted by `RCTTextLayoutManager.mm` after `drawGlyphsForGlyphRange:`. Wavy uses an adaptation of WebKit's formula from `Source/WebCore/style/InlineTextBoxStyle.cpp` (`controlPointDistance = thickness * 1.5 + 0.5`, one cubic Bezier per wavelength, control points at the midpoint above and below the y-axis). At iOS point sizes the literal Blink amplitude renders as a very pronounced wave because Core Graphics paints in points (not device pixels), so the constants are dialed back to read as a clear-but-subtle browser-style wave at typical text sizes.
- Dotted uses a custom CG path with a zero-length dash + round line caps, producing actual circular dots at `2 * thickness` spacing.
- Dashed uses a custom CG path with `[2 * thickness, thickness]` intervals — short rectangular dashes with a tight gap, closer to Safari's geometry than UIKit's default.
- Solid and double continue to use UIKit's native `NSUnderlineStyle` pattern bits, so this PR does not touch the long-standing iOS Arial+bold solid-underline rendering bug tracked in #53935.
- The wavy drawing loop iterates `while x < x2` so the final cycle continues through the last character (including trailing punctuation that would otherwise be visually uncovered when the run width is not an integer multiple of the wavelength).

Companion PRs (independent, also targeting `main`):
- #56767 — fix(android): textDecorationColor on underlines + strikethroughs. Resolves #4579 (2015).
- #56768 — feat(android): textDecorationStyle solid/double/dotted/dashed/wavy. Shares the `TextDecorationStyle::Wavy` enum addition; whichever lands first leaves the other with a trivial conflict to resolve.

## Changelog:

[IOS] [ADDED] - `textDecorationStyle: 'wavy'` for `<Text>` (custom CoreGraphics path)
[IOS] [CHANGED] - `textDecorationStyle: 'dotted'` and `'dashed'` for `<Text>` render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely

Pull Request resolved: #56769

Test Plan:
See the screenshot comparisons here:

https://www.internalfb.com/compare-screenshots-from-diff/D104680636

{F1990979243}

----

Side-by-side comparison on iPhone 17 sim (iOS 26.4) of a `<Text>` with `textDecorationLine="underline"` and `textDecorationStyle` cycling through `solid` / `double` / `dotted` / `dashed` / `wavy`, verified against Safari rendering of the same CSS. Trailing periods now fall under the wavy stroke. Verified with `textDecorationColor` set distinct from the foreground color.

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

Reviewed By: cipolleschi

Differential Revision: D104680636

Pulled By: cortinico

fbshipit-source-id: ac96e5b36530f7d243a4b85a67c576b62fe99866
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.

[Android] Issue with textDecoration

3 participants