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..c7452885545d 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
@@ -218,28 +218,16 @@ protected void onDraw(Canvas canvas) {
if (layout != null) {
CanvasEffectSpan[] drawSpans =
spanned.getSpans(0, spanned.length(), CanvasEffectSpan.class);
- if (drawSpans.length > 0) {
- canvas.save();
- canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
- for (CanvasEffectSpan span : drawSpans) {
- int start = spanned.getSpanStart(span);
- int end = spanned.getSpanEnd(span);
- span.onPreDraw(start, end, canvas, layout);
- }
- canvas.restore();
-
- super.onDraw(canvas);
-
- canvas.save();
- canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
- for (CanvasEffectSpan span : drawSpans) {
- int start = spanned.getSpanStart(span);
- int end = spanned.getSpanEnd(span);
- span.onDraw(start, end, canvas, layout);
- }
- canvas.restore();
+ if (shouldDrawLayoutWithoutTextViewClip()) {
+ drawLayoutWithoutTextViewClip(canvas, spanned, layout, drawSpans);
} else {
- super.onDraw(canvas);
+ if (drawSpans.length > 0) {
+ drawTextEffects(canvas, spanned, layout, drawSpans, true, false);
+ super.onDraw(canvas);
+ drawTextEffects(canvas, spanned, layout, drawSpans, false, false);
+ } else {
+ super.onDraw(canvas);
+ }
}
} else {
super.onDraw(canvas);
@@ -250,6 +238,69 @@ protected void onDraw(Canvas canvas) {
}
}
+ private boolean shouldDrawLayoutWithoutTextViewClip() {
+ return mOverflow == Overflow.VISIBLE && !mTextIsSelectable && getMovementMethod() == null;
+ }
+
+ private void drawLayoutWithoutTextViewClip(
+ Canvas canvas, Spannable spanned, Layout layout, CanvasEffectSpan[] drawSpans) {
+ getPaint().setColor(getCurrentTextColor());
+ getPaint().drawableState = getDrawableState();
+
+ drawTextEffects(canvas, spanned, layout, drawSpans, true, true);
+
+ canvas.save();
+ canvas.translate(
+ getCompoundPaddingLeft(), getExtendedPaddingTop() + getVerticalGravityOffset(layout));
+ layout.draw(canvas);
+ canvas.restore();
+
+ drawTextEffects(canvas, spanned, layout, drawSpans, false, true);
+ }
+
+ private void drawTextEffects(
+ Canvas canvas,
+ Spannable spanned,
+ Layout layout,
+ CanvasEffectSpan[] drawSpans,
+ boolean beforeText,
+ boolean includeVerticalGravityOffset) {
+ if (drawSpans.length == 0) {
+ return;
+ }
+
+ canvas.save();
+ canvas.translate(
+ getCompoundPaddingLeft(),
+ getExtendedPaddingTop()
+ + (includeVerticalGravityOffset ? getVerticalGravityOffset(layout) : 0));
+ for (CanvasEffectSpan span : drawSpans) {
+ int start = spanned.getSpanStart(span);
+ int end = spanned.getSpanEnd(span);
+ if (beforeText) {
+ span.onPreDraw(start, end, canvas, layout);
+ } else {
+ span.onDraw(start, end, canvas, layout);
+ }
+ }
+ canvas.restore();
+ }
+
+ private int getVerticalGravityOffset(Layout layout) {
+ int verticalGravity = getGravity() & Gravity.VERTICAL_GRAVITY_MASK;
+ if (verticalGravity == Gravity.BOTTOM) {
+ return getAvailableVerticalSpace() - layout.getHeight();
+ } else if (verticalGravity == Gravity.CENTER_VERTICAL) {
+ return (getAvailableVerticalSpace() - layout.getHeight()) / 2;
+ }
+
+ return 0;
+ }
+
+ private int getAvailableVerticalSpace() {
+ return getHeight() - getCompoundPaddingTop() - getCompoundPaddingBottom();
+ }
+
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
try (SystraceSection s = new SystraceSection("ReactTextView.onMeasure")) {
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..8ac8af64a02d
--- /dev/null
+++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.ReplacementSpan
+import android.util.TypedValue
+import android.view.View
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class ReactTextViewTest {
+
+ @Test
+ fun drawsGlyphInkOutsideLineHeightWhenOverflowIsVisible() {
+ val bitmap = drawReactTextViewWithOverflow(null)
+
+ assertThat(hasVisiblePixelBelowViewBounds(bitmap)).isTrue()
+ }
+
+ private fun drawReactTextViewWithOverflow(overflow: String?): Bitmap {
+ val lineHeight = 24
+ val width = 200
+ val bitmapHeight = 64
+ val text = SpannableString("x")
+ text.setSpan(
+ OverflowingInkSpan(lineHeight), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ val view = TestReactTextView(RuntimeEnvironment.getApplication())
+ view.setTextColor(Color.BLACK)
+ view.setTextSize(TypedValue.COMPLEX_UNIT_PX, 24f)
+ view.includeFontPadding = true
+ view.setSpanned(text)
+ view.text = text
+ view.setOverflow(overflow)
+ view.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(lineHeight, View.MeasureSpec.EXACTLY),
+ )
+ view.layout(0, 0, width, lineHeight)
+
+ return Bitmap.createBitmap(width, bitmapHeight, Bitmap.Config.ARGB_8888).also {
+ view.drawTextForTest(Canvas(it))
+ }
+ }
+
+ private fun hasVisiblePixelBelowViewBounds(bitmap: Bitmap): Boolean {
+ for (y in 24 until bitmap.height) {
+ for (x in 0 until bitmap.width) {
+ if (Color.alpha(bitmap.getPixel(x, y)) != 0) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ private class TestReactTextView(context: Context) : ReactTextView(context) {
+ fun drawTextForTest(canvas: Canvas) {
+ super.onDraw(canvas)
+ }
+ }
+
+ private class OverflowingInkSpan(private val lineHeight: Int) : ReplacementSpan() {
+ override fun getSize(
+ paint: Paint,
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ fm: Paint.FontMetricsInt?,
+ ): Int {
+ fm?.ascent = -lineHeight
+ fm?.descent = 0
+ fm?.top = -lineHeight
+ fm?.bottom = 0
+ return lineHeight
+ }
+
+ override fun draw(
+ canvas: Canvas,
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ x: Float,
+ top: Int,
+ y: Int,
+ bottom: Int,
+ paint: Paint,
+ ) {
+ canvas.drawRect(
+ x,
+ y + (lineHeight / 4f),
+ x + lineHeight,
+ y + (lineHeight / 2f),
+ paint,
+ )
+ }
+ }
+}
diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt
new file mode 100644
index 000000000000..98b9dd0b35bc
--- /dev/null
+++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.internal.span
+
+import android.graphics.Paint
+import android.text.Layout
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.StaticLayout
+import android.text.TextPaint
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class CustomLineHeightSpanTest {
+
+ @Test
+ fun tightLineHeightDoesNotClipFirstOrLastLineFontBounds() {
+ val span = CustomLineHeightSpan(16f)
+ val fm =
+ Paint.FontMetricsInt().apply {
+ top = -18
+ ascent = -14
+ descent = 6
+ bottom = 8
+ }
+
+ span.chooseHeight("gjpqy", 0, 5, 0, 0, fm)
+
+ assertThat(fm.ascent).isEqualTo(-12)
+ assertThat(fm.descent).isEqualTo(4)
+ assertThat(fm.top).isEqualTo(-12)
+ assertThat(fm.bottom).isEqualTo(4)
+ }
+
+ @Test
+ fun looseLineHeightStillExpandsFirstAndLastLineBounds() {
+ val span = CustomLineHeightSpan(24f)
+ val fm =
+ Paint.FontMetricsInt().apply {
+ top = -18
+ ascent = -14
+ descent = 6
+ bottom = 8
+ }
+
+ span.chooseHeight("gjpqy", 0, 5, 0, 0, fm)
+
+ assertThat(fm.ascent).isEqualTo(-16)
+ assertThat(fm.descent).isEqualTo(8)
+ assertThat(fm.top).isEqualTo(-16)
+ assertThat(fm.bottom).isEqualTo(8)
+ }
+
+ @Test
+ fun tightLineHeightDoesNotExpandStaticLayoutHeightWithFontPadding() {
+ val layout = buildStaticLayout("gjpqy\ngjpqy\ngjpqy", lineHeight = 24)
+
+ assertThat(layout.lineCount).isEqualTo(3)
+ assertThat(layout.height).isEqualTo(72)
+ }
+
+ @Test
+ fun tightLineHeightDoesNotExpandSingleLineStaticLayoutHeightWithFontPadding() {
+ val layout = buildStaticLayout("gjpqy", lineHeight = 24)
+
+ assertThat(layout.lineCount).isEqualTo(1)
+ assertThat(layout.height).isEqualTo(24)
+ }
+
+ private fun buildStaticLayout(text: String, lineHeight: Int): StaticLayout {
+ val spannable = SpannableString(text)
+ spannable.setSpan(
+ CustomLineHeightSpan(lineHeight.toFloat()), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ return StaticLayout.Builder.obtain(
+ spannable, 0, spannable.length, TextPaint().apply { textSize = 24f }, 400)
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
+ .setIncludePad(true)
+ .setLineSpacing(0f, 1f)
+ .build()
+ }
+}
diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js
index 090ced25e600..a88bef0bac79 100644
--- a/packages/rn-tester/js/examples/Text/TextExample.android.js
+++ b/packages/rn-tester/js/examples/Text/TextExample.android.js
@@ -1119,6 +1119,19 @@ function LineHeightExample(props: {}): React.Node {
Continually expedite
magnetic potentialities rather than client-focused interfaces.
+
+ gjpqy
+