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 +