From 5285cca42a52a73f119ea9c047b9668d981aaa2c Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 6 Nov 2026 14:31:00 -0800 Subject: [PATCH 1/2] Keep adjustsFontSizeToFit from shrinking text at whole-pixel heights on Android (#59792) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59792 `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, From 5402f54d797a52d34ee814fd8542917958599c89 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 6 Nov 2026 14:31:11 -0800 Subject: [PATCH 2/2] Keep line heights from making adjustsFontSizeToFit shrink text on Android (#59800) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59800 Android lays text out in lines a whole number of pixels tall, so `CustomLineHeightSpan` rounds a `lineHeight` up to the next whole pixel. That broke `adjustsFontSizeToFit` for text whose lines exactly fill its container: - A `lineHeight` that isn't a whole number of pixels, such as 20dp at 420dpi (52.5px), is laid out as 53px, which doesn't fit the 52.5px its container leaves for it. - A `lineHeight` that converts to a hair over a whole number of pixels, such as 44.000004px, is laid out a whole pixel taller than it asks for. `adjustsFontSizeToFit` then shrinks the font. Shrinking it never makes a line with a fixed `lineHeight` shorter, so the text drops to the minimum font size, and text with `numberOfLines` greater than 1 collapses onto fewer lines. - `CustomLineHeightSpan` no longer rounds a line height up past a whole number of pixels it only exceeds by floating point error, using the tolerance `PIXEL_SIZE_TOLERANCE` that the earlier changes added. - `adjustsFontSizeToFit` compares the available height with the layout's height minus what rounding its line heights added, so text fits when its line heights, as given, fit. Text without a `lineHeight` is compared as before. Adds unit tests and an RNTester example with nine one-line and nine two-line labels whose lines exactly fill their containers. Each label's line height is half a pixel taller than the last. Without this change, 10 of the 18 labels shrink at 420dpi and 8 at 320dpi. Changelog: [Android][Fixed] - Fix `adjustsFontSizeToFit` shrinking text whose `lineHeight` exactly fills its container Differential Revision: D129646293 --- .../react/views/text/TextLayoutManager.kt | 26 +++++++++- .../internal/span/CustomLineHeightSpan.kt | 7 ++- ...youtManagerFontSizeToFitPixelHeightTest.kt | 26 ++++++++++ .../internal/span/CustomLineHeightSpanTest.kt | 12 +++++ .../js/examples/Text/TextExample.android.js | 48 +++++++++++++++++++ 5 files changed, 115 insertions(+), 4 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 e30f4dc416fe..5691558840dd 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 @@ -120,7 +120,7 @@ internal object TextLayoutManager { // 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 + internal const val PIXEL_SIZE_TOLERANCE: Float = 0.001f private val tagToSpannableCache = ConcurrentHashMap() @@ -1465,6 +1465,27 @@ internal object TextLayoutManager { ) } + /** + * How much taller rounding line heights up to whole pixels made a layout than the line heights + * its text asks for. StaticLayout applies every line height span in a paragraph to each of its + * lines, and the last one sets the line's height. + */ + private fun lineHeightRounding(layout: Layout): Float { + val text = layout.text as? Spanned ?: return 0f + var rounding = 0f + for (line in 0 until layout.lineCount) { + val lineStart = layout.getLineStart(line) + val paragraphStart = TextUtils.lastIndexOf(text, '\n', lineStart - 1) + 1 + val paragraphEnd = + TextUtils.indexOf(text, '\n', lineStart).let { if (it < 0) text.length else it } + val span = + text.getSpans(paragraphStart, paragraphEnd, CustomLineHeightSpan::class.java).lastOrNull() + ?: continue + rounding += span.lineHeight - span.height + } + return rounding + } + @JvmStatic fun adjustSpannableFontToFit( text: Spannable, @@ -1561,7 +1582,8 @@ internal object TextLayoutManager { val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width val exceedsHeight = heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && - layout.height > height + PIXEL_SIZE_TOLERANCE + layout.height > height + PIXEL_SIZE_TOLERANCE && + layout.height - lineHeightRounding(layout) > height + PIXEL_SIZE_TOLERANCE val exceedsMaximumNumberOfLines = maximumNumberOfLines != ReactConstants.UNSET && maximumNumberOfLines != 0 && 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..233d901267f9 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 @@ -9,6 +9,7 @@ package com.facebook.react.views.text.internal.span import android.graphics.Paint.FontMetricsInt import android.text.style.LineHeightSpan +import com.facebook.react.views.text.TextLayoutManager import kotlin.math.ceil import kotlin.math.floor @@ -17,8 +18,10 @@ import kotlin.math.floor * 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). */ -internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan { - val lineHeight: Int = ceil(height.toDouble()).toInt() +internal class CustomLineHeightSpan(val height: Float) : LineHeightSpan, ReactSpan { + // Lines are laid out a whole number of pixels tall, so round the height up, but not past a whole + // number of pixels it only exceeds by floating point error. + val lineHeight: Int = ceil(height - TextLayoutManager.PIXEL_SIZE_TOLERANCE).toInt() override fun chooseHeight( text: CharSequence, 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 index ce2669d8b388..c32d283470d6 100644 --- 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 @@ -16,6 +16,7 @@ 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.CustomLineHeightSpan import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan import com.facebook.yoga.YogaMeasureMode import org.assertj.core.api.Assertions.assertThat @@ -64,6 +65,30 @@ class TextLayoutManagerFontSizeToFitPixelHeightTest { assertThat(fontSize(text)).isLessThan(FONT_SIZE) } + @Test + fun `line heights rounded up to whole pixels still fit the height of their lines`() { + for (lineCount in 1..2) { + val text = spannableWithLineHeight(FRACTIONAL_LINE_HEIGHT, lineCount) + adjustToFitHeight(text, lineCount * FRACTIONAL_LINE_HEIGHT) + + assertThat(fontSize(text)).isEqualTo(FONT_SIZE) + } + } + + @Test + fun `line heights that do not fit the height of their lines shrink the font`() { + val text = spannableWithLineHeight(FRACTIONAL_LINE_HEIGHT, lineCount = 2) + adjustToFitHeight(text, 2 * FRACTIONAL_LINE_HEIGHT - 1f) + + assertThat(fontSize(text)).isLessThan(FONT_SIZE) + } + + private fun spannableWithLineHeight(lineHeight: Float, lineCount: Int): SpannableString = + SpannableString(List(lineCount) { "Add payment method" }.joinToString("\n")).apply { + setSpan(ReactAbsoluteSizeSpan(FONT_SIZE), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + setSpan(CustomLineHeightSpan(lineHeight), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + private fun spannableWithFontSize(fontSize: Int): SpannableString = SpannableString("Add payment method").apply { setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) @@ -104,6 +129,7 @@ class TextLayoutManagerFontSizeToFitPixelHeightTest { private companion object { const val FONT_SIZE = 40 + const val FRACTIONAL_LINE_HEIGHT = 52.5f const val WIDTH = 1000f } } 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..d6e23b42c62d 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 @@ -75,6 +75,18 @@ class CustomLineHeightSpanTest { assertThat(layout.height).isEqualTo(24) } + @Test + fun fractionalLineHeightRoundsUpToWholePixels() { + assertThat(CustomLineHeightSpan(42.5f).lineHeight).isEqualTo(43) + assertThat(CustomLineHeightSpan(44.01f).lineHeight).isEqualTo(45) + } + + @Test + fun lineHeightFloatingPointErrorAboveWholePixelsDoesNotRoundUp() { + assertThat(CustomLineHeightSpan(Math.nextUp(44f)).lineHeight).isEqualTo(44) + assertThat(CustomLineHeightSpan(44.0004f).lineHeight).isEqualTo(44) + } + private fun buildStaticLayout(text: String, lineHeight: Int): StaticLayout { val spannable = SpannableString(text) spannable.setSpan( diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js index 2bd0b1e07c1c..56dc27e7ba4d 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.android.js +++ b/packages/rn-tester/js/examples/Text/TextExample.android.js @@ -1448,6 +1448,39 @@ function AdjustsFontSizeToFitWholePixelHeightExample(props: {}): React.Node { ); } +function AdjustsFontSizeToFitLineHeightExample(props: {}): React.Node { + // Each label's lines exactly fill its container. Android lays lines out a + // whole number of pixels tall, so it rounds up a line height that isn't one. + // Each row's line height is half a pixel taller than the last, so it + // alternates between whole and fractional pixels, and some of the whole ones + // convert to a hair over a whole number of pixels. No label should shrink. + const pixelRatio = PixelRatio.get(); + const rows = []; + for (const numberOfLines of [1, 2]) { + for (let i = 0; i < 9; i++) { + const lineHeight = (Math.round(16 * pixelRatio) + i / 2) / pixelRatio; + rows.push( + 1 && + styles.adjustsFontSizeToFitLineHeightWrappingRow, + {height: numberOfLines * lineHeight}, + ]}> + + Add payment method + + , + ); + } + } + return {rows}; +} + const examples = [ { title: 'Wrapped text width mode', @@ -1554,6 +1587,11 @@ const examples = [ name: 'adjustsFontSizeToFitWholePixelHeight', render: AdjustsFontSizeToFitWholePixelHeightExample, }, + { + title: 'adjustsFontSizeToFit with lineHeight', + name: 'adjustsFontSizeToFitLineHeight', + render: AdjustsFontSizeToFitLineHeightExample, + }, { title: 'Hyphenation', name: 'hyphenation', @@ -2074,6 +2112,16 @@ const styles = StyleSheet.create({ adjustsFontSizeToFitLabel: { fontSize: 16, }, + adjustsFontSizeToFitLineHeightRow: { + backgroundColor: '#E4E6EB', + marginBottom: 2, + }, + adjustsFontSizeToFitLineHeightWrappingRow: { + width: 90, + }, + adjustsFontSizeToFitLineHeightLabel: { + fontSize: 12, + }, lineBreakSample: { backgroundColor: '#eee', fontSize: 16,