From 54176329a594bbbeb85f753033f045c38b536beb Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Thu, 17 Sep 2026 11:43:31 -0500 Subject: [PATCH 1/5] Fix minimumFontScale with adjustsFontSizeToFit on iOS in the New Architecture Fabric's ParagraphAttributes carried `minimumFontSize`, which no `` prop can set, so RCTTextLayoutManager always fell back to a 4pt floor and ignored `minimumFontScale`. Replace that field with the existing `minimumFontScale` (default 0) everywhere it is parsed, diffed and serialized, and derive the floor in RCTTextLayoutManager the way the legacy renderer did: MAX(minimumFontScale * largest font size in the attributed string, 4.0), reusing the largest-font-size helper that #58529 added. MapBuffer key 6 now carries the scale instead of an absolute size. Updates ParagraphAttributesTest for the removed field and adds an RNTester example exercising `minimumFontScale`. --- .../attributedstring/ParagraphAttributes.cpp | 7 ++-- .../attributedstring/ParagraphAttributes.h | 8 +---- .../renderer/attributedstring/conversions.h | 10 ++---- .../tests/ParagraphAttributesTest.cpp | 32 +++++++++---------- .../components/text/BaseParagraphProps.cpp | 6 ---- .../text/HostPlatformParagraphProps.cpp | 6 ---- .../textinput/BaseTextInputProps.cpp | 4 +-- .../AndroidTextInputProps.cpp | 6 ++-- .../textlayoutmanager/RCTTextLayoutManager.mm | 2 +- .../js/examples/Text/TextExample.ios.js | 8 +++++ 10 files changed, 36 insertions(+), 53 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp index 793acf9476c4..32711dbeb161 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp @@ -33,7 +33,6 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const { rhs.includeFontPadding, rhs.android_hyphenationFrequency, rhs.textAlignVertical) && - floatEquality(minimumFontSize, rhs.minimumFontSize) && floatEquality(minimumFontScale, rhs.minimumFontScale); } @@ -60,9 +59,9 @@ SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const { adjustsFontSizeToFit, paragraphAttributes.adjustsFontSizeToFit), debugStringConvertibleItem( - "minimumFontSize", - minimumFontSize, - paragraphAttributes.minimumFontSize), + "minimumFontScale", + minimumFontScale, + paragraphAttributes.minimumFontScale), debugStringConvertibleItem( "includeFontPadding", includeFontPadding, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h index 7cd8252d7c8f..4811d8063df2 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h @@ -67,16 +67,11 @@ class ParagraphAttributes : public DebugStringConvertible { */ HyphenationFrequency android_hyphenationFrequency{}; - /* - * In case of font size adjustment enabled, defines the minimum font size. - */ - Float minimumFontSize{std::numeric_limits::quiet_NaN()}; - /* * Specifies the smallest possible scale a font can reach when * adjustsFontSizeToFit is enabled. (values 0.01-1.0). */ - Float minimumFontScale{std::numeric_limits::quiet_NaN()}; + Float minimumFontScale{0.0}; /* * The vertical alignment of the text, causing the glyphs to be vertically @@ -107,7 +102,6 @@ struct hash { attributes.textBreakStrategy, attributes.textWidthMode, attributes.adjustsFontSizeToFit, - attributes.minimumFontSize, attributes.includeFontPadding, attributes.android_hyphenationFrequency, attributes.minimumFontScale, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h index e77b21d33653..ed876b51d45c 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1085,12 +1085,6 @@ inline ParagraphAttributes convertRawProp( "minimumFontScale", sourceParagraphAttributes.minimumFontScale, defaultParagraphAttributes.minimumFontScale); - paragraphAttributes.minimumFontSize = convertRawProp( - context, - rawProps, - "minimumFontSize", - sourceParagraphAttributes.minimumFontSize, - defaultParagraphAttributes.minimumFontSize); paragraphAttributes.includeFontPadding = convertRawProp( context, rawProps, @@ -1193,7 +1187,7 @@ constexpr static MapBuffer::Key PA_KEY_TEXT_BREAK_STRATEGY = 2; constexpr static MapBuffer::Key PA_KEY_ADJUST_FONT_SIZE_TO_FIT = 3; constexpr static MapBuffer::Key PA_KEY_INCLUDE_FONT_PADDING = 4; constexpr static MapBuffer::Key PA_KEY_HYPHENATION_FREQUENCY = 5; -constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SIZE = 6; +constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SCALE = 6; constexpr static MapBuffer::Key PA_KEY_TEXT_ALIGN_VERTICAL = 8; constexpr static MapBuffer::Key PA_KEY_TEXT_WIDTH_MODE = 9; @@ -1210,7 +1204,7 @@ inline MapBuffer toMapBuffer(const ParagraphAttributes ¶graphAttributes) if (paragraphAttributes.textAlignVertical.has_value()) { builder.putString(PA_KEY_TEXT_ALIGN_VERTICAL, toString(*paragraphAttributes.textAlignVertical)); } - builder.putDouble(PA_KEY_MINIMUM_FONT_SIZE, paragraphAttributes.minimumFontSize); + builder.putDouble(PA_KEY_MINIMUM_FONT_SCALE, paragraphAttributes.minimumFontScale); return builder.build(); } diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp index e3aa2c29814e..0a084379091e 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp @@ -9,11 +9,11 @@ #include #include +#include + namespace facebook::react { -// The two Float fields default to NaN, and NaN != NaN under IEEE-754. -// operator== must special-case NaN via floatEquality so two freshly -// default-constructed ParagraphAttributes compare equal. +// Two freshly default-constructed ParagraphAttributes must compare equal. TEST( ParagraphAttributesTest, testOperatorEqualsDefaultConstructedInstancesAreEqual) { @@ -23,36 +23,36 @@ TEST( EXPECT_TRUE(a == b); } -// operator== compares Float fields with an epsilon tolerance (0.005) rather -// than an exact ==. Differences below the epsilon must still compare equal; -// differences well above the epsilon must compare unequal. +// operator== compares minimumFontScale with an epsilon tolerance (0.005) +// rather than an exact ==. Differences below the epsilon must still compare +// equal; differences well above the epsilon must compare unequal. TEST( ParagraphAttributesTest, testOperatorEqualsFloatFieldsUseEpsilonComparison) { ParagraphAttributes a{}; - a.minimumFontSize = 12.0f; a.minimumFontScale = 0.5f; auto b = a; - b.minimumFontSize = a.minimumFontSize + 0.001f; b.minimumFontScale = a.minimumFontScale + 0.001f; EXPECT_TRUE(a == b); b = a; - b.minimumFontSize = a.minimumFontSize + 1.0f; + b.minimumFontScale = a.minimumFontScale + 0.1f; EXPECT_FALSE(a == b); } // floatEquality returns true only when *both* operands are NaN or when -// *neither* is. A NaN-vs-finite mismatch in either float field must -// therefore make the instances unequal, even though both operands are -// "invalid" font sizes. -TEST( - ParagraphAttributesTest, - testOperatorEqualsNaNVsFiniteFloatComparesUnequal) { +// *neither* is. Two NaN minimumFontScale values must compare equal, and a +// NaN-vs-finite mismatch must compare unequal. +TEST(ParagraphAttributesTest, testOperatorEqualsHandlesNaNMinimumFontScale) { ParagraphAttributes withNaN{}; + withNaN.minimumFontScale = std::numeric_limits::quiet_NaN(); + auto otherWithNaN = withNaN; + + EXPECT_TRUE(withNaN == otherWithNaN); + ParagraphAttributes withFinite{}; - withFinite.minimumFontSize = 12.0f; + withFinite.minimumFontScale = 0.5f; EXPECT_FALSE(withNaN == withFinite); } diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp index dbe0ba096cbe..deb1c44c5bfa 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp @@ -98,12 +98,6 @@ void BaseParagraphProps::setProp( paragraphAttributes, minimumFontScale, "minimumFontScale"); - REBUILD_FIELD_SWITCH_CASE( - paDefaults, - value, - paragraphAttributes, - minimumFontSize, - "minimumFontSize"); REBUILD_FIELD_SWITCH_CASE( paDefaults, value, diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp index ee668b4d4f26..a56f60823459 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp @@ -122,12 +122,6 @@ folly::dynamic HostPlatformParagraphProps::getDiffProps( result["minimumFontScale"] = paragraphAttributes.minimumFontScale; } - if (!floatEquality( - paragraphAttributes.minimumFontSize, - oldProps->paragraphAttributes.minimumFontSize)) { - result["minimumFontSize"] = paragraphAttributes.minimumFontSize; - } - if (paragraphAttributes.includeFontPadding != oldProps->paragraphAttributes.includeFontPadding) { result["includeFontPadding"] = paragraphAttributes.includeFontPadding; diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp index c1ae0758f3c7..3c8f35198473 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp @@ -177,8 +177,8 @@ void BaseTextInputProps::setProp( paDefaults, value, paragraphAttributes, - minimumFontSize, - "minimumFontSize"); + minimumFontScale, + "minimumFontScale"); REBUILD_FIELD_SWITCH_CASE( paDefaults, value, diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp index ce2a3ac666e6..0aa01288075a 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp @@ -390,9 +390,9 @@ folly::dynamic AndroidTextInputProps::getDiffProps( } if (!floatEquality( - paragraphAttributes.minimumFontSize, - oldProps->paragraphAttributes.minimumFontSize)) { - result["minimumFontSize"] = paragraphAttributes.minimumFontSize; + paragraphAttributes.minimumFontScale, + oldProps->paragraphAttributes.minimumFontScale)) { + result["minimumFontScale"] = paragraphAttributes.minimumFontScale; } if (paragraphAttributes.includeFontPadding != diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index af5b225e8e7e..c0cab8e63ce3 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -453,8 +453,8 @@ - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttribute [textStorage addLayoutManager:layoutManager]; if (paragraphAttributes.adjustsFontSizeToFit) { - CGFloat minimumFontSize = !isnan(paragraphAttributes.minimumFontSize) ? paragraphAttributes.minimumFontSize : 4.0; CGFloat maximumFontSize = [self _maximumFontSizeInAttributedString:attributedString]; + CGFloat minimumFontSize = MAX(paragraphAttributes.minimumFontScale * maximumFontSize, 4.0); [textStorage scaleFontSizeToFitSize:size minimumFontSize:minimumFontSize maximumFontSize:maximumFontSize]; } diff --git a/packages/rn-tester/js/examples/Text/TextExample.ios.js b/packages/rn-tester/js/examples/Text/TextExample.ios.js index 5de344e2aed7..e367974e3da2 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.ios.js +++ b/packages/rn-tester/js/examples/Text/TextExample.ios.js @@ -212,6 +212,14 @@ class AdjustingFontSize extends React.Component< Shrinking to fit available space is much better! + + Can limit how small the text becomes with minimumFontScale + + Date: Thu, 17 Sep 2026 11:43:57 -0500 Subject: [PATCH 2/5] Honor minimumFontScale with adjustsFontSizeToFit on Android in the New Architecture TextLayoutManager read MapBuffer key 6 as an absolute minimum font size in pixels and fell back to 4dp when it was NaN. Since nothing on the JS side ever set `minimumFontSize`, Android always used the 4dp floor and `minimumFontScale` was ignored. Key 6 now carries `minimumFontScale`, so adjustSpannableFontToFit() finds the largest ReactAbsoluteSizeSpan first and derives the floor as max(minimumFontScale * largestFontSize, 4dp), matching iOS and the formula the original Android implementation (#26389) used. A NaN or non-positive scale keeps the bare 4dp floor. Both the measurement path and the view path go through this one function, so only its interpretation of the value changes. Renames PA_KEY_MINIMUM_FONT_SIZE to PA_KEY_MINIMUM_FONT_SCALE and renames ReactTextView.setMinimumFontSize() to setMinimumFontScale() (public API dump updated). Adds Robolectric coverage for the floor computation, an RNTester example, and drops the `@platform ios` annotation from the `minimumFontScale` prop docs. --- .../react-native/Libraries/Text/TextProps.js | 2 - .../ReactAndroid/api/ReactAndroid.api | 2 +- .../react/views/text/ReactTextView.java | 10 +- .../react/views/text/ReactTextViewManager.kt | 6 +- .../react/views/text/TextLayoutManager.kt | 26 +-- .../react/views/text/ReactTextViewTest.kt | 15 +- .../TextLayoutManagerMinimumFontScaleTest.kt | 150 ++++++++++++++++++ .../js/examples/Text/TextExample.android.js | 8 + 8 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt diff --git a/packages/react-native/Libraries/Text/TextProps.js b/packages/react-native/Libraries/Text/TextProps.js index 38e9899f2dfc..c8f28ddc4b49 100644 --- a/packages/react-native/Libraries/Text/TextProps.js +++ b/packages/react-native/Libraries/Text/TextProps.js @@ -122,8 +122,6 @@ export type TextPropsAndroid = { /** * Smallest possible font scale when `adjustsFontSizeToFit` is enabled * (values 0.01-1.0). - * - * @platform ios */ minimumFontScale?: ?number, }; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 29a04dfbfc07..47d4db4803ac 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6040,7 +6040,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi public fun setIncludeFontPadding (Z)V public fun setLetterSpacing (F)V public fun setLinkifyMask (I)V - public fun setMinimumFontSize (F)V + public fun setMinimumFontScale (F)V public fun setNumberOfLines (I)V public fun setOverflow (Ljava/lang/String;)V public fun setSpanned (Landroid/text/Spannable;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index eed89a0aa568..595b605efcc3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -71,7 +71,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private @Nullable TextUtils.TruncateAt mEllipsizeLocation; private boolean mAdjustsFontSizeToFit; private float mFontSize; - private float mMinimumFontSize; + private float mMinimumFontScale; private float mLetterSpacing; private int mLinkifyMaskType; private boolean mTextIsSelectable; @@ -132,7 +132,7 @@ private void initView() { mShouldAdjustSpannableFontSize = false; mEllipsizeLocation = TextUtils.TruncateAt.END; mFontSize = Float.NaN; - mMinimumFontSize = Float.NaN; + mMinimumFontScale = Float.NaN; mLetterSpacing = 0.f; mOverflow = Overflow.VISIBLE; mSpanned = null; @@ -238,7 +238,7 @@ protected void onDraw(Canvas canvas) { YogaMeasureMode.EXACTLY, getHeight(), YogaMeasureMode.EXACTLY, - mMinimumFontSize, + mMinimumFontScale, mNumberOfLines, getIncludeFontPadding(), getBreakStrategy(), @@ -540,8 +540,8 @@ public void setFontSize(float fontSize) { applyTextAttributes(); } - public void setMinimumFontSize(float minimumFontSize) { - mMinimumFontSize = minimumFontSize; + public void setMinimumFontScale(float minimumFontScale) { + mMinimumFontScale = minimumFontScale; mShouldAdjustSpannableFontSize = true; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 044fa7923c6a..a0748fcf71c9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -169,9 +169,9 @@ public constructor( ) view.setSpanned(spanned) - val minimumFontSize: Float = - paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SIZE).toFloat() - view.setMinimumFontSize(minimumFontSize) + val minimumFontScale: Float = + paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SCALE).toFloat() + view.setMinimumFontScale(minimumFontScale) // Clear any stale PreparedLayout from a previous update view.setPreparedLayout(null) 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 53a67377158f..c419aa97f7f3 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 @@ -95,7 +95,7 @@ internal object TextLayoutManager { const val PA_KEY_ADJUST_FONT_SIZE_TO_FIT: Int = 3 const val PA_KEY_INCLUDE_FONT_PADDING: Int = 4 const val PA_KEY_HYPHENATION_FREQUENCY: Int = 5 - const val PA_KEY_MINIMUM_FONT_SIZE: Int = 6 + const val PA_KEY_MINIMUM_FONT_SCALE: Int = 6 const val PA_KEY_TEXT_ALIGN_VERTICAL: Int = 8 const val PA_KEY_TEXT_WIDTH_MODE: Int = 9 @@ -1167,9 +1167,9 @@ internal object TextLayoutManager { val justificationMode = getTextJustificationMode(alignmentAttr) if (adjustFontSizeToFit) { - val minimumFontSize = - if (paragraphAttributes.contains(PA_KEY_MINIMUM_FONT_SIZE)) - paragraphAttributes.getDouble(PA_KEY_MINIMUM_FONT_SIZE).toFloat() + val minimumFontScale = + if (paragraphAttributes.contains(PA_KEY_MINIMUM_FONT_SCALE)) + paragraphAttributes.getDouble(PA_KEY_MINIMUM_FONT_SCALE).toFloat() else Float.NaN adjustSpannableFontToFit( @@ -1178,7 +1178,7 @@ internal object TextLayoutManager { YogaMeasureMode.EXACTLY, height, heightYogaMeasureMode, - minimumFontSize, + minimumFontScale, maximumNumberOfLines, includeFontPadding, textBreakStrategy, @@ -1337,7 +1337,7 @@ internal object TextLayoutManager { widthYogaMeasureMode: YogaMeasureMode, height: Float, heightYogaMeasureMode: YogaMeasureMode, - minimumFontSizeAttr: Float, + minimumFontScale: Float, maximumNumberOfLines: Int, includeFontPadding: Boolean, textBreakStrategy: Int, @@ -1349,17 +1349,21 @@ internal object TextLayoutManager { var boring = isBoring(text, paint) var layout: Layout - // Minimum font size is 4pts to match the iOS implementation. - val minimumFontSize = - (if (minimumFontSizeAttr.isNaN()) 4.dpToPx() else minimumFontSizeAttr).toInt() - // Find the largest font size used in the spannable to use as a starting point. - var currentFontSize = minimumFontSize + var currentFontSize = 0 val spans = text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java) for (span in spans) { currentFontSize = max(currentFontSize, span.size) } + // The smallest font size is the largest font size scaled by minimumFontScale, floored at 4dp + // to match the iOS implementation. + val absoluteMinimumFontSize = 4.dpToPx().toInt() + val minimumFontSize = + if (minimumFontScale.isNaN() || minimumFontScale <= 0f) absoluteMinimumFontSize + else max((minimumFontScale * currentFontSize).toInt(), absoluteMinimumFontSize) + currentFontSize = max(currentFontSize, minimumFontSize) + var intervalStart = minimumFontSize var intervalEnd = currentFontSize var previousFontSize = currentFontSize diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt index a6178e923d67..4deaf2daa543 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt @@ -21,8 +21,11 @@ import android.view.View import android.view.ViewGroup import androidx.core.graphics.createBitmap import androidx.core.graphics.get +import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan 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 @@ -32,6 +35,16 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class ReactTextViewTest { + @Before + fun setUp() { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + @Test fun drawsGlyphInkOutsideLineHeightWhenOverflowIsVisible() { val bitmap = drawReactTextViewWithOverflow(null) @@ -70,7 +83,7 @@ class ReactTextViewTest { ViewGroup.LayoutParams.WRAP_CONTENT, ) view.setTextColor(Color.BLACK) - view.setMinimumFontSize(4f) + view.setMinimumFontScale(0.1f) view.setNumberOfLines(0) view.setAdjustFontSizeToFit(true) view.setSpanned(text) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt new file mode 100644 index 000000000000..5fab4ab18c49 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt @@ -0,0 +1,150 @@ +/* + * 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.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.PixelUtil.dpToPx +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 + +@RunWith(RobolectricTestRunner::class) +class TextLayoutManagerMinimumFontScaleTest { + + @Before + fun setUp() { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun `minimumFontScale limits how far the font shrinks relative to the largest font size`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + adjustToUnsatisfiableHeight(text, minimumFontScale = 0.5f) + + assertThat(largestFontSize(text)).isEqualTo((LARGE_FONT_SIZE * 0.5f).toInt()) + } + + @Test + fun `minimumFontScale is applied to the largest font size in the spannable`() { + val text = SpannableString("Small text and LARGE TEXT") + text.setSpan(ReactAbsoluteSizeSpan(SMALL_FONT_SIZE), 0, 14, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan( + ReactAbsoluteSizeSpan(LARGE_FONT_SIZE), + 15, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + adjustToUnsatisfiableHeight(text, minimumFontScale = 0.5f) + + assertThat(largestFontSize(text)).isEqualTo((LARGE_FONT_SIZE * 0.5f).toInt()) + } + + @Test + fun `missing minimumFontScale shrinks down to the 4dp floor`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + adjustToUnsatisfiableHeight(text, minimumFontScale = Float.NaN) + + assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt()) + } + + @Test + fun `zero minimumFontScale shrinks down to the 4dp floor`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + adjustToUnsatisfiableHeight(text, minimumFontScale = 0f) + + assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt()) + } + + @Test + fun `minimumFontScale never shrinks below the 4dp floor`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + adjustToUnsatisfiableHeight(text, minimumFontScale = 0.01f) + + assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt()) + } + + @Test + fun `text that already fits is not shrunk`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + TextLayoutManager.adjustSpannableFontToFit( + text, + 10_000f, + YogaMeasureMode.EXACTLY, + 10_000f, + YogaMeasureMode.EXACTLY, + 0.5f, + ReactConstants.UNSET, + true, + Layout.BREAK_STRATEGY_SIMPLE, + Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + 0, + newPaint(), + ) + + assertThat(largestFontSize(text)).isEqualTo(LARGE_FONT_SIZE) + } + + // Uses a height no font size can satisfy so the text is shrunk all the way to the minimum. + private fun adjustToUnsatisfiableHeight(text: SpannableString, minimumFontScale: Float) { + TextLayoutManager.adjustSpannableFontToFit( + text, + 10_000f, + YogaMeasureMode.EXACTLY, + 1f, + YogaMeasureMode.EXACTLY, + minimumFontScale, + ReactConstants.UNSET, + true, + Layout.BREAK_STRATEGY_SIMPLE, + Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + 0, + newPaint(), + ) + } + + private fun spannableWithFontSize(fontSize: Int): SpannableString { + val text = SpannableString("Hello") + text.setSpan(ReactAbsoluteSizeSpan(fontSize), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + return text + } + + private fun newPaint(): TextPaint = + TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = LARGE_FONT_SIZE.toFloat() } + + private fun largestFontSize(text: Spanned): Int = + text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).maxOfOrNull { it.size } ?: 0 + + private companion object { + const val SMALL_FONT_SIZE = 10 + const val LARGE_FONT_SIZE = 40 + } +} diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js index 7be080f10715..39b1c94aa248 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.android.js +++ b/packages/rn-tester/js/examples/Text/TextExample.android.js @@ -154,6 +154,14 @@ class AdjustingFontSize extends React.Component< Shrinking to fit available space is much better! + + Can limit how small the text becomes with minimumFontScale + + Date: Mon, 14 Sep 2026 14:31:57 -0500 Subject: [PATCH 3/5] Keep minimumFontSize and serialize minimumFontScale under a new MapBuffer key --- .../attributedstring/ParagraphAttributes.cpp | 5 +++ .../attributedstring/ParagraphAttributes.h | 9 +++- .../renderer/attributedstring/conversions.h | 10 ++++- .../tests/ParagraphAttributesTest.cpp | 44 ++++++++++++------- .../components/text/BaseParagraphProps.cpp | 6 +++ .../text/HostPlatformParagraphProps.cpp | 6 +++ .../textinput/BaseTextInputProps.cpp | 4 +- .../AndroidTextInputProps.cpp | 6 +-- .../textlayoutmanager/RCTTextLayoutManager.mm | 7 ++- 9 files changed, 73 insertions(+), 24 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp index 32711dbeb161..af4c92f85611 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp @@ -33,6 +33,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const { rhs.includeFontPadding, rhs.android_hyphenationFrequency, rhs.textAlignVertical) && + floatEquality(minimumFontSize, rhs.minimumFontSize) && floatEquality(minimumFontScale, rhs.minimumFontScale); } @@ -62,6 +63,10 @@ SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const { "minimumFontScale", minimumFontScale, paragraphAttributes.minimumFontScale), + debugStringConvertibleItem( + "minimumFontSize", + minimumFontSize, + paragraphAttributes.minimumFontSize), debugStringConvertibleItem( "includeFontPadding", includeFontPadding, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h index 4811d8063df2..6ec769a5dcd0 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h @@ -67,11 +67,17 @@ class ParagraphAttributes : public DebugStringConvertible { */ HyphenationFrequency android_hyphenationFrequency{}; + /* + * In case of font size adjustment enabled, defines the minimum font size. + * Deprecated in favor of minimumFontScale. + */ + Float minimumFontSize{std::numeric_limits::quiet_NaN()}; + /* * Specifies the smallest possible scale a font can reach when * adjustsFontSizeToFit is enabled. (values 0.01-1.0). */ - Float minimumFontScale{0.0}; + Float minimumFontScale{std::numeric_limits::quiet_NaN()}; /* * The vertical alignment of the text, causing the glyphs to be vertically @@ -102,6 +108,7 @@ struct hash { attributes.textBreakStrategy, attributes.textWidthMode, attributes.adjustsFontSizeToFit, + attributes.minimumFontSize, attributes.includeFontPadding, attributes.android_hyphenationFrequency, attributes.minimumFontScale, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h index ed876b51d45c..42c0dab9378b 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1085,6 +1085,12 @@ inline ParagraphAttributes convertRawProp( "minimumFontScale", sourceParagraphAttributes.minimumFontScale, defaultParagraphAttributes.minimumFontScale); + paragraphAttributes.minimumFontSize = convertRawProp( + context, + rawProps, + "minimumFontSize", + sourceParagraphAttributes.minimumFontSize, + defaultParagraphAttributes.minimumFontSize); paragraphAttributes.includeFontPadding = convertRawProp( context, rawProps, @@ -1187,9 +1193,10 @@ constexpr static MapBuffer::Key PA_KEY_TEXT_BREAK_STRATEGY = 2; constexpr static MapBuffer::Key PA_KEY_ADJUST_FONT_SIZE_TO_FIT = 3; constexpr static MapBuffer::Key PA_KEY_INCLUDE_FONT_PADDING = 4; constexpr static MapBuffer::Key PA_KEY_HYPHENATION_FREQUENCY = 5; -constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SCALE = 6; +constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SIZE = 6; constexpr static MapBuffer::Key PA_KEY_TEXT_ALIGN_VERTICAL = 8; constexpr static MapBuffer::Key PA_KEY_TEXT_WIDTH_MODE = 9; +constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SCALE = 10; inline MapBuffer toMapBuffer(const ParagraphAttributes ¶graphAttributes) { @@ -1204,6 +1211,7 @@ inline MapBuffer toMapBuffer(const ParagraphAttributes ¶graphAttributes) if (paragraphAttributes.textAlignVertical.has_value()) { builder.putString(PA_KEY_TEXT_ALIGN_VERTICAL, toString(*paragraphAttributes.textAlignVertical)); } + builder.putDouble(PA_KEY_MINIMUM_FONT_SIZE, paragraphAttributes.minimumFontSize); builder.putDouble(PA_KEY_MINIMUM_FONT_SCALE, paragraphAttributes.minimumFontScale); return builder.build(); diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp index 0a084379091e..3478df3f838b 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp @@ -9,11 +9,11 @@ #include #include -#include - namespace facebook::react { -// Two freshly default-constructed ParagraphAttributes must compare equal. +// The two Float fields default to NaN, and NaN != NaN under IEEE-754. +// operator== must special-case NaN via floatEquality so two freshly +// default-constructed ParagraphAttributes compare equal. TEST( ParagraphAttributesTest, testOperatorEqualsDefaultConstructedInstancesAreEqual) { @@ -23,40 +23,52 @@ TEST( EXPECT_TRUE(a == b); } -// operator== compares minimumFontScale with an epsilon tolerance (0.005) -// rather than an exact ==. Differences below the epsilon must still compare -// equal; differences well above the epsilon must compare unequal. +// operator== compares Float fields with an epsilon tolerance (0.005) rather +// than an exact ==. Differences below the epsilon must still compare equal; +// differences well above the epsilon must compare unequal. TEST( ParagraphAttributesTest, testOperatorEqualsFloatFieldsUseEpsilonComparison) { ParagraphAttributes a{}; + a.minimumFontSize = 12.0f; a.minimumFontScale = 0.5f; auto b = a; + b.minimumFontSize = a.minimumFontSize + 0.001f; b.minimumFontScale = a.minimumFontScale + 0.001f; EXPECT_TRUE(a == b); b = a; - b.minimumFontScale = a.minimumFontScale + 0.1f; + b.minimumFontSize = a.minimumFontSize + 1.0f; EXPECT_FALSE(a == b); } // floatEquality returns true only when *both* operands are NaN or when -// *neither* is. Two NaN minimumFontScale values must compare equal, and a -// NaN-vs-finite mismatch must compare unequal. -TEST(ParagraphAttributesTest, testOperatorEqualsHandlesNaNMinimumFontScale) { +// *neither* is. A NaN-vs-finite mismatch in either float field must +// therefore make the instances unequal, even though both operands are +// "invalid" font sizes. +TEST( + ParagraphAttributesTest, + testOperatorEqualsNaNVsFiniteFloatComparesUnequal) { ParagraphAttributes withNaN{}; - withNaN.minimumFontScale = std::numeric_limits::quiet_NaN(); - auto otherWithNaN = withNaN; - - EXPECT_TRUE(withNaN == otherWithNaN); - ParagraphAttributes withFinite{}; - withFinite.minimumFontScale = 0.5f; + withFinite.minimumFontSize = 12.0f; EXPECT_FALSE(withNaN == withFinite); } +// minimumFontScale defaults to NaN, meaning "unset"; an instance with a scale +// must compare unequal to one without. +TEST( + ParagraphAttributesTest, + testOperatorEqualsNaNVsFiniteMinimumFontScaleComparesUnequal) { + ParagraphAttributes unset{}; + ParagraphAttributes withScale{}; + withScale.minimumFontScale = 0.5f; + + EXPECT_FALSE(unset == withScale); +} + // textAlignVertical is a std::optional; operator== must treat "unset" and // "set" as distinct, independent of the wrapped value. TEST( diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp index deb1c44c5bfa..dbe0ba096cbe 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp @@ -98,6 +98,12 @@ void BaseParagraphProps::setProp( paragraphAttributes, minimumFontScale, "minimumFontScale"); + REBUILD_FIELD_SWITCH_CASE( + paDefaults, + value, + paragraphAttributes, + minimumFontSize, + "minimumFontSize"); REBUILD_FIELD_SWITCH_CASE( paDefaults, value, diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp index a56f60823459..ee668b4d4f26 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/android/react/renderer/components/text/HostPlatformParagraphProps.cpp @@ -122,6 +122,12 @@ folly::dynamic HostPlatformParagraphProps::getDiffProps( result["minimumFontScale"] = paragraphAttributes.minimumFontScale; } + if (!floatEquality( + paragraphAttributes.minimumFontSize, + oldProps->paragraphAttributes.minimumFontSize)) { + result["minimumFontSize"] = paragraphAttributes.minimumFontSize; + } + if (paragraphAttributes.includeFontPadding != oldProps->paragraphAttributes.includeFontPadding) { result["includeFontPadding"] = paragraphAttributes.includeFontPadding; diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp index 3c8f35198473..c1ae0758f3c7 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp @@ -177,8 +177,8 @@ void BaseTextInputProps::setProp( paDefaults, value, paragraphAttributes, - minimumFontScale, - "minimumFontScale"); + minimumFontSize, + "minimumFontSize"); REBUILD_FIELD_SWITCH_CASE( paDefaults, value, diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp index 0aa01288075a..ce2a3ac666e6 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp @@ -390,9 +390,9 @@ folly::dynamic AndroidTextInputProps::getDiffProps( } if (!floatEquality( - paragraphAttributes.minimumFontScale, - oldProps->paragraphAttributes.minimumFontScale)) { - result["minimumFontScale"] = paragraphAttributes.minimumFontScale; + paragraphAttributes.minimumFontSize, + oldProps->paragraphAttributes.minimumFontSize)) { + result["minimumFontSize"] = paragraphAttributes.minimumFontSize; } if (paragraphAttributes.includeFontPadding != diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index c0cab8e63ce3..6a171457a679 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -454,7 +454,12 @@ - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttribute if (paragraphAttributes.adjustsFontSizeToFit) { CGFloat maximumFontSize = [self _maximumFontSizeInAttributedString:attributedString]; - CGFloat minimumFontSize = MAX(paragraphAttributes.minimumFontScale * maximumFontSize, 4.0); + CGFloat minimumFontSize = 4.0; + if (!isnan(paragraphAttributes.minimumFontSize)) { + minimumFontSize = paragraphAttributes.minimumFontSize; + } else if (!isnan(paragraphAttributes.minimumFontScale)) { + minimumFontSize = MAX(paragraphAttributes.minimumFontScale * maximumFontSize, 4.0); + } [textStorage scaleFontSizeToFitSize:size minimumFontSize:minimumFontSize maximumFontSize:maximumFontSize]; } From a86976ea380411d2c2bb6449cc005e2190e30de2 Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Mon, 14 Sep 2026 14:31:59 -0500 Subject: [PATCH 4/5] Restore ReactTextView.setMinimumFontSize() as deprecated and add setMinimumFontScale() --- .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/views/text/ReactTextView.java | 12 +++++++++ .../react/views/text/ReactTextViewManager.kt | 7 ++++- .../react/views/text/TextLayoutManager.kt | 21 +++++++++++---- .../TextLayoutManagerMinimumFontScaleTest.kt | 26 ++++++++++++++++++- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 47d4db4803ac..ff33c2d5bdd6 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6041,6 +6041,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi public fun setLetterSpacing (F)V public fun setLinkifyMask (I)V public fun setMinimumFontScale (F)V + public fun setMinimumFontSize (F)V public fun setNumberOfLines (I)V public fun setOverflow (Ljava/lang/String;)V public fun setSpanned (Landroid/text/Spannable;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 595b605efcc3..677eb472660b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -71,6 +71,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private @Nullable TextUtils.TruncateAt mEllipsizeLocation; private boolean mAdjustsFontSizeToFit; private float mFontSize; + private float mMinimumFontSize; private float mMinimumFontScale; private float mLetterSpacing; private int mLinkifyMaskType; @@ -132,6 +133,7 @@ private void initView() { mShouldAdjustSpannableFontSize = false; mEllipsizeLocation = TextUtils.TruncateAt.END; mFontSize = Float.NaN; + mMinimumFontSize = Float.NaN; mMinimumFontScale = Float.NaN; mLetterSpacing = 0.f; mOverflow = Overflow.VISIBLE; @@ -238,6 +240,7 @@ protected void onDraw(Canvas canvas) { YogaMeasureMode.EXACTLY, getHeight(), YogaMeasureMode.EXACTLY, + mMinimumFontSize, mMinimumFontScale, mNumberOfLines, getIncludeFontPadding(), @@ -540,6 +543,15 @@ public void setFontSize(float fontSize) { applyTextAttributes(); } + /** + * @deprecated Use {@link #setMinimumFontScale(float)} instead. + */ + @Deprecated + public void setMinimumFontSize(float minimumFontSize) { + mMinimumFontSize = minimumFontSize; + mShouldAdjustSpannableFontSize = true; + } + public void setMinimumFontScale(float minimumFontScale) { mMinimumFontScale = minimumFontScale; mShouldAdjustSpannableFontSize = true; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index a0748fcf71c9..6eaf70f6f24e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -169,8 +169,13 @@ public constructor( ) view.setSpanned(spanned) + val minimumFontSize: Float = + paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SIZE).toFloat() + @Suppress("DEPRECATION") view.setMinimumFontSize(minimumFontSize) val minimumFontScale: Float = - paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SCALE).toFloat() + if (paragraphAttributes.contains(TextLayoutManager.PA_KEY_MINIMUM_FONT_SCALE)) + paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SCALE).toFloat() + else Float.NaN view.setMinimumFontScale(minimumFontScale) // Clear any stale PreparedLayout from a previous update 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 c419aa97f7f3..d9c28ca5fa20 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 @@ -95,9 +95,10 @@ internal object TextLayoutManager { const val PA_KEY_ADJUST_FONT_SIZE_TO_FIT: Int = 3 const val PA_KEY_INCLUDE_FONT_PADDING: Int = 4 const val PA_KEY_HYPHENATION_FREQUENCY: Int = 5 - const val PA_KEY_MINIMUM_FONT_SCALE: Int = 6 + const val PA_KEY_MINIMUM_FONT_SIZE: Int = 6 const val PA_KEY_TEXT_ALIGN_VERTICAL: Int = 8 const val PA_KEY_TEXT_WIDTH_MODE: Int = 9 + const val PA_KEY_MINIMUM_FONT_SCALE: Int = 10 private val TAG: String = TextLayoutManager::class.java.simpleName @@ -1167,6 +1168,10 @@ internal object TextLayoutManager { val justificationMode = getTextJustificationMode(alignmentAttr) if (adjustFontSizeToFit) { + val minimumFontSize = + if (paragraphAttributes.contains(PA_KEY_MINIMUM_FONT_SIZE)) + paragraphAttributes.getDouble(PA_KEY_MINIMUM_FONT_SIZE).toFloat() + else Float.NaN val minimumFontScale = if (paragraphAttributes.contains(PA_KEY_MINIMUM_FONT_SCALE)) paragraphAttributes.getDouble(PA_KEY_MINIMUM_FONT_SCALE).toFloat() @@ -1178,6 +1183,7 @@ internal object TextLayoutManager { YogaMeasureMode.EXACTLY, height, heightYogaMeasureMode, + minimumFontSize, minimumFontScale, maximumNumberOfLines, includeFontPadding, @@ -1337,6 +1343,7 @@ internal object TextLayoutManager { widthYogaMeasureMode: YogaMeasureMode, height: Float, heightYogaMeasureMode: YogaMeasureMode, + minimumFontSizeAttr: Float, minimumFontScale: Float, maximumNumberOfLines: Int, includeFontPadding: Boolean, @@ -1356,12 +1363,16 @@ internal object TextLayoutManager { currentFontSize = max(currentFontSize, span.size) } - // The smallest font size is the largest font size scaled by minimumFontScale, floored at 4dp - // to match the iOS implementation. + // An explicit minimum font size wins over minimumFontScale, which is applied to the largest + // font size in the spannable. The 4dp floor matches the iOS implementation. val absoluteMinimumFontSize = 4.dpToPx().toInt() val minimumFontSize = - if (minimumFontScale.isNaN() || minimumFontScale <= 0f) absoluteMinimumFontSize - else max((minimumFontScale * currentFontSize).toInt(), absoluteMinimumFontSize) + when { + !minimumFontSizeAttr.isNaN() -> minimumFontSizeAttr.toInt() + !minimumFontScale.isNaN() && minimumFontScale > 0f -> + max((minimumFontScale * currentFontSize).toInt(), absoluteMinimumFontSize) + else -> absoluteMinimumFontSize + } currentFontSize = max(currentFontSize, minimumFontSize) var intervalStart = minimumFontSize diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt index 5fab4ab18c49..1fba91156cb6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt @@ -89,6 +89,24 @@ class TextLayoutManagerMinimumFontScaleTest { assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt()) } + @Test + fun `explicit minimumFontSize is used as the floor`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + adjustToUnsatisfiableHeight(text, minimumFontSize = 12f, minimumFontScale = Float.NaN) + + assertThat(largestFontSize(text)).isEqualTo(12) + } + + @Test + fun `explicit minimumFontSize takes precedence over minimumFontScale`() { + val text = spannableWithFontSize(LARGE_FONT_SIZE) + + adjustToUnsatisfiableHeight(text, minimumFontSize = 12f, minimumFontScale = 0.5f) + + assertThat(largestFontSize(text)).isEqualTo(12) + } + @Test fun `text that already fits is not shrunk`() { val text = spannableWithFontSize(LARGE_FONT_SIZE) @@ -99,6 +117,7 @@ class TextLayoutManagerMinimumFontScaleTest { YogaMeasureMode.EXACTLY, 10_000f, YogaMeasureMode.EXACTLY, + Float.NaN, 0.5f, ReactConstants.UNSET, true, @@ -113,13 +132,18 @@ class TextLayoutManagerMinimumFontScaleTest { } // Uses a height no font size can satisfy so the text is shrunk all the way to the minimum. - private fun adjustToUnsatisfiableHeight(text: SpannableString, minimumFontScale: Float) { + private fun adjustToUnsatisfiableHeight( + text: SpannableString, + minimumFontScale: Float, + minimumFontSize: Float = Float.NaN, + ) { TextLayoutManager.adjustSpannableFontToFit( text, 10_000f, YogaMeasureMode.EXACTLY, 1f, YogaMeasureMode.EXACTLY, + minimumFontSize, minimumFontScale, ReactConstants.UNSET, true, From cfa5c054031f576efa8a36275a508a571f514dd3 Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Mon, 14 Sep 2026 18:53:39 -0500 Subject: [PATCH 5/5] Update C++ API snapshots for PA_KEY_MINIMUM_FONT_SCALE --- scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api | 1 + 3 files changed, 3 insertions(+) diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index c61ae05c5935..5f0ef9e40368 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -509,6 +509,7 @@ static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_ELLIPSI static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_HYPHENATION_FREQUENCY; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_INCLUDE_FONT_PADDING; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MAX_NUMBER_OF_LINES; +static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MINIMUM_FONT_SCALE; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MINIMUM_FONT_SIZE; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_TEXT_ALIGN_VERTICAL; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_TEXT_BREAK_STRATEGY; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 9d5008343459..49dfaf93bb4b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -509,6 +509,7 @@ static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_ELLIPSI static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_HYPHENATION_FREQUENCY; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_INCLUDE_FONT_PADDING; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MAX_NUMBER_OF_LINES; +static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MINIMUM_FONT_SCALE; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MINIMUM_FONT_SIZE; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_TEXT_ALIGN_VERTICAL; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_TEXT_BREAK_STRATEGY; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 2470a7083fb9..57c6018ea113 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -509,6 +509,7 @@ static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_ELLIPSI static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_HYPHENATION_FREQUENCY; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_INCLUDE_FONT_PADDING; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MAX_NUMBER_OF_LINES; +static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MINIMUM_FONT_SCALE; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_MINIMUM_FONT_SIZE; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_TEXT_ALIGN_VERTICAL; static constexpr facebook::react::MapBuffer::Key facebook::react::PA_KEY_TEXT_BREAK_STRATEGY;