From 1dc9ffcc4353de403aaa5ac363570bb7013e063a Mon Sep 17 00:00:00 2001 From: Hicabi Date: Tue, 20 Oct 2026 11:38:52 +0300 Subject: [PATCH] Fix Android Text measured one line taller than drawn at fractional widths createLayout() wrapped text at floor(width) for AT_MOST but at ceil(width) for EXACTLY. Text whose width falls in (floor(width), width] was measured with one more line than it was drawn with, which left an empty line below it. AT_MOST now wraps at ceil(width), like EXACTLY. calculateWidth() clamps the reported width to the AT_MOST width, so the size returned to Yoga still never exceeds the constraint. Fixes #59174 Co-Authored-By: Claude Opus 5.5 --- .../react/views/text/TextLayoutManager.kt | 15 ++++- ...tLayoutManagerAtMostFractionalWidthTest.kt | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAtMostFractionalWidthTest.kt 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 de92c1ae230..37ec4a44c51 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 @@ -883,15 +883,19 @@ internal object TextLayoutManager { // Layout.getDesiredWidth is a full measuring pass over the text, so only run it when its // result can affect the layout width. It cannot when the width is EXACTLY, nor when the text // was boring but did not fit: boring.width is the unwrapped width of the same text and we only - // get here if it exceeds floor(width), so min(desiredWidth, floor(width)) is floor(width). + // get here if it exceeds floor(width), so min(desiredWidth, ceil(width)) is ceil(width). + // + // AT_MOST wraps at ceil(width), the same width EXACTLY uses when the text is drawn. Wrapping at + // floor(width) here measured text that fits in (floor(width), width] as one line more than it + // is drawn with. calculateWidth() clamps the reported width to the AT_MOST constraint. val layoutWidth = when { widthYogaMeasureMode == YogaMeasureMode.EXACTLY -> ceil(width).toInt() - widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null -> floor(width).toInt() + widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null -> ceil(width).toInt() else -> { val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST) { - min(desiredWidth, floor(width).toInt()) + min(desiredWidth, ceil(width).toInt()) } else { desiredWidth } @@ -1708,6 +1712,11 @@ internal object TextLayoutManager { return width } + // The layout may be up to a subpixel wider than an AT_MOST width, see createLayout(). + if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST) { + return min(layout.width.toFloat(), width) + } + return layout.width.toFloat() } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAtMostFractionalWidthTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAtMostFractionalWidthTest.kt new file mode 100644 index 00000000000..ef1211c6f4f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAtMostFractionalWidthTest.kt @@ -0,0 +1,64 @@ +/* + * 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.annotation.SuppressLint +import android.text.BoringLayout +import android.text.Layout +import android.text.SpannableString +import android.text.TextPaint +import com.facebook.yoga.YogaMeasureMode +import kotlin.math.floor +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TextLayoutManagerAtMostFractionalWidthTest { + + @Test + fun `AT_MOST measure and EXACTLY draw at the same fractional width have the same line count`() { + val text = SpannableString("Keep watching this text") + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f } + val desiredWidth = Layout.getDesiredWidth(text, paint) + // A width the text fits in, but whose floor is smaller than the text. + val width = floor(desiredWidth) + (desiredWidth - floor(desiredWidth) + 1f) / 2f + assertThat(floor(width)).isLessThan(desiredWidth) + assertThat(width).isGreaterThanOrEqualTo(desiredWidth) + + val measured = invokeCreateLayout(text, width, YogaMeasureMode.AT_MOST, paint) + val drawn = invokeCreateLayout(text, width, YogaMeasureMode.EXACTLY, paint) + + assertThat(measured.lineCount).isEqualTo(drawn.lineCount) + } + + @SuppressLint("InlinedApi") + private fun invokeCreateLayout( + text: SpannableString, + width: Float, + mode: YogaMeasureMode, + paint: TextPaint, + ): Layout = + TextLayoutManager.createLayout( + text, + BoringLayout.isBoring(text, paint), + width, + mode, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ 2, + paint, + ) +}