Skip to content

Commit 21d1669

Browse files
committed
fix(android): track textAlignVertical in custom decoration draw offset
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.
1 parent 4adca58 commit 21d1669

2 files changed

Lines changed: 75 additions & 2 deletions

File tree

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,14 @@ protected void onDraw(Canvas canvas) {
219219
CanvasEffectSpan[] drawSpans =
220220
spanned.getSpans(0, spanned.length(), CanvasEffectSpan.class);
221221
if (drawSpans.length > 0) {
222+
int voffsetText =
223+
verticalGravityOffset(
224+
getGravity(),
225+
getMeasuredHeight() - getExtendedPaddingTop() - getExtendedPaddingBottom(),
226+
layout.getHeight());
227+
222228
canvas.save();
223-
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
229+
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop() + voffsetText);
224230
for (CanvasEffectSpan span : drawSpans) {
225231
int start = spanned.getSpanStart(span);
226232
int end = spanned.getSpanEnd(span);
@@ -231,7 +237,7 @@ protected void onDraw(Canvas canvas) {
231237
super.onDraw(canvas);
232238

233239
canvas.save();
234-
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
240+
canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop() + voffsetText);
235241
for (CanvasEffectSpan span : drawSpans) {
236242
int start = spanned.getSpanStart(span);
237243
int end = spanned.getSpanEnd(span);
@@ -413,6 +419,20 @@ public boolean hasOverlappingRendering() {
413419
setGravity((getGravity() & ~Gravity.VERTICAL_GRAVITY_MASK) | gravityVertical);
414420
}
415421

422+
/**
423+
* The vertical shift `super.onDraw` applies to glyphs for the current gravity, mirroring the
424+
* private {@code TextView.getVerticalOffset()}. {@link CanvasEffectSpan} decorations paint in a
425+
* separate pass and must add the same offset so they track the text. Returns 0 for {@code TOP}
426+
* gravity and whenever the text fills or overflows the box (no room to shift).
427+
*/
428+
/* package */ static int verticalGravityOffset(int gravity, int boxHeight, int textHeight) {
429+
int vertical = gravity & Gravity.VERTICAL_GRAVITY_MASK;
430+
if (vertical == Gravity.TOP || textHeight >= boxHeight) {
431+
return 0;
432+
}
433+
return (vertical == Gravity.BOTTOM) ? (boxHeight - textHeight) : (boxHeight - textHeight) / 2;
434+
}
435+
416436
public void setNumberOfLines(int numberOfLines) {
417437
mNumberOfLines = numberOfLines == 0 ? ViewDefaults.NUMBER_OF_LINES : numberOfLines;
418438
setMaxLines(mNumberOfLines);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text
9+
10+
import android.view.Gravity
11+
import org.assertj.core.api.Assertions.assertThat
12+
import org.junit.Test
13+
import org.junit.runner.RunWith
14+
import org.robolectric.RobolectricTestRunner
15+
16+
/**
17+
* Covers [ReactTextView.verticalGravityOffset], the offset that keeps CanvasEffectSpan decorations
18+
* (underline, strikethrough) tracking the glyphs when `textAlignVertical` shifts the text within a
19+
* taller box.
20+
*/
21+
@RunWith(RobolectricTestRunner::class)
22+
class ReactTextViewTest {
23+
24+
@Test
25+
fun topGravityNeverShifts() {
26+
assertThat(ReactTextView.verticalGravityOffset(Gravity.TOP, 200, 40)).isEqualTo(0)
27+
}
28+
29+
@Test
30+
fun centerGravityShiftsByHalfTheSlack() {
31+
assertThat(ReactTextView.verticalGravityOffset(Gravity.CENTER_VERTICAL, 200, 40)).isEqualTo(80)
32+
}
33+
34+
@Test
35+
fun bottomGravityShiftsByFullSlack() {
36+
assertThat(ReactTextView.verticalGravityOffset(Gravity.BOTTOM, 200, 40)).isEqualTo(160)
37+
}
38+
39+
@Test
40+
fun centerGravityFloorsOddSlack() {
41+
assertThat(ReactTextView.verticalGravityOffset(Gravity.CENTER_VERTICAL, 101, 40)).isEqualTo(30)
42+
}
43+
44+
@Test
45+
fun fullBoxDoesNotShift() {
46+
assertThat(ReactTextView.verticalGravityOffset(Gravity.CENTER_VERTICAL, 200, 200)).isEqualTo(0)
47+
}
48+
49+
@Test
50+
fun overflowDoesNotShift() {
51+
assertThat(ReactTextView.verticalGravityOffset(Gravity.BOTTOM, 200, 260)).isEqualTo(0)
52+
}
53+
}

0 commit comments

Comments
 (0)