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 @@ -47,6 +47,7 @@ import com.facebook.react.util.AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM
import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan
import com.facebook.react.views.text.internal.span.CustomLineHeightSpan
import com.facebook.react.views.text.internal.span.CustomStyleSpan
import com.facebook.react.views.text.internal.span.InlineViewHeights
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
import com.facebook.react.views.text.internal.span.ReactBackgroundColorSpan
import com.facebook.react.views.text.internal.span.ReactClickableSpan
Expand Down Expand Up @@ -309,6 +310,15 @@ internal object TextLayoutManager {
// single spans, avoiding duplicate draws (e.g. multiple accent marks in HighlighterTextSpan).
var pendingEffects: List<TextAttributeProps.TextEffectEntry> = emptyList()
var pendingEffectStart = 0
val inlineViews by
lazy(LazyThreadSafetyMode.NONE) {
val hasInlineViews =
(0 until fragments.count).any { i ->
val fragment = fragments.getMapBuffer(i)
fragment.contains(FR_KEY_IS_ATTACHMENT) && fragment.getBoolean(FR_KEY_IS_ATTACHMENT)
}
if (hasInlineViews) InlineViewHeights() else null
}

for (i in 0 until fragments.count) {
val fragment = fragments.getMapBuffer(i)
Expand Down Expand Up @@ -438,7 +448,13 @@ internal object TextLayoutManager {
)
}
if (!textAttributes.lineHeight.isNaN()) {
ops.add(SetSpanOperation(start, end, CustomLineHeightSpan(textAttributes.lineHeight)))
ops.add(
SetSpanOperation(
start,
end,
CustomLineHeightSpan(textAttributes.lineHeight, inlineViews),
),
)
}

if (ReactNativeFeatureFlags.enablePreparedTextLayout()) {
Expand Down Expand Up @@ -536,6 +552,7 @@ internal object TextLayoutManager {
}

val spannable = SpannableString(text)
val inlineViews = if (parsedFragments.any { it.isAttachment }) InlineViewHeights() else null

// Track pending text effects to coalesce consecutive fragments with the same effects into
// single spans, avoiding duplicate draws (e.g. multiple accent marks in HighlighterTextSpan).
Expand Down Expand Up @@ -675,7 +692,12 @@ internal object TextLayoutManager {
}

if (!fragment.props.lineHeight.isNaN()) {
spannable.setSpan(CustomLineHeightSpan(fragment.props.lineHeight), start, end, spanFlags)
spannable.setSpan(
CustomLineHeightSpan(fragment.props.lineHeight, inlineViews),
start,
end,
spanFlags,
)
}

if (ReactNativeFeatureFlags.enablePreparedTextLayout()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,28 @@
package com.facebook.react.views.text.internal.span

import android.graphics.Paint.FontMetricsInt
import android.text.Spanned
import android.text.TextPaint
import android.text.style.LineHeightSpan
import android.text.style.MetricAffectingSpan
import android.text.style.ReplacementSpan
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min

/**
* Implements a [LineHeightSpan] which follows web-like behavior for line height, unlike
* LineHeightSpan.Standard which only effects space between the baselines of adjacent line boxes
* (does not impact space before the first line or after the last).
*
* Pass [inlineViews] when the text contains [TextInlineViewPlaceholderSpan]s, so that lines holding
* an inline view grow to contain it, as CSS does for inline replaced elements.
*/
internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
internal class CustomLineHeightSpan(
height: Float,
private val inlineViews: InlineViewHeights? = null,
) : LineHeightSpan.WithDensity, ReactSpan {
val lineHeight: Int = ceil(height.toDouble()).toInt()

override fun chooseHeight(
Expand All @@ -28,6 +40,28 @@ internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
v: Int,
fm: FontMetricsInt,
) {
chooseHeight(text, start, end, spanstartv, v, fm, null)
}

override fun chooseHeight(
text: CharSequence,
start: Int,
end: Int,
spanstartv: Int,
v: Int,
fm: FontMetricsInt,
paint: TextPaint?,
) {
// Inline views sit on the baseline and are already merged into `fm` as ascent, so the
// half-leading must come from the text's own font metrics.
val inlineViewHeight =
if (inlineViews != null && text is Spanned) inlineViews.maxHeight(text, start, end) else 0
if (inlineViewHeight > 0 && paint != null) {
val fontMetrics = textFontMetrics(text as Spanned, paint)
fm.ascent = fontMetrics.ascent
fm.descent = fontMetrics.descent
}

// https://www.w3.org/TR/css-inline-3/#inline-height
// When its computed line-height is not normal, its layout bounds are derived solely from
// metrics of its first available font (ignoring glyphs from other fonts), and leading is used
Expand All @@ -42,6 +76,11 @@ internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
fm.ascent -= ceil(leading / 2.0f).toInt()
fm.descent += floor(leading / 2.0f).toInt()

if (inlineViewHeight > 0) {
fm.ascent = min(fm.ascent, -inlineViewHeight)
fm.descent = max(fm.descent, 0)
}

// The top of the first line, and the bottom of the last line, may influence bounds of the
// paragraph, so we match them to the text ascent/descent. It is otherwise desirable to allow
// line boxes to overlap (to allow too large glyphs to be drawn outside them), so we do not
Expand All @@ -53,4 +92,46 @@ internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
fm.bottom = fm.descent
}
}

// This span covers exactly the text fragment that declared the line height, never an inline
// view, so the metric spans over its range carry that fragment's font.
private fun textFontMetrics(text: Spanned, paint: TextPaint): FontMetricsInt {
val textPaint = TextPaint(paint)
val spanStart = text.getSpanStart(this)
val spanEnd = text.getSpanEnd(this)
if (spanStart in 0 until spanEnd) {
for (span in text.getSpans(spanStart, spanEnd, MetricAffectingSpan::class.java)) {
if (span !is ReplacementSpan) {
span.updateMeasureState(textPaint)
}
}
}
return textPaint.fontMetricsInt
}
}

/**
* The inline views of one text, shared by all of its [CustomLineHeightSpan]s so that the text is
* scanned for them once rather than once per span per line.
*/
internal class InlineViewHeights {
private var offsets: IntArray? = null
private var heights: IntArray = IntArray(0)

fun maxHeight(text: Spanned, start: Int, end: Int): Int {
val offsets = this.offsets ?: load(text)
var height = 0
for (i in offsets.indices) {
if (offsets[i] in start until end) {
height = max(height, heights[i])
}
}
return height
}

private fun load(text: Spanned): IntArray {
val spans = text.getSpans(0, text.length, TextInlineViewPlaceholderSpan::class.java)
heights = IntArray(spans.size) { spans[it].height }
return IntArray(spans.size) { text.getSpanStart(spans[it]) }.also { offsets = it }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@

package com.facebook.react.views.text.internal.span

import android.annotation.SuppressLint
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 android.text.style.MetricAffectingSpan
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@SuppressLint("NewApi")
@RunWith(RobolectricTestRunner::class)
class CustomLineHeightSpanTest {

Expand Down Expand Up @@ -75,14 +78,99 @@ class CustomLineHeightSpanTest {
assertThat(layout.height).isEqualTo(24)
}

private fun buildStaticLayout(text: String, lineHeight: Int): StaticLayout {
@Test
fun inlineViewTallerThanLineHeightGrowsLineBoxAroundTextAndView() {
val paint = TextPaint().apply { textSize = 24f }
val text = SpannableString("ab\uFFFC")
text.setSpan(
CustomLineHeightSpan(24f, inlineViews = InlineViewHeights()),
0,
2,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
text.setSpan(
TextInlineViewPlaceholderSpan(reactTag = 1, width = 40, height = 40),
2,
3,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
val fontMetrics = paint.fontMetricsInt
val leading = 24 - (-fontMetrics.ascent + fontMetrics.descent)
val textDescent = fontMetrics.descent + Math.floorDiv(leading, 2)

val layout =
StaticLayout.Builder.obtain(text, 0, text.length, paint, 400)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(true)
.setLineSpacing(0f, 1f)
.build()

assertThat(layout.lineCount).isEqualTo(1)
assertThat(layout.getLineBaseline(0) - layout.getLineTop(0)).isEqualTo(40)
assertThat(layout.getLineBottom(0) - layout.getLineBaseline(0)).isEqualTo(maxOf(textDescent, 0))
}

@Test
fun lineWithOnlyAnInlineViewMeasuresTheLineHeightTextFont() {
val span = CustomLineHeightSpan(60f, inlineViews = InlineViewHeights())
val textFont = RecordingMetricAffectingSpan()
val text = SpannableString("ab\uFFFC")
text.setSpan(textFont, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
text.setSpan(
TextInlineViewPlaceholderSpan(reactTag = 1, width = 80, height = 80),
2,
3,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
val fm =
Paint.FontMetricsInt().apply {
top = -80
ascent = -80
descent = 0
bottom = 0
}

span.chooseHeight(text, 2, 3, 0, 0, fm, TextPaint())

assertThat(textFont.measured).isTrue()
assertThat(fm.ascent).isEqualTo(-80)
assertThat(fm.descent).isGreaterThanOrEqualTo(0)
assertThat(fm.bottom).isEqualTo(fm.descent)
}

@Test
fun inlineViewShorterThanLineHeightKeepsLineHeight() {
val layout = buildStaticLayout("ab\uFFFC", lineHeight = 24, inlineViewHeight = 10)

assertThat(layout.lineCount).isEqualTo(1)
assertThat(layout.height).isEqualTo(24)
}

private fun buildStaticLayout(
text: String,
lineHeight: Int,
inlineViewHeight: Int? = null,
): StaticLayout {
val spannable = SpannableString(text)
val textEnd = if (inlineViewHeight == null) text.length else text.length - 1
spannable.setSpan(
CustomLineHeightSpan(lineHeight.toFloat()),
CustomLineHeightSpan(
lineHeight.toFloat(),
inlineViews = inlineViewHeight?.let { InlineViewHeights() },
),
0,
text.length,
textEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
if (inlineViewHeight != null) {
spannable.setSpan(
TextInlineViewPlaceholderSpan(reactTag = 1, width = 10, height = inlineViewHeight),
textEnd,
text.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}

return StaticLayout.Builder.obtain(
spannable,
Expand All @@ -96,4 +184,14 @@ class CustomLineHeightSpanTest {
.setLineSpacing(0f, 1f)
.build()
}

private class RecordingMetricAffectingSpan : MetricAffectingSpan() {
var measured = false

override fun updateMeasureState(textPaint: TextPaint) {
measured = true
}

override fun updateDrawState(textPaint: TextPaint) = Unit
}
}
Loading