From 21d1669dac497aff8368ad34654ad0da8c14dc36 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Tue, 9 Jun 2026 13:06:07 -0400 Subject: [PATCH] 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 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. --- .../react/views/text/ReactTextView.java | 24 ++++++++- .../react/views/text/ReactTextViewTest.kt | 53 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index ff4357278512..82a7acd5fc94 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -219,8 +219,14 @@ protected void onDraw(Canvas canvas) { CanvasEffectSpan[] drawSpans = spanned.getSpans(0, spanned.length(), CanvasEffectSpan.class); if (drawSpans.length > 0) { + int voffsetText = + verticalGravityOffset( + getGravity(), + getMeasuredHeight() - getExtendedPaddingTop() - getExtendedPaddingBottom(), + layout.getHeight()); + canvas.save(); - canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop()); + canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop() + voffsetText); for (CanvasEffectSpan span : drawSpans) { int start = spanned.getSpanStart(span); int end = spanned.getSpanEnd(span); @@ -231,7 +237,7 @@ protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.save(); - canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop()); + canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop() + voffsetText); for (CanvasEffectSpan span : drawSpans) { int start = spanned.getSpanStart(span); int end = spanned.getSpanEnd(span); @@ -413,6 +419,20 @@ public boolean hasOverlappingRendering() { setGravity((getGravity() & ~Gravity.VERTICAL_GRAVITY_MASK) | gravityVertical); } + /** + * The vertical shift `super.onDraw` applies to glyphs for the current gravity, mirroring the + * private {@code TextView.getVerticalOffset()}. {@link CanvasEffectSpan} decorations paint in a + * separate pass and must add the same offset so they track the text. Returns 0 for {@code TOP} + * gravity and whenever the text fills or overflows the box (no room to shift). + */ + /* package */ static int verticalGravityOffset(int gravity, int boxHeight, int textHeight) { + int vertical = gravity & Gravity.VERTICAL_GRAVITY_MASK; + if (vertical == Gravity.TOP || textHeight >= boxHeight) { + return 0; + } + return (vertical == Gravity.BOTTOM) ? (boxHeight - textHeight) : (boxHeight - textHeight) / 2; + } + public void setNumberOfLines(int numberOfLines) { mNumberOfLines = numberOfLines == 0 ? ViewDefaults.NUMBER_OF_LINES : numberOfLines; setMaxLines(mNumberOfLines); diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt new file mode 100644 index 000000000000..172e9c40eb17 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.view.Gravity +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Covers [ReactTextView.verticalGravityOffset], the offset that keeps CanvasEffectSpan decorations + * (underline, strikethrough) tracking the glyphs when `textAlignVertical` shifts the text within a + * taller box. + */ +@RunWith(RobolectricTestRunner::class) +class ReactTextViewTest { + + @Test + fun topGravityNeverShifts() { + assertThat(ReactTextView.verticalGravityOffset(Gravity.TOP, 200, 40)).isEqualTo(0) + } + + @Test + fun centerGravityShiftsByHalfTheSlack() { + assertThat(ReactTextView.verticalGravityOffset(Gravity.CENTER_VERTICAL, 200, 40)).isEqualTo(80) + } + + @Test + fun bottomGravityShiftsByFullSlack() { + assertThat(ReactTextView.verticalGravityOffset(Gravity.BOTTOM, 200, 40)).isEqualTo(160) + } + + @Test + fun centerGravityFloorsOddSlack() { + assertThat(ReactTextView.verticalGravityOffset(Gravity.CENTER_VERTICAL, 101, 40)).isEqualTo(30) + } + + @Test + fun fullBoxDoesNotShift() { + assertThat(ReactTextView.verticalGravityOffset(Gravity.CENTER_VERTICAL, 200, 200)).isEqualTo(0) + } + + @Test + fun overflowDoesNotShift() { + assertThat(ReactTextView.verticalGravityOffset(Gravity.BOTTOM, 200, 260)).isEqualTo(0) + } +}