From c75f3301ca01ccd9366a13a7b75510d5db443c3d Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Sun, 1 Nov 2026 08:56:24 -0800 Subject: [PATCH] Keep text inside the line box when an inline view is taller than lineHeight (#59685) Summary: `CustomLineHeightSpan` derived its half-leading from the line's merged font metrics, which already include each inline view's full height as ascent. When an inline view is taller than `lineHeight`, the leading becomes strongly negative, the descent drops below zero, and the baseline lands below the bottom of the line box. Text drawn from the measured layout is then clipped, and the `` is measured too short for its inline view. The half-leading now comes from the font metrics of the line's text, and the line box then grows to contain any inline view, as CSS does for inline replaced elements. Lines without inline views keep their current metrics. `StaticLayout` asks every line-height span of a paragraph about every line, so the inline-view lookup is kept off that path for text without inline views: `TextLayoutManager` passes an `InlineViewHeights` only when the text has inline views, and that text is scanned for them once, shared by all of its line-height spans. Changelog: [Android][Fixed] - Fix clipped text when `` with `lineHeight` contains an inline view taller than the line height Differential Revision: D128623966 --- .../react/views/text/TextLayoutManager.kt | 26 ++- .../internal/span/CustomLineHeightSpan.kt | 88 +++++++++- .../internal/span/CustomLineHeightSpanTest.kt | 159 +++++++++++++++++- 3 files changed, 267 insertions(+), 6 deletions(-) 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..96f0d7e575ff 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,31 @@ 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. Without a paint those metrics are + // unknown, so the line keeps the merged metrics and does not grow. + var inlineViewHeight = 0 + if (paint != null && inlineViews != null && text is Spanned) { + inlineViewHeight = inlineViews.maxHeight(text, start, end) + if (inlineViewHeight > 0) { + val fontMetrics = textFontMetrics(text, 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 +79,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 +95,48 @@ 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 starts: IntArray? = null + private var ends: IntArray = IntArray(0) + private var heights: IntArray = IntArray(0) + + fun maxHeight(text: Spanned, start: Int, end: Int): Int { + val starts = this.starts ?: load(text) + var height = 0 + for (i in starts.indices) { + if (starts[i] < end && ends[i] > start) { + 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 } + ends = IntArray(spans.size) { text.getSpanEnd(spans[it]) } + return IntArray(spans.size) { text.getSpanStart(spans[it]) }.also { starts = 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..58fc74ba73c6 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,154 @@ 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 withoutPaintInlineViewKeepsTheLineHeightOfTextWithoutInlineViews() { + val text = SpannableString("ab\uFFFC") + val span = CustomLineHeightSpan(60f, inlineViews = InlineViewHeights()) + text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan( + TextInlineViewPlaceholderSpan(reactTag = 1, width = 80, height = 80), + 2, + 3, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + fun inlineViewMetrics() = + Paint.FontMetricsInt().apply { + top = -80 + ascent = -80 + descent = 0 + bottom = 0 + } + val fm = inlineViewMetrics() + val expected = inlineViewMetrics() + + span.chooseHeight(text, 0, 3, 0, 0, fm) + CustomLineHeightSpan(60f).chooseHeight(text, 0, 3, 0, 0, expected) + + assertThat(fm.ascent).isEqualTo(expected.ascent) + assertThat(fm.descent).isEqualTo(expected.descent) + assertThat(fm.top).isEqualTo(expected.top) + assertThat(fm.bottom).isEqualTo(expected.bottom) + } + + @Test + fun inlineViewStartingBeforeTheLineStillGrowsIt() { + val span = CustomLineHeightSpan(24f, inlineViews = InlineViewHeights()) + val text = SpannableString("abc\uFFFC\uFFFC") + text.setSpan(span, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan( + TextInlineViewPlaceholderSpan(reactTag = 1, width = 40, height = 40), + 3, + 5, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val fm = + Paint.FontMetricsInt().apply { + top = -40 + ascent = -40 + descent = 0 + bottom = 0 + } + + span.chooseHeight(text, 4, 5, 0, 0, fm, TextPaint()) + + assertThat(fm.ascent).isEqualTo(-40) + assertThat(fm.descent).isGreaterThanOrEqualTo(0) + } + + @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 +239,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 + } }