Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -250,6 +238,74 @@ 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 availableVerticalSpace = getAvailableVerticalSpace();
if (layout.getHeight() >= availableVerticalSpace) {
return 0;
}

int verticalGravity = getGravity() & Gravity.VERTICAL_GRAVITY_MASK;
if (verticalGravity == Gravity.BOTTOM) {
return availableVerticalSpace - layout.getHeight();
} else if (verticalGravity == Gravity.CENTER_VERTICAL) {
return (availableVerticalSpace - layout.getHeight()) / 2;
}

return 0;
}

private int getAvailableVerticalSpace() {
return getHeight() - getExtendedPaddingTop() - getExtendedPaddingBottom();
}
Comment on lines +305 to +307

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAvailableVerticalSpace() uses getCompoundPaddingTop/Bottom() while the canvas translation uses getExtendedPaddingTop() — Android's TextView.getVerticalOffset() uses extended paddings for both.

Suggestions: Calculate available vertical space using getExtendedPaddingTop() and getExtendedPaddingBottom() to match TextView's gravity logic.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one more narrow spot in the same vertical offset path and fixed it here: 2c04e30


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
try (SystraceSection s = new SystraceSection("ReactTextView.onMeasure")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* 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.Gravity
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()
}

@Test
fun bottomGravityDoesNotShiftLayoutUpWhenTextIsTallerThanView() {
val lineHeight = 48
val viewHeight = 24
val bitmap = drawReactTextViewWithOverflow(null, lineHeight, viewHeight, Gravity.BOTTOM)

assertThat(firstVisiblePixelY(bitmap)).isGreaterThanOrEqualTo(lineHeight)
}

private fun drawReactTextViewWithOverflow(overflow: String?): Bitmap {
return drawReactTextViewWithOverflow(overflow, lineHeight = 24, viewHeight = 24, gravity = null)
}

private fun drawReactTextViewWithOverflow(
overflow: String?,
lineHeight: Int,
viewHeight: Int,
gravity: Int?,
): Bitmap {
val width = 200
val bitmapHeight = 80
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)
if (gravity != null) {
view.setGravityVertical(gravity)
}
view.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(viewHeight, View.MeasureSpec.EXACTLY),
)
view.layout(0, 0, width, viewHeight)

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 fun firstVisiblePixelY(bitmap: Bitmap): Int {
for (y in 0 until bitmap.height) {
for (x in 0 until bitmap.width) {
if (Color.alpha(bitmap.getPixel(x, y)) != 0) {
return y
}
}
}

return bitmap.height
}

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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
13 changes: 13 additions & 0 deletions packages/rn-tester/js/examples/Text/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,19 @@ function LineHeightExample(props: {}): React.Node {
<RNTesterText style={{fontSize: 20}}>Continually</RNTesterText> expedite
magnetic potentialities rather than client-focused interfaces.
</RNTesterText>
<RNTesterText
style={[
{
fontSize: 24,
lineHeight: 24,
borderColor: 'black',
borderWidth: 1,
},
styles.wrappedText,
]}
testID="line-height-matches-font-size-descenders">
gjpqy
</RNTesterText>
<RNTesterText
style={[
{
Expand Down
Loading