fix(android): track textAlignVertical in custom decoration draw offset#56767
fix(android): track textAlignVertical in custom decoration draw offset#56767quantizor wants to merge 1 commit into
Conversation
|
@fabriziocucci has imported this pull request. If you are a Meta employee, you can view this in D104669839. |
|
Hey @quantizor , there's a potential issue we might need to double-check before we land. The new Could you try this repro and see if the underline still tracks the text? 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: (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. |
|
Good catch, @fabriziocucci. Confirmed the drift on Android API 36 with a tall |
|
False positive: the PR diff is five Android Kotlin/Java files only, no |
|
@fabriziocucci hi, just following up here! Is there anything else I need to do? |
|
@fabriziocucci following up, is any further action needed here? What's the typical merge timeline for RN fixes? |
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.
bbe2e8c to
21d1669
Compare
|
Closing the loop here: the color half landed in #56768, which moved underlines and strikethroughs onto the custom
|
There was a problem hiding this comment.
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.onDrawto apply the same vertical translation toCanvasEffectSpandrawing passes as the glyph/layout pass. - Introduce
ReactTextView.verticalGravityOffset(...)mirroringTextView’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.
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
Summary:
The
textDecorationColorwork this PR started as has shipped: #56768 moved underlines and strikethroughs to a custom span-painting path (ReactUnderlineSpan/ReactStrikethroughSpannow extendCanvasEffectSpanand 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 withtextAlignVertical.ReactTextView.onDrawpaints theCanvasEffectSpandecorations in their own pass, translating bygetExtendedPaddingTop()only. That matches the defaultGravity.TOP, but whentextAlignVerticaliscenterorbottomand the text is shorter than the view,super.onDrawshifts 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-privateverticalGravityOffset()helper and applied to both theonPreDrawandonDrawtranslate passes.Changelog:
[ANDROID] [FIXED] - Custom text decorations track
textAlignVerticalTest Plan:
ReactTextViewTestcoversverticalGravityOffsetacross top, center, bottom, exact-fit, and overflow cases (6 cases, all green locally).Visually, on an Android API 36 emulator: a fixed-height
<Text>withtextAlignVertical="center"(and"bottom"),textDecorationLine="underline", and a distincttextDecorationColorinside 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.