From 9c161e06a885d067f27f35da0349ca74cfd8ebd3 Mon Sep 17 00:00:00 2001 From: Danyal Ahmed <58849388+danyalahmed1995@users.noreply.github.com> Date: Thu, 14 May 2026 23:53:12 +0500 Subject: [PATCH] Fix Android adjustsFontSizeToFit shrinking text that fits --- .../react/views/text/TextLayoutManager.kt | 61 ++++++++++--- ...extLayoutManagerAdjustFontSizeToFitTest.kt | 88 +++++++++++++++++++ 2 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAdjustFontSizeToFitTest.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 b5d061823f9b..84244f7147f4 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 @@ -64,6 +64,7 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt import org.json.JSONArray import org.json.JSONObject @@ -1060,7 +1061,21 @@ internal object TextLayoutManager { paint: TextPaint, ): Unit { var boring = isBoring(text, paint) - var layout: Layout + var layout = + createLayout( + text, + boring, + width, + widthYogaMeasureMode, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + null, + ReactConstants.UNSET, + paint, + ) // Minimum font size is 4pts to match the iOS implementation. val minimumFontSize = @@ -1073,6 +1088,13 @@ internal object TextLayoutManager { currentFontSize = max(currentFontSize, span.size).toInt() } + if ( + !layoutExceedsFontSizeFitConstraints( + text, layout, width, height, heightYogaMeasureMode, maximumNumberOfLines) + ) { + return + } + var intervalStart = minimumFontSize var intervalEnd = currentFontSize var previousFontSize = currentFontSize @@ -1087,12 +1109,14 @@ internal object TextLayoutManager { val currentFontSize = (intervalStart + intervalEnd + 1) / 2 val ratio = currentFontSize.toFloat() / previousFontSize.toFloat() - paint.textSize = max((paint.textSize * ratio).toInt(), minimumFontSize).toFloat() + paint.textSize = scaleFontSizeForFontSizeFit(paint.textSize, ratio, minimumFontSize).toFloat() val sizeSpans = text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java) for (span in sizeSpans) { text.setSpan( - ReactAbsoluteSizeSpan(max((span.size * ratio).toInt(), minimumFontSize)), + ReactAbsoluteSizeSpan( + scaleFontSizeForFontSizeFit(span.size.toFloat(), ratio, minimumFontSize) + ), text.getSpanStart(span), text.getSpanEnd(span), text.getSpanFlags(span), @@ -1123,17 +1147,10 @@ internal object TextLayoutManager { break } - val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width - val exceedsHeight = - heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height - val exceedsMaximumNumberOfLines = - maximumNumberOfLines != ReactConstants.UNSET && - maximumNumberOfLines != 0 && - layout.lineCount > maximumNumberOfLines - if ( currentFontSize > minimumFontSize && - (exceedsMaximumNumberOfLines || exceedsHeight || singleLineTextExceedsWidth) + layoutExceedsFontSizeFitConstraints( + text, layout, width, height, heightYogaMeasureMode, maximumNumberOfLines) ) { // Text doesn't fit the constraints. If intervalEnd - intervalStart == 1, it's known that // the correct font size is intervalStart. Set intervalEnd to match intervalStart and do one @@ -1148,6 +1165,26 @@ internal object TextLayoutManager { } } + private fun layoutExceedsFontSizeFitConstraints( + text: Spannable, + layout: Layout, + width: Float, + height: Float, + heightYogaMeasureMode: YogaMeasureMode, + maximumNumberOfLines: Int, + ): Boolean = + (maximumNumberOfLines != ReactConstants.UNSET && + maximumNumberOfLines != 0 && + layout.lineCount > maximumNumberOfLines) || + (heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height) || + (text.length == 1 && layout.getLineWidth(0) > width) + + internal fun scaleFontSizeForFontSizeFit( + fontSize: Float, + ratio: Float, + minimumFontSize: Int, + ): Int = max((fontSize * ratio).roundToInt(), minimumFontSize) + @JvmStatic @OptIn(UnstableReactNativeAPI::class) fun measureText( diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAdjustFontSizeToFitTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAdjustFontSizeToFitTest.kt new file mode 100644 index 000000000000..61862e343440 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAdjustFontSizeToFitTest.kt @@ -0,0 +1,88 @@ +/* + * 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.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import com.facebook.react.common.ReactConstants +import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan +import com.facebook.yoga.YogaMeasureMode +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TextLayoutManagerAdjustFontSizeToFitTest { + + @Test + @SuppressLint("InlinedApi") + fun `adjustSpannableFontToFit keeps original font size when text already fits`() { + val text = spannableWithFontSize("Already fits", 22) + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 22f } + + adjustSpannableFontToFit( + text, + /* width = */ 1000f, + /* height = */ 1000f, + paint, + ) + + assertThat(paint.textSize).isEqualTo(22f) + assertThat(text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).single().size) + .isEqualTo(22) + } + + @Test + @SuppressLint("InlinedApi") + fun `adjustSpannableFontToFit does not compound rounding down while shrinking`() { + var scaledFontSize = 22 + var previousFontSize = 22 + + for (currentFontSize in listOf(13, 18, 20, 21, 22)) { + val ratio = currentFontSize.toFloat() / previousFontSize.toFloat() + scaledFontSize = + TextLayoutManager.scaleFontSizeForFontSizeFit(scaledFontSize.toFloat(), ratio, 4) + previousFontSize = currentFontSize + } + + assertThat(scaledFontSize).isEqualTo(22) + } + + private fun spannableWithFontSize(text: String, fontSize: Int): SpannableString = + SpannableString(text).apply { + setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + + @SuppressLint("InlinedApi") + private fun adjustSpannableFontToFit( + text: SpannableString, + width: Float, + height: Float, + paint: TextPaint, + ) { + TextLayoutManager.adjustSpannableFontToFit( + text, + width, + YogaMeasureMode.EXACTLY, + height, + YogaMeasureMode.EXACTLY, + /* minimumFontSizeAttr = */ 4f, + ReactConstants.UNSET, + /* includeFontPadding = */ false, + Layout.BREAK_STRATEGY_HIGH_QUALITY, + Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + paint, + ) + } +}