Skip to content

Commit aa854a1

Browse files
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.
1 parent 8a135ec commit aa854a1

8 files changed

Lines changed: 196 additions & 23 deletions

File tree

‎packages/react-native/Libraries/Text/TextProps.js‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,6 @@ export type TextPropsAndroid = {
122122
/**
123123
* Smallest possible font scale when `adjustsFontSizeToFit` is enabled
124124
* (values 0.01-1.0).
125-
*
126-
* @platform ios
127125
*/
128126
minimumFontScale?: ?number,
129127
};

‎packages/react-native/ReactAndroid/api/ReactAndroid.api‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6034,7 +6034,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi
60346034
public fun setIncludeFontPadding (Z)V
60356035
public fun setLetterSpacing (F)V
60366036
public fun setLinkifyMask (I)V
6037-
public fun setMinimumFontSize (F)V
6037+
public fun setMinimumFontScale (F)V
60386038
public fun setNumberOfLines (I)V
60396039
public fun setOverflow (Ljava/lang/String;)V
60406040
public fun setSpanned (Landroid/text/Spannable;)V

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
7171
private @Nullable TextUtils.TruncateAt mEllipsizeLocation;
7272
private boolean mAdjustsFontSizeToFit;
7373
private float mFontSize;
74-
private float mMinimumFontSize;
74+
private float mMinimumFontScale;
7575
private float mLetterSpacing;
7676
private int mLinkifyMaskType;
7777
private boolean mTextIsSelectable;
@@ -132,7 +132,7 @@ private void initView() {
132132
mShouldAdjustSpannableFontSize = false;
133133
mEllipsizeLocation = TextUtils.TruncateAt.END;
134134
mFontSize = Float.NaN;
135-
mMinimumFontSize = Float.NaN;
135+
mMinimumFontScale = Float.NaN;
136136
mLetterSpacing = 0.f;
137137
mOverflow = Overflow.VISIBLE;
138138
mSpanned = null;
@@ -238,7 +238,7 @@ protected void onDraw(Canvas canvas) {
238238
YogaMeasureMode.EXACTLY,
239239
getHeight(),
240240
YogaMeasureMode.EXACTLY,
241-
mMinimumFontSize,
241+
mMinimumFontScale,
242242
mNumberOfLines,
243243
getIncludeFontPadding(),
244244
getBreakStrategy(),
@@ -540,8 +540,8 @@ public void setFontSize(float fontSize) {
540540
applyTextAttributes();
541541
}
542542

543-
public void setMinimumFontSize(float minimumFontSize) {
544-
mMinimumFontSize = minimumFontSize;
543+
public void setMinimumFontScale(float minimumFontScale) {
544+
mMinimumFontScale = minimumFontScale;
545545
mShouldAdjustSpannableFontSize = true;
546546
}
547547

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ public constructor(
168168
)
169169
view.setSpanned(spanned)
170170

171-
val minimumFontSize: Float =
172-
paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SIZE).toFloat()
173-
view.setMinimumFontSize(minimumFontSize)
171+
val minimumFontScale: Float =
172+
paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SCALE).toFloat()
173+
view.setMinimumFontScale(minimumFontScale)
174174

175175
// Clear any stale PreparedLayout from a previous update
176176
view.setPreparedLayout(null)

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ internal object TextLayoutManager {
9393
const val PA_KEY_ADJUST_FONT_SIZE_TO_FIT: Int = 3
9494
const val PA_KEY_INCLUDE_FONT_PADDING: Int = 4
9595
const val PA_KEY_HYPHENATION_FREQUENCY: Int = 5
96-
const val PA_KEY_MINIMUM_FONT_SIZE: Int = 6
96+
const val PA_KEY_MINIMUM_FONT_SCALE: Int = 6
9797
const val PA_KEY_TEXT_ALIGN_VERTICAL: Int = 8
9898
const val PA_KEY_TEXT_WIDTH_MODE: Int = 9
9999

@@ -1049,9 +1049,9 @@ internal object TextLayoutManager {
10491049
val justificationMode = getTextJustificationMode(alignmentAttr)
10501050

10511051
if (adjustFontSizeToFit) {
1052-
val minimumFontSize =
1053-
if (paragraphAttributes.contains(PA_KEY_MINIMUM_FONT_SIZE))
1054-
paragraphAttributes.getDouble(PA_KEY_MINIMUM_FONT_SIZE).toFloat()
1052+
val minimumFontScale =
1053+
if (paragraphAttributes.contains(PA_KEY_MINIMUM_FONT_SCALE))
1054+
paragraphAttributes.getDouble(PA_KEY_MINIMUM_FONT_SCALE).toFloat()
10551055
else Float.NaN
10561056

10571057
adjustSpannableFontToFit(
@@ -1060,7 +1060,7 @@ internal object TextLayoutManager {
10601060
YogaMeasureMode.EXACTLY,
10611061
height,
10621062
heightYogaMeasureMode,
1063-
minimumFontSize,
1063+
minimumFontScale,
10641064
maximumNumberOfLines,
10651065
includeFontPadding,
10661066
textBreakStrategy,
@@ -1218,7 +1218,7 @@ internal object TextLayoutManager {
12181218
widthYogaMeasureMode: YogaMeasureMode,
12191219
height: Float,
12201220
heightYogaMeasureMode: YogaMeasureMode,
1221-
minimumFontSizeAttr: Float,
1221+
minimumFontScale: Float,
12221222
maximumNumberOfLines: Int,
12231223
includeFontPadding: Boolean,
12241224
textBreakStrategy: Int,
@@ -1230,17 +1230,21 @@ internal object TextLayoutManager {
12301230
var boring = isBoring(text, paint)
12311231
var layout: Layout
12321232

1233-
// Minimum font size is 4pts to match the iOS implementation.
1234-
val minimumFontSize =
1235-
(if (minimumFontSizeAttr.isNaN()) 4.dpToPx() else minimumFontSizeAttr).toInt()
1236-
12371233
// Find the largest font size used in the spannable to use as a starting point.
1238-
var currentFontSize = minimumFontSize
1234+
var currentFontSize = 0
12391235
val spans = text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java)
12401236
for (span in spans) {
12411237
currentFontSize = max(currentFontSize, span.size)
12421238
}
12431239

1240+
// The smallest font size is the largest font size scaled by minimumFontScale, floored at 4dp
1241+
// to match the iOS implementation.
1242+
val absoluteMinimumFontSize = 4.dpToPx().toInt()
1243+
val minimumFontSize =
1244+
if (minimumFontScale.isNaN() || minimumFontScale <= 0f) absoluteMinimumFontSize
1245+
else max((minimumFontScale * currentFontSize).toInt(), absoluteMinimumFontSize)
1246+
currentFontSize = max(currentFontSize, minimumFontSize)
1247+
12441248
var intervalStart = minimumFontSize
12451249
var intervalEnd = currentFontSize
12461250
var previousFontSize = currentFontSize

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ import android.view.View
2121
import android.view.ViewGroup
2222
import androidx.core.graphics.createBitmap
2323
import androidx.core.graphics.get
24+
import com.facebook.react.uimanager.DisplayMetricsHolder
2425
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
2526
import org.assertj.core.api.Assertions.assertThat
27+
import org.junit.After
28+
import org.junit.Before
2629
import org.junit.Test
2730
import org.junit.runner.RunWith
2831
import org.robolectric.RobolectricTestRunner
@@ -32,6 +35,16 @@ import org.robolectric.annotation.Config
3235
@RunWith(RobolectricTestRunner::class)
3336
class ReactTextViewTest {
3437

38+
@Before
39+
fun setUp() {
40+
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication())
41+
}
42+
43+
@After
44+
fun tearDown() {
45+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
46+
}
47+
3548
@Test
3649
fun drawsGlyphInkOutsideLineHeightWhenOverflowIsVisible() {
3750
val bitmap = drawReactTextViewWithOverflow(null)
@@ -70,7 +83,7 @@ class ReactTextViewTest {
7083
ViewGroup.LayoutParams.WRAP_CONTENT,
7184
)
7285
view.setTextColor(Color.BLACK)
73-
view.setMinimumFontSize(4f)
86+
view.setMinimumFontScale(0.1f)
7487
view.setNumberOfLines(0)
7588
view.setAdjustFontSizeToFit(true)
7689
view.setSpanned(text)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text
9+
10+
import android.text.Layout
11+
import android.text.SpannableString
12+
import android.text.Spanned
13+
import android.text.TextPaint
14+
import com.facebook.react.common.ReactConstants
15+
import com.facebook.react.uimanager.DisplayMetricsHolder
16+
import com.facebook.react.uimanager.PixelUtil.dpToPx
17+
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
18+
import com.facebook.yoga.YogaMeasureMode
19+
import org.assertj.core.api.Assertions.assertThat
20+
import org.junit.After
21+
import org.junit.Before
22+
import org.junit.Test
23+
import org.junit.runner.RunWith
24+
import org.robolectric.RobolectricTestRunner
25+
import org.robolectric.RuntimeEnvironment
26+
27+
@RunWith(RobolectricTestRunner::class)
28+
class TextLayoutManagerMinimumFontScaleTest {
29+
30+
@Before
31+
fun setUp() {
32+
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication())
33+
}
34+
35+
@After
36+
fun tearDown() {
37+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
38+
}
39+
40+
@Test
41+
fun `minimumFontScale limits how far the font shrinks relative to the largest font size`() {
42+
val text = spannableWithFontSize(LARGE_FONT_SIZE)
43+
44+
adjustToUnsatisfiableHeight(text, minimumFontScale = 0.5f)
45+
46+
assertThat(largestFontSize(text)).isEqualTo((LARGE_FONT_SIZE * 0.5f).toInt())
47+
}
48+
49+
@Test
50+
fun `minimumFontScale is applied to the largest font size in the spannable`() {
51+
val text = SpannableString("Small text and LARGE TEXT")
52+
text.setSpan(ReactAbsoluteSizeSpan(SMALL_FONT_SIZE), 0, 14, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
53+
text.setSpan(
54+
ReactAbsoluteSizeSpan(LARGE_FONT_SIZE),
55+
15,
56+
text.length,
57+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
58+
)
59+
60+
adjustToUnsatisfiableHeight(text, minimumFontScale = 0.5f)
61+
62+
assertThat(largestFontSize(text)).isEqualTo((LARGE_FONT_SIZE * 0.5f).toInt())
63+
}
64+
65+
@Test
66+
fun `missing minimumFontScale shrinks down to the 4dp floor`() {
67+
val text = spannableWithFontSize(LARGE_FONT_SIZE)
68+
69+
adjustToUnsatisfiableHeight(text, minimumFontScale = Float.NaN)
70+
71+
assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt())
72+
}
73+
74+
@Test
75+
fun `zero minimumFontScale shrinks down to the 4dp floor`() {
76+
val text = spannableWithFontSize(LARGE_FONT_SIZE)
77+
78+
adjustToUnsatisfiableHeight(text, minimumFontScale = 0f)
79+
80+
assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt())
81+
}
82+
83+
@Test
84+
fun `minimumFontScale never shrinks below the 4dp floor`() {
85+
val text = spannableWithFontSize(LARGE_FONT_SIZE)
86+
87+
adjustToUnsatisfiableHeight(text, minimumFontScale = 0.01f)
88+
89+
assertThat(largestFontSize(text)).isEqualTo(4.dpToPx().toInt())
90+
}
91+
92+
@Test
93+
fun `text that already fits is not shrunk`() {
94+
val text = spannableWithFontSize(LARGE_FONT_SIZE)
95+
96+
TextLayoutManager.adjustSpannableFontToFit(
97+
text,
98+
10_000f,
99+
YogaMeasureMode.EXACTLY,
100+
10_000f,
101+
YogaMeasureMode.EXACTLY,
102+
0.5f,
103+
ReactConstants.UNSET,
104+
true,
105+
Layout.BREAK_STRATEGY_SIMPLE,
106+
Layout.HYPHENATION_FREQUENCY_NONE,
107+
Layout.Alignment.ALIGN_NORMAL,
108+
0,
109+
newPaint(),
110+
)
111+
112+
assertThat(largestFontSize(text)).isEqualTo(LARGE_FONT_SIZE)
113+
}
114+
115+
// Uses a height no font size can satisfy so the text is shrunk all the way to the minimum.
116+
private fun adjustToUnsatisfiableHeight(text: SpannableString, minimumFontScale: Float) {
117+
TextLayoutManager.adjustSpannableFontToFit(
118+
text,
119+
10_000f,
120+
YogaMeasureMode.EXACTLY,
121+
1f,
122+
YogaMeasureMode.EXACTLY,
123+
minimumFontScale,
124+
ReactConstants.UNSET,
125+
true,
126+
Layout.BREAK_STRATEGY_SIMPLE,
127+
Layout.HYPHENATION_FREQUENCY_NONE,
128+
Layout.Alignment.ALIGN_NORMAL,
129+
0,
130+
newPaint(),
131+
)
132+
}
133+
134+
private fun spannableWithFontSize(fontSize: Int): SpannableString {
135+
val text = SpannableString("Hello")
136+
text.setSpan(ReactAbsoluteSizeSpan(fontSize), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
137+
return text
138+
}
139+
140+
private fun newPaint(): TextPaint =
141+
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = LARGE_FONT_SIZE.toFloat() }
142+
143+
private fun largestFontSize(text: Spanned): Int =
144+
text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).maxOfOrNull { it.size } ?: 0
145+
146+
private companion object {
147+
const val SMALL_FONT_SIZE = 10
148+
const val LARGE_FONT_SIZE = 40
149+
}
150+
}

‎packages/rn-tester/js/examples/Text/TextExample.android.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ class AdjustingFontSize extends React.Component<
154154
Shrinking to fit available space is much better!
155155
</RNTesterText>
156156

157+
<RNTesterText
158+
numberOfLines={1}
159+
adjustsFontSizeToFit={true}
160+
minimumFontScale={0.5}
161+
style={{fontSize: 40, marginVertical: 6}}>
162+
Can limit how small the text becomes with minimumFontScale
163+
</RNTesterText>
164+
157165
<RNTesterText
158166
adjustsFontSizeToFit={true}
159167
numberOfLines={1}

0 commit comments

Comments
 (0)