diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index d906e9d78e6b..b5395b697fa1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -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 @@ -309,6 +310,15 @@ internal object TextLayoutManager { // single spans, avoiding duplicate draws (e.g. multiple accent marks in HighlighterTextSpan). var pendingEffects: List = 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) @@ -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()) { @@ -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). @@ -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()) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt index 5d30f853c3b2..33b7842ede6e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt @@ -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( @@ -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 @@ -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 @@ -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 } + } } 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 index a8c9f84d19e7..22e1d71fe3fb 100644 --- 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 @@ -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 { @@ -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, @@ -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 + } }