Skip to content

Commit 87184c8

Browse files
quantizormeta-codesync[bot]
authored andcommitted
feat(android): honor textDecorationStyle on Text decorations (#56768)
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
1 parent 62903bc commit 87184c8

25 files changed

Lines changed: 726 additions & 29 deletions

File tree

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,13 @@ export type FontVariant =
572572
export interface TextStyleIOS extends ViewStyle {
573573
fontVariant?: FontVariant[] | undefined;
574574
textDecorationColor?: ColorValue | undefined;
575-
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | undefined;
575+
textDecorationStyle?:
576+
| 'solid'
577+
| 'double'
578+
| 'dotted'
579+
| 'dashed'
580+
| 'wavy'
581+
| undefined;
576582
writingDirection?: 'auto' | 'ltr' | 'rtl' | undefined;
577583
}
578584

@@ -634,7 +640,13 @@ export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle {
634640
| 'line-through'
635641
| 'underline line-through'
636642
| undefined;
637-
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | undefined;
643+
textDecorationStyle?:
644+
| 'solid'
645+
| 'double'
646+
| 'dotted'
647+
| 'dashed'
648+
| 'wavy'
649+
| undefined;
638650
textDecorationColor?: ColorValue | undefined;
639651
textShadowColor?: ColorValue | undefined;
640652
textShadowOffset?: {width: number; height: number} | undefined;

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@ type ____TextStyle_InternalBase = Readonly<{
10181018
| 'underline'
10191019
| 'line-through'
10201020
| 'underline line-through',
1021-
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed',
1021+
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy',
10221022
textDecorationColor?: ____ColorValue_Internal,
10231023
textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase',
10241024
userSelect?: 'auto' | 'text' | 'none' | 'contain' | 'all',

packages/react-native/Libraries/Text/__tests__/Text-itest.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,204 @@ describe('<Text>', () => {
311311
});
312312

313313
describe('style', () => {
314+
describe('textDecorationStyle', () => {
315+
it('propagates each style to mounting layer', () => {
316+
const root = Fantom.createRoot();
317+
318+
Fantom.runTask(() => {
319+
root.render(
320+
<Text
321+
style={{
322+
textDecorationLine: 'underline',
323+
textDecorationStyle: 'solid',
324+
}}>
325+
{TEST_TEXT}
326+
</Text>,
327+
);
328+
});
329+
330+
expect(
331+
root
332+
.getRenderedOutput({
333+
props: ['textDecorationLineType', 'textDecorationStyle'],
334+
})
335+
.toJSX(),
336+
).toEqual(
337+
<rn-paragraph
338+
textDecorationLineType="underline"
339+
textDecorationStyle="solid">
340+
{TEST_TEXT}
341+
</rn-paragraph>,
342+
);
343+
344+
Fantom.runTask(() => {
345+
root.render(
346+
<Text
347+
style={{
348+
textDecorationLine: 'underline',
349+
textDecorationStyle: 'double',
350+
}}>
351+
{TEST_TEXT}
352+
</Text>,
353+
);
354+
});
355+
356+
expect(
357+
root
358+
.getRenderedOutput({
359+
props: ['textDecorationLineType', 'textDecorationStyle'],
360+
})
361+
.toJSX(),
362+
).toEqual(
363+
<rn-paragraph
364+
textDecorationLineType="underline"
365+
textDecorationStyle="double">
366+
{TEST_TEXT}
367+
</rn-paragraph>,
368+
);
369+
370+
Fantom.runTask(() => {
371+
root.render(
372+
<Text
373+
style={{
374+
textDecorationLine: 'underline',
375+
textDecorationStyle: 'dotted',
376+
}}>
377+
{TEST_TEXT}
378+
</Text>,
379+
);
380+
});
381+
382+
expect(
383+
root
384+
.getRenderedOutput({
385+
props: ['textDecorationLineType', 'textDecorationStyle'],
386+
})
387+
.toJSX(),
388+
).toEqual(
389+
<rn-paragraph
390+
textDecorationLineType="underline"
391+
textDecorationStyle="dotted">
392+
{TEST_TEXT}
393+
</rn-paragraph>,
394+
);
395+
396+
Fantom.runTask(() => {
397+
root.render(
398+
<Text
399+
style={{
400+
textDecorationLine: 'underline',
401+
textDecorationStyle: 'dashed',
402+
}}>
403+
{TEST_TEXT}
404+
</Text>,
405+
);
406+
});
407+
408+
expect(
409+
root
410+
.getRenderedOutput({
411+
props: ['textDecorationLineType', 'textDecorationStyle'],
412+
})
413+
.toJSX(),
414+
).toEqual(
415+
<rn-paragraph
416+
textDecorationLineType="underline"
417+
textDecorationStyle="dashed">
418+
{TEST_TEXT}
419+
</rn-paragraph>,
420+
);
421+
422+
Fantom.runTask(() => {
423+
root.render(
424+
<Text
425+
style={{
426+
textDecorationLine: 'underline',
427+
textDecorationStyle: 'wavy',
428+
}}>
429+
{TEST_TEXT}
430+
</Text>,
431+
);
432+
});
433+
434+
expect(
435+
root
436+
.getRenderedOutput({
437+
props: ['textDecorationLineType', 'textDecorationStyle'],
438+
})
439+
.toJSX(),
440+
).toEqual(
441+
<rn-paragraph
442+
textDecorationLineType="underline"
443+
textDecorationStyle="wavy">
444+
{TEST_TEXT}
445+
</rn-paragraph>,
446+
);
447+
});
448+
449+
it('works with multi-line wrapped text', () => {
450+
const root = Fantom.createRoot({viewportWidth: 100});
451+
452+
Fantom.runTask(() => {
453+
root.render(
454+
<Text
455+
style={{
456+
textDecorationLine: 'underline',
457+
textDecorationStyle: 'wavy',
458+
}}>
459+
This is a long text that should wrap across multiple lines to
460+
verify decoration continuity
461+
</Text>,
462+
);
463+
});
464+
465+
expect(
466+
root
467+
.getRenderedOutput({
468+
props: ['textDecorationLineType', 'textDecorationStyle'],
469+
})
470+
.toJSX(),
471+
).toEqual(
472+
<rn-paragraph
473+
textDecorationLineType="underline"
474+
textDecorationStyle="wavy">
475+
This is a long text that should wrap across multiple lines to
476+
verify decoration continuity
477+
</rn-paragraph>,
478+
);
479+
});
480+
481+
it('works with line-through', () => {
482+
const root = Fantom.createRoot();
483+
484+
Fantom.runTask(() => {
485+
root.render(
486+
<Text
487+
style={{
488+
textDecorationLine: 'line-through',
489+
textDecorationStyle: 'wavy',
490+
}}>
491+
{TEST_TEXT}
492+
</Text>,
493+
);
494+
});
495+
496+
expect(
497+
root
498+
.getRenderedOutput({
499+
props: ['textDecorationLineType', 'textDecorationStyle'],
500+
})
501+
.toJSX(),
502+
).toEqual(
503+
<rn-paragraph
504+
textDecorationLineType="strikethrough"
505+
textDecorationStyle="wavy">
506+
{TEST_TEXT}
507+
</rn-paragraph>,
508+
);
509+
});
510+
});
511+
314512
describe('writingDirection', () => {
315513
it('propagates to mounting layer', () => {
316514
const root = Fantom.createRoot();

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import com.facebook.react.uimanager.style.BorderStyle;
4747
import com.facebook.react.uimanager.style.LogicalEdge;
4848
import com.facebook.react.uimanager.style.Overflow;
49+
import com.facebook.react.views.text.internal.span.CanvasEffectSpan;
4950
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan;
5051
import com.facebook.react.views.text.internal.span.ReactTagSpan;
5152
import com.facebook.yoga.YogaMeasureMode;
@@ -212,7 +213,40 @@ protected void onDraw(Canvas canvas) {
212213
BackgroundStyleApplicator.clipToPaddingBox(this, canvas);
213214
}
214215

215-
super.onDraw(canvas);
216+
if (spanned != null) {
217+
Layout layout = getLayout();
218+
if (layout != null) {
219+
CanvasEffectSpan[] drawSpans =
220+
spanned.getSpans(0, spanned.length(), CanvasEffectSpan.class);
221+
if (drawSpans.length > 0) {
222+
canvas.save();
223+
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
224+
for (CanvasEffectSpan span : drawSpans) {
225+
int start = spanned.getSpanStart(span);
226+
int end = spanned.getSpanEnd(span);
227+
span.onPreDraw(start, end, canvas, layout);
228+
}
229+
canvas.restore();
230+
231+
super.onDraw(canvas);
232+
233+
canvas.save();
234+
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
235+
for (CanvasEffectSpan span : drawSpans) {
236+
int start = spanned.getSpanStart(span);
237+
int end = spanned.getSpanEnd(span);
238+
span.onDraw(start, end, canvas, layout);
239+
}
240+
canvas.restore();
241+
} else {
242+
super.onDraw(canvas);
243+
}
244+
} else {
245+
super.onDraw(canvas);
246+
}
247+
} else {
248+
super.onDraw(canvas);
249+
}
216250
}
217251
}
218252

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ public class TextAttributeProps private constructor() {
9292
public var isLineThroughTextDecorationSet: Boolean = false
9393
private set
9494

95+
/**
96+
* Decoration color for underlines and strikethroughs. `Color.TRANSPARENT` (the default) means
97+
* "fall back to the text color" so existing call sites that don't pass a value retain the prior
98+
* behavior. Honored by `ReactUnderlineSpan` and `ReactStrikethroughSpan`.
99+
*/
100+
internal var textDecorationColor: Int = android.graphics.Color.TRANSPARENT
101+
private set
102+
103+
/**
104+
* CSS `text-decoration-style`. Defaults to `SOLID` so existing call sites retain the prior visual
105+
* behavior. Honored by `ReactUnderlineSpan` and `ReactStrikethroughSpan`.
106+
*/
107+
internal var textDecorationStyle: TextDecorationStyle = TextDecorationStyle.SOLID
108+
private set
109+
95110
private var includeFontPadding: Boolean = true
96111

97112
public var accessibilityRole: AccessibilityRole? = null
@@ -423,9 +438,10 @@ public class TextAttributeProps private constructor() {
423438
TA_KEY_LINE_HEIGHT -> result.lineHeight = entry.doubleValue.toFloat()
424439
TA_KEY_ALIGNMENT -> {}
425440
TA_KEY_BEST_WRITING_DIRECTION -> {}
426-
TA_KEY_TEXT_DECORATION_COLOR -> {}
441+
TA_KEY_TEXT_DECORATION_COLOR -> result.textDecorationColor = entry.intValue
427442
TA_KEY_TEXT_DECORATION_LINE -> result.setTextDecorationLine(entry.stringValue)
428-
TA_KEY_TEXT_DECORATION_STYLE -> {}
443+
TA_KEY_TEXT_DECORATION_STYLE ->
444+
result.textDecorationStyle = TextDecorationStyle.fromString(entry.stringValue)
429445
TA_KEY_TEXT_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat()
430446
TA_KEY_TEXT_SHADOW_COLOR -> result.textShadowColor = entry.intValue
431447
TA_KEY_TEXT_SHADOW_OFFSET_DX -> result.textShadowOffsetDx = entry.doubleValue.toFloat()
@@ -486,6 +502,10 @@ public class TextAttributeProps private constructor() {
486502
result.setFontVariant(getArrayProp(props, ViewProps.FONT_VARIANT))
487503
result.includeFontPadding = getBooleanProp(props, ViewProps.INCLUDE_FONT_PADDING, true)
488504
result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE))
505+
result.textDecorationColor =
506+
getIntProp(props, "textDecorationColor", android.graphics.Color.TRANSPARENT)
507+
result.textDecorationStyle =
508+
TextDecorationStyle.fromString(getStringProp(props, "textDecorationStyle"))
489509
result.setTextShadowOffset(
490510
if (props.hasKey(PROP_SHADOW_OFFSET)) props.getMap(PROP_SHADOW_OFFSET) else null
491511
)

0 commit comments

Comments
 (0)