From 4d529baf04b739aa400dd6a4923f9bc9caf38b1d Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 3 Nov 2026 19:33:34 -0800 Subject: [PATCH 1/2] Keep whole-pixel text widths from wrapping on Android (#59791) Summary: A `Text` laid out at its own measured width is measured twice. This happens, for example, when it has `maxWidth: '100%'` inside a container that is only as wide as the text. The second measurement gets the first result back as its `AT_MOST` width, after Yoga adds and removes the container's padding and converts the width from pixels to DIPs and back, all in 32-bit floats. A whole number of pixels can come back a fraction short: at 420dpi, 372px comes back as 371.99997px. `TextLayoutManager.createLayout` floors `AT_MOST` widths, so the text gets a layout one pixel narrower than it was just measured to need and wraps onto a second line. Floor `AT_MOST` widths with a 0.001px tolerance, so a width that falls a float error short of a whole pixel keeps that pixel. Widths further below a whole pixel still round down. Adds a `createLayout` unit test and an RNTester example that lays out 17 labels at their own widths, each about a pixel wider than the last. Without this change, every label wraps at 320dpi and 6 of them wrap at 420dpi. Changelog: [Android][Fixed] - Fix `Text` wrapping onto a second line when it is laid out at its own measured width Differential Revision: D129505599 --- .../react/views/text/TextLayoutManager.kt | 22 +++++- .../text/TextLayoutManagerPixelWidthTest.kt | 74 +++++++++++++++++++ .../js/examples/Text/TextExample.android.js | 46 ++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerPixelWidthTest.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 d906e9d78e6b..dd3367ed6c56 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 @@ -118,6 +118,10 @@ internal object TextLayoutManager { private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line" + // Widths arrive in pixels converted from Yoga's floating point DIPs, so a width of a whole number + // of pixels can arrive a fraction of a pixel short of it (e.g. 371.99997 instead of 372). + private const val PIXEL_WIDTH_TOLERANCE = 0.001f + private val tagToSpannableCache = ConcurrentHashMap() // These wrappers mirror Android 15 APIs but use reflection because some internal targets still @@ -865,7 +869,8 @@ internal object TextLayoutManager { // layout as a BoringLayout if ( boring != null && - (widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || boring.width <= floor(width)) + (widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || + boring.width <= floorPixelWidth(width)) ) { // Guard uses floor() but layout width below uses ceil() for EXACTLY mode intentionally: // text that barely fails the floor-based guard falls through to StaticLayout, which also @@ -888,15 +893,16 @@ 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 the floored width, so the minimum of both is the floored width. val layoutWidth = when { widthYogaMeasureMode == YogaMeasureMode.EXACTLY -> ceil(width).toInt() - widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null -> floor(width).toInt() + widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null -> + floorPixelWidth(width) else -> { val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST) { - min(desiredWidth, floor(width).toInt()) + min(desiredWidth, floorPixelWidth(width)) } else { desiredWidth } @@ -970,6 +976,14 @@ internal object TextLayoutManager { } } + /** + * Rounds a maximum width down to whole pixels, without losing the last pixel of a width that + * falls a floating point error short of it. Text measured at a whole number of pixels gets that + * width back as its constraint once Yoga has converted it to DIPs and back, and flooring that to + * one pixel less wraps the text. + */ + private fun floorPixelWidth(width: Float): Int = floor(width + PIXEL_WIDTH_TOLERANCE).toInt() + @VisibleForTesting internal fun adjustLayoutForRtlRightOverhang( layout: Layout, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerPixelWidthTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerPixelWidthTest.kt new file mode 100644 index 000000000000..a42a4ee2e401 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerPixelWidthTest.kt @@ -0,0 +1,74 @@ +/* + * 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.graphics.text.LineBreakConfig +import android.text.BoringLayout +import android.text.Layout +import android.text.SpannableString +import android.text.TextPaint +import com.facebook.yoga.YogaMeasureMode +import kotlin.math.ceil +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 +import org.robolectric.annotation.TextLayoutMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@TextLayoutMode(TextLayoutMode.Mode.REALISTIC) +class TextLayoutManagerPixelWidthTest { + private val text = SpannableString("Add payment method") + private val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f } + private val measuredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() + + @Test + fun `AT_MOST width a floating point error short of the measured width keeps one line`() { + val widths = listOf(Math.nextDown(measuredWidth.toFloat()), measuredWidth - 0.0004f) + for (width in widths) { + for (boring in listOf(BoringLayout.isBoring(text, paint), null)) { + val layout = createLayout(width, boring) + + assertThat(layout.width).isEqualTo(measuredWidth) + assertThat(layout.lineCount).isEqualTo(1) + } + } + } + + @Test + fun `AT_MOST width a fraction of a pixel short of the measured width wraps`() { + for (boring in listOf(BoringLayout.isBoring(text, paint), null)) { + val layout = createLayout(measuredWidth - 0.5f, boring) + + assertThat(layout.width).isEqualTo(measuredWidth - 1) + assertThat(layout.lineCount).isGreaterThan(1) + } + } + + @SuppressLint("InlinedApi") + private fun createLayout(width: Float, boring: BoringLayout.Metrics?): Layout = + TextLayoutManager.createLayout( + text, + boring, + width, + YogaMeasureMode.AT_MOST, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + /* lineBreakStyle = */ LineBreakConfig.LINE_BREAK_STYLE_NONE, + /* lineBreakWordStyle = */ LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ 0, + paint, + ) +} diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js index aae2faacf9a0..af4c76503899 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.android.js +++ b/packages/rn-tester/js/examples/Text/TextExample.android.js @@ -24,6 +24,7 @@ const React = require('react'); const { Image, LayoutAnimation, + PixelRatio, StyleSheet, Text, TextInput, @@ -1388,6 +1389,32 @@ function TextBaseLineLayoutExample(props: {}): React.Node { ); } +function FitContentWholePixelWidthExample(props: {}): React.Node { + // Each label is measured, then measured again with its own width as its + // maximum, because its container is only as wide as the label and the + // container's padding. That maximum picks up floating point error from the + // padding and from the conversion to DIPs and back, so it can fall a hair + // short of the label's width. Each row is about a pixel wider than the last. + // No label should wrap. + const rows = []; + for (let i = 0; i < 17; i++) { + rows.push( + + + + Add payment method + + + , + ); + } + return {rows}; +} + const examples = [ { title: 'Wrapped text width mode', @@ -1484,6 +1511,11 @@ const examples = [ ); }, }, + { + title: 'Fit-content text at whole pixel widths', + name: 'fitContentWholePixelWidth', + render: FitContentWholePixelWidthExample, + }, { title: 'Hyphenation', name: 'hyphenation', @@ -1982,6 +2014,20 @@ const styles = StyleSheet.create({ wrappedText: { maxWidth: 300, }, + fitContentRow: { + backgroundColor: '#E4E6EB', + flexDirection: 'row', + justifyContent: 'center', + marginBottom: 2, + }, + fitContentLabelContainer: { + alignItems: 'center', + paddingHorizontal: 60.15, + }, + fitContentLabel: { + fontSize: 15, + maxWidth: '100%', + }, lineBreakSample: { backgroundColor: '#eee', fontSize: 16, From 3a8ba2bc3a9454d859409802c2fdc7f3193b445e Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 3 Nov 2026 19:33:34 -0800 Subject: [PATCH 2/2] Keep adjustsFontSizeToFit from shrinking text at whole-pixel heights on Android Summary: `adjustsFontSizeToFit` shrinks the font while the text layout is taller than its `AT_MOST` height. That height arrives in pixels after Yoga subtracts the container's padding and converts it from DIPs back to pixels, all in 32-bit floats, so a whole number of pixels can come back a fraction short: 46px comes back as 45.999996px. A layout exactly as tall as the space it was given then counts as too tall. When the text sets a `lineHeight`, shrinking the font never makes it shorter, so it shrinks all the way to the minimum font size. Compare the layout height against the `AT_MOST` height with the same 0.001px tolerance the previous change added for widths, now named `PIXEL_SIZE_TOLERANCE`. Adds a unit test and an RNTester example. The example lays out 17 labels, each with a `lineHeight` of a whole number of pixels, in containers that leave exactly that height below their padding. Each row is a pixel taller than the last. Without this change, 11 of them shrink to the minimum font size at 320dpi and 4 at 420dpi. Changelog: [Android][Fixed] - Fix `adjustsFontSizeToFit` shrinking text that exactly fits its container's height Differential Revision: D129533186 --- .../react/views/text/TextLayoutManager.kt | 11 +- ...youtManagerFontSizeToFitPixelHeightTest.kt | 109 ++++++++++++++++++ .../js/examples/Text/TextExample.android.js | 46 ++++++++ 3 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.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 dd3367ed6c56..e30f4dc416fe 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 @@ -118,9 +118,9 @@ internal object TextLayoutManager { private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line" - // Widths arrive in pixels converted from Yoga's floating point DIPs, so a width of a whole number - // of pixels can arrive a fraction of a pixel short of it (e.g. 371.99997 instead of 372). - private const val PIXEL_WIDTH_TOLERANCE = 0.001f + // Sizes arrive in pixels converted from Yoga's floating point DIPs, so a whole number of pixels + // can arrive a fraction of a pixel short of it (e.g. 371.99997 instead of 372). + private const val PIXEL_SIZE_TOLERANCE = 0.001f private val tagToSpannableCache = ConcurrentHashMap() @@ -982,7 +982,7 @@ internal object TextLayoutManager { * width back as its constraint once Yoga has converted it to DIPs and back, and flooring that to * one pixel less wraps the text. */ - private fun floorPixelWidth(width: Float): Int = floor(width + PIXEL_WIDTH_TOLERANCE).toInt() + private fun floorPixelWidth(width: Float): Int = floor(width + PIXEL_SIZE_TOLERANCE).toInt() @VisibleForTesting internal fun adjustLayoutForRtlRightOverhang( @@ -1560,7 +1560,8 @@ internal object TextLayoutManager { val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width val exceedsHeight = - heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height + heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && + layout.height > height + PIXEL_SIZE_TOLERANCE val exceedsMaximumNumberOfLines = maximumNumberOfLines != ReactConstants.UNSET && maximumNumberOfLines != 0 && diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt new file mode 100644 index 000000000000..ce2669d8b388 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt @@ -0,0 +1,109 @@ +/* + * 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.graphics.text.LineBreakConfig +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan +import com.facebook.yoga.YogaMeasureMode +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.TextLayoutMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@TextLayoutMode(TextLayoutMode.Mode.REALISTIC) +@SuppressLint("InlinedApi") +class TextLayoutManagerFontSizeToFitPixelHeightTest { + + @Before + fun setUp() { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun `AT_MOST height a floating point error short of the text height keeps the font size`() { + val textHeight = layoutHeight(spannableWithFontSize(FONT_SIZE)) + + for (height in listOf(Math.nextDown(textHeight.toFloat()), textHeight - 0.0004f)) { + val text = spannableWithFontSize(FONT_SIZE) + adjustToFitHeight(text, height) + + assertThat(fontSize(text)).isEqualTo(FONT_SIZE) + } + } + + @Test + fun `AT_MOST height a fraction of a pixel short of the text height shrinks the font`() { + val text = spannableWithFontSize(FONT_SIZE) + adjustToFitHeight(text, layoutHeight(text) - 0.5f) + + assertThat(fontSize(text)).isLessThan(FONT_SIZE) + } + + private fun spannableWithFontSize(fontSize: Int): SpannableString = + SpannableString("Add payment method").apply { + setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + + private fun paint(): TextPaint = + TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = FONT_SIZE.toFloat() } + + private fun layoutHeight(text: SpannableString): Int = + StaticLayout.Builder.obtain(text, 0, text.length, paint(), WIDTH.toInt()) + .setIncludePad(false) + .build() + .height + + private fun fontSize(text: SpannableString): Int = + text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).single().size + + private fun adjustToFitHeight(text: SpannableString, height: Float) { + TextLayoutManager.adjustSpannableFontToFit( + text, + WIDTH, + YogaMeasureMode.EXACTLY, + height, + YogaMeasureMode.AT_MOST, + /* minimumFontSizeAttr = */ Float.NaN, + /* minimumFontScale = */ Float.NaN, + ReactConstants.UNSET, + /* includeFontPadding = */ false, + Layout.BREAK_STRATEGY_HIGH_QUALITY, + Layout.HYPHENATION_FREQUENCY_NONE, + LineBreakConfig.LINE_BREAK_STYLE_NONE, + LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + paint(), + ) + } + + private companion object { + const val FONT_SIZE = 40 + const val WIDTH = 1000f + } +} diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js index af4c76503899..2bd0b1e07c1c 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.android.js +++ b/packages/rn-tester/js/examples/Text/TextExample.android.js @@ -1415,6 +1415,39 @@ function FitContentWholePixelWidthExample(props: {}): React.Node { return {rows}; } +function AdjustsFontSizeToFitWholePixelHeightExample(props: {}): React.Node { + // Each label's line height is a whole number of pixels, and so is the space + // its container leaves for it below the container's padding. That space + // picks up floating point error from the padding and from the conversion to + // DIPs and back, so it can fall a hair short of the line height. Each row is + // a pixel taller than the last. No label should shrink. + const pixelRatio = PixelRatio.get(); + const rows = []; + for (let i = 0; i < 17; i++) { + const lineHeight = (Math.round(20 * pixelRatio) + i) / pixelRatio; + rows.push( + + + Add payment method + + , + ); + } + return ( + + {rows} + + ); +} + const examples = [ { title: 'Wrapped text width mode', @@ -1516,6 +1549,11 @@ const examples = [ name: 'fitContentWholePixelWidth', render: FitContentWholePixelWidthExample, }, + { + title: 'adjustsFontSizeToFit at whole pixel heights', + name: 'adjustsFontSizeToFitWholePixelHeight', + render: AdjustsFontSizeToFitWholePixelHeightExample, + }, { title: 'Hyphenation', name: 'hyphenation', @@ -2028,6 +2066,14 @@ const styles = StyleSheet.create({ fontSize: 15, maxWidth: '100%', }, + adjustsFontSizeToFitRow: { + backgroundColor: '#E4E6EB', + marginBottom: 2, + paddingTop: 7.1, + }, + adjustsFontSizeToFitLabel: { + fontSize: 16, + }, lineBreakSample: { backgroundColor: '#eee', fontSize: 16,