From 79be07438a08fb9c9bdf35e3c71f23cfe880c059 Mon Sep 17 00:00:00 2001 From: Conner Reimers Date: Mon, 6 Oct 2025 17:48:27 -0500 Subject: [PATCH 1/3] Fix minimumFontScale with adjustsFontSizeToFit on iOS in the New Architecture Fabric's ParagraphAttributes carried `minimumFontSize` and `maximumFontSize`, which no `` prop can set, so RCTTextLayoutManager always fell back to a 4pt floor and ignored `minimumFontScale`. Replace those two fields 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). MapBuffer key 6 now carries the scale instead of an absolute size; key 7 is no longer written. Updates ParagraphAttributesTest for the removed fields and adds an RNTester example exercising `minimumFontScale`. --- .../attributedstring/ParagraphAttributes.cpp | 12 ++----- .../attributedstring/ParagraphAttributes.h | 11 +----- .../renderer/attributedstring/conversions.h | 18 ++-------- .../tests/ParagraphAttributesTest.cpp | 34 +++++++++---------- .../components/text/BaseParagraphProps.cpp | 12 ------- .../text/HostPlatformParagraphProps.cpp | 12 ------- .../textinput/BaseTextInputProps.cpp | 10 ++---- .../AndroidTextInputProps.cpp | 12 ++----- .../textlayoutmanager/RCTTextLayoutManager.mm | 20 +++++++++-- .../js/examples/Text/TextExample.ios.js | 8 +++++ 10 files changed, 53 insertions(+), 96 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp index 98992aa2287a..32711dbeb161 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp @@ -33,8 +33,6 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const { rhs.includeFontPadding, rhs.android_hyphenationFrequency, rhs.textAlignVertical) && - floatEquality(minimumFontSize, rhs.minimumFontSize) && - floatEquality(maximumFontSize, rhs.maximumFontSize) && floatEquality(minimumFontScale, rhs.minimumFontScale); } @@ -61,13 +59,9 @@ SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const { adjustsFontSizeToFit, paragraphAttributes.adjustsFontSizeToFit), debugStringConvertibleItem( - "minimumFontSize", - minimumFontSize, - paragraphAttributes.minimumFontSize), - debugStringConvertibleItem( - "maximumFontSize", - maximumFontSize, - paragraphAttributes.maximumFontSize), + "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 6207386a640c..4811d8063df2 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h @@ -67,18 +67,11 @@ class ParagraphAttributes : public DebugStringConvertible { */ HyphenationFrequency android_hyphenationFrequency{}; - /* - * In case of font size adjustment enabled, defines minimum and maximum - * font sizes. - */ - Float minimumFontSize{std::numeric_limits::quiet_NaN()}; - Float maximumFontSize{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 @@ -109,8 +102,6 @@ struct hash { attributes.textBreakStrategy, attributes.textWidthMode, attributes.adjustsFontSizeToFit, - attributes.minimumFontSize, - attributes.maximumFontSize, 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 4c03d715c35d..ed876b51d45c 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1085,18 +1085,6 @@ inline ParagraphAttributes convertRawProp( "minimumFontScale", sourceParagraphAttributes.minimumFontScale, defaultParagraphAttributes.minimumFontScale); - paragraphAttributes.minimumFontSize = convertRawProp( - context, - rawProps, - "minimumFontSize", - sourceParagraphAttributes.minimumFontSize, - defaultParagraphAttributes.minimumFontSize); - paragraphAttributes.maximumFontSize = convertRawProp( - context, - rawProps, - "maximumFontSize", - sourceParagraphAttributes.maximumFontSize, - defaultParagraphAttributes.maximumFontSize); paragraphAttributes.includeFontPadding = convertRawProp( context, rawProps, @@ -1199,8 +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_MAXIMUM_FONT_SIZE = 7; +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; @@ -1217,8 +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_MAXIMUM_FONT_SIZE, paragraphAttributes.maximumFontSize); + 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 3b395a1d9065..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 three 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,38 +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.maximumFontSize = 48.0f; a.minimumFontScale = 0.5f; auto b = a; - b.minimumFontSize = a.minimumFontSize + 0.001f; - b.maximumFontSize = a.maximumFontSize + 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 any of the three float fields -// 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 e0fcc551392e..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,18 +98,6 @@ void BaseParagraphProps::setProp( paragraphAttributes, minimumFontScale, "minimumFontScale"); - REBUILD_FIELD_SWITCH_CASE( - paDefaults, - value, - paragraphAttributes, - minimumFontSize, - "minimumFontSize"); - REBUILD_FIELD_SWITCH_CASE( - paDefaults, - value, - paragraphAttributes, - maximumFontSize, - "maximumFontSize"); 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 a9a6f1452c1c..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,18 +122,6 @@ folly::dynamic HostPlatformParagraphProps::getDiffProps( result["minimumFontScale"] = paragraphAttributes.minimumFontScale; } - if (!floatEquality( - paragraphAttributes.minimumFontSize, - oldProps->paragraphAttributes.minimumFontSize)) { - result["minimumFontSize"] = paragraphAttributes.minimumFontSize; - } - - if (!floatEquality( - paragraphAttributes.maximumFontSize, - oldProps->paragraphAttributes.maximumFontSize)) { - result["maximumFontSize"] = paragraphAttributes.maximumFontSize; - } - 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 ac3adc4d4ed2..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,14 +177,8 @@ void BaseTextInputProps::setProp( paDefaults, value, paragraphAttributes, - minimumFontSize, - "minimumFontSize"); - REBUILD_FIELD_SWITCH_CASE( - paDefaults, - value, - paragraphAttributes, - maximumFontSize, - "maximumFontSize"); + 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 60b14966a648..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,15 +390,9 @@ folly::dynamic AndroidTextInputProps::getDiffProps( } if (!floatEquality( - paragraphAttributes.minimumFontSize, - oldProps->paragraphAttributes.minimumFontSize)) { - result["minimumFontSize"] = paragraphAttributes.minimumFontSize; - } - - if (!floatEquality( - paragraphAttributes.maximumFontSize, - oldProps->paragraphAttributes.maximumFontSize)) { - result["maximumFontSize"] = paragraphAttributes.maximumFontSize; + 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 171f5f67b618..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 @@ -415,6 +415,22 @@ - (LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedStr return paragraphLines; } +- (CGFloat)_maximumFontSizeInAttributedString:(NSAttributedString *)attributedString +{ + __block CGFloat maximumFontSize = 0.0; + [attributedString enumerateAttribute:NSFontAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(id _Nullable value, NSRange range, BOOL *_Nonnull stop) { + CGFloat fontSize = ((UIFont *)value).pointSize; + if (fontSize > maximumFontSize) { + maximumFontSize = fontSize; + } + }]; + + return maximumFontSize; +} + - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size @@ -438,8 +454,8 @@ - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttribute [textStorage addLayoutManager:layoutManager]; if (paragraphAttributes.adjustsFontSizeToFit) { - CGFloat minimumFontSize = !isnan(paragraphAttributes.minimumFontSize) ? paragraphAttributes.minimumFontSize : 4.0; - CGFloat maximumFontSize = !isnan(paragraphAttributes.maximumFontSize) ? paragraphAttributes.maximumFontSize : 96.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: Tue, 8 Sep 2026 16:37:38 -0500 Subject: [PATCH 2/3] 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, drops the unused PA_KEY_MAXIMUM_FONT_SIZE, 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 | 27 ++-- .../react/views/text/ReactTextViewTest.kt | 15 +- .../TextLayoutManagerMinimumFontScaleTest.kt | 150 ++++++++++++++++++ .../js/examples/Text/TextExample.android.js | 8 + 8 files changed, 196 insertions(+), 24 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 10730dda4a4b..3feb7cea1fe0 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6044,7 +6044,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 5ea878dcef05..a31a707ab251 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,8 +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_MAXIMUM_FONT_SIZE: Int = 7 + 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 @@ -1046,9 +1045,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( @@ -1057,7 +1056,7 @@ internal object TextLayoutManager { YogaMeasureMode.EXACTLY, height, heightYogaMeasureMode, - minimumFontSize, + minimumFontScale, maximumNumberOfLines, includeFontPadding, textBreakStrategy, @@ -1209,7 +1208,7 @@ internal object TextLayoutManager { widthYogaMeasureMode: YogaMeasureMode, height: Float, heightYogaMeasureMode: YogaMeasureMode, - minimumFontSizeAttr: Float, + minimumFontScale: Float, maximumNumberOfLines: Int, includeFontPadding: Boolean, textBreakStrategy: Int, @@ -1221,17 +1220,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 8f07d2159077..ddfc265f736e 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/3] 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 ddfc265f736e..5c2eb50719fd 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),