From 8a135ec6b6e104318c0571a96af263e0d1277134 Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Thu, 17 Sep 2026 11:43:31 -0500 Subject: [PATCH 1/6] 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 58a9879af9d5..44606ea4a6e0 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,8 +454,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/6] 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 bd06a3b94d37..efc6f660cea0 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6034,7 +6034,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 eda2036c2b4d..5c854c5c67a6 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 @@ -168,9 +168,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 cd1674a3950f..2963fd233975 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 @@ -93,7 +93,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 @@ -1049,9 +1049,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( @@ -1060,7 +1060,7 @@ internal object TextLayoutManager { YogaMeasureMode.EXACTLY, height, heightYogaMeasureMode, - minimumFontSize, + minimumFontScale, maximumNumberOfLines, includeFontPadding, textBreakStrategy, @@ -1218,7 +1218,7 @@ internal object TextLayoutManager { widthYogaMeasureMode: YogaMeasureMode, height: Float, heightYogaMeasureMode: YogaMeasureMode, - minimumFontSizeAttr: Float, + minimumFontScale: Float, maximumNumberOfLines: Int, includeFontPadding: Boolean, textBreakStrategy: Int, @@ -1230,17 +1230,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: Tue, 8 Sep 2026 16:37:38 -0500 Subject: [PATCH 3/6] Keep ellipsizeMode when adjustsFontSizeToFit is set on Android ReactTextView.updateView() cleared the ellipsize location whenever adjustsFontSizeToFit was on. Now that minimumFontScale can stop the text from shrinking further, text that still does not fit at the floor was clipped instead of ellipsized. iOS applies the ellipsize mode regardless of adjustsFontSizeToFit, so do the same here. --- .../facebook/react/views/text/ReactTextView.java | 4 +--- .../facebook/react/views/text/ReactTextViewTest.kt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) 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..8cd8d4005f91 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 @@ -585,9 +585,7 @@ public void setEllipsizeLocation(@Nullable TextUtils.TruncateAt ellipsizeLocatio public void updateView() { @Nullable TextUtils.TruncateAt ellipsizeLocation = - mNumberOfLines == ViewDefaults.NUMBER_OF_LINES || mAdjustsFontSizeToFit - ? null - : mEllipsizeLocation; + mNumberOfLines == ViewDefaults.NUMBER_OF_LINES ? null : mEllipsizeLocation; setEllipsize(ellipsizeLocation); } 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 4deaf2daa543..bd85ec01dafb 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 @@ -14,6 +14,7 @@ import android.graphics.Color import android.graphics.Paint import android.text.SpannableString import android.text.Spanned +import android.text.TextUtils import android.text.style.ReplacementSpan import android.util.TypedValue import android.view.Gravity @@ -122,6 +123,18 @@ class ReactTextViewTest { assertThat(view.useBoundsForWidth).isFalse() } + @Test + fun adjustsFontSizeToFitKeepsEllipsizeLocation() { + val view = TestReactTextView(RuntimeEnvironment.getApplication()) + view.setNumberOfLines(1) + view.setEllipsizeLocation(TextUtils.TruncateAt.END) + view.setAdjustFontSizeToFit(true) + + view.updateView() + + assertThat(view.ellipsize).isEqualTo(TextUtils.TruncateAt.END) + } + private fun layoutAndDraw(view: TestReactTextView, width: Int, height: Int) { view.measure( View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), From 9c12869afd5cfe90a31ea2f6cd67e6300d263bdf Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Mon, 14 Sep 2026 14:31:57 -0500 Subject: [PATCH 4/6] 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 44606ea4a6e0..bfd6cef378da 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 @@ -455,7 +455,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 f29ca4c693383cf7a9643382ff4d921e3f6fc9ca Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Mon, 14 Sep 2026 14:31:59 -0500 Subject: [PATCH 5/6] 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 efc6f660cea0..2eb28f247376 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6035,6 +6035,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 8cd8d4005f91..e6941e98f8ea 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 5c854c5c67a6..34037763ca5e 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 @@ -168,8 +168,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 2963fd233975..b65969a6a379 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 @@ -93,9 +93,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 @@ -1049,6 +1050,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() @@ -1060,6 +1065,7 @@ internal object TextLayoutManager { YogaMeasureMode.EXACTLY, height, heightYogaMeasureMode, + minimumFontSize, minimumFontScale, maximumNumberOfLines, includeFontPadding, @@ -1218,6 +1224,7 @@ internal object TextLayoutManager { widthYogaMeasureMode: YogaMeasureMode, height: Float, heightYogaMeasureMode: YogaMeasureMode, + minimumFontSizeAttr: Float, minimumFontScale: Float, maximumNumberOfLines: Int, includeFontPadding: Boolean, @@ -1237,12 +1244,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 76e1c9c536cdfd909439f5d3f3178cc8a9f261a7 Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Mon, 14 Sep 2026 18:53:39 -0500 Subject: [PATCH 6/6] 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 eaa69cfd1d93..54dd150824c9 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 6ef175742788..e4d3fdfc3321 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 0bfdc66232f0..e4d17a8803f3 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;