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,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")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
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