diff --git a/packages/react-native/Libraries/Text/TextProps.js b/packages/react-native/Libraries/Text/TextProps.js index 38e9899f2dfc..c8f28ddc4b49 100644 --- a/packages/react-native/Libraries/Text/TextProps.js +++ b/packages/react-native/Libraries/Text/TextProps.js @@ -122,8 +122,6 @@ export type TextPropsAndroid = { /** * Smallest possible font scale when `adjustsFontSizeToFit` is enabled * (values 0.01-1.0). - * - * @platform ios */ minimumFontScale?: ?number, }; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 29a04dfbfc07..ff33c2d5bdd6 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6040,6 +6040,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi public fun setIncludeFontPadding (Z)V public fun setLetterSpacing (F)V public fun setLinkifyMask (I)V + public fun setMinimumFontScale (F)V public fun setMinimumFontSize (F)V public fun setNumberOfLines (I)V public fun setOverflow (Ljava/lang/String;)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..677eb472660b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -72,6 +72,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private boolean mAdjustsFontSizeToFit; private float mFontSize; private float mMinimumFontSize; + private float mMinimumFontScale; private float mLetterSpacing; private int mLinkifyMaskType; private boolean mTextIsSelectable; @@ -133,6 +134,7 @@ private void initView() { mEllipsizeLocation = TextUtils.TruncateAt.END; mFontSize = Float.NaN; mMinimumFontSize = Float.NaN; + mMinimumFontScale = Float.NaN; mLetterSpacing = 0.f; mOverflow = Overflow.VISIBLE; mSpanned = null; @@ -239,6 +241,7 @@ protected void onDraw(Canvas canvas) { getHeight(), YogaMeasureMode.EXACTLY, mMinimumFontSize, + mMinimumFontScale, mNumberOfLines, getIncludeFontPadding(), getBreakStrategy(), @@ -540,11 +543,20 @@ 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; + } + @Override public void setIncludeFontPadding(boolean includepad) { super.setIncludeFontPadding(includepad); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 044fa7923c6a..6eaf70f6f24e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -171,7 +171,12 @@ public constructor( val minimumFontSize: Float = paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SIZE).toFloat() - view.setMinimumFontSize(minimumFontSize) + @Suppress("DEPRECATION") view.setMinimumFontSize(minimumFontSize) + val minimumFontScale: Float = + 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 view.setPreparedLayout(null) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 53a67377158f..d9c28ca5fa20 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -98,6 +98,7 @@ internal object TextLayoutManager { 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 @@ -1171,6 +1172,10 @@ internal object TextLayoutManager { 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() + else Float.NaN adjustSpannableFontToFit( text, @@ -1179,6 +1184,7 @@ internal object TextLayoutManager { height, heightYogaMeasureMode, minimumFontSize, + minimumFontScale, maximumNumberOfLines, includeFontPadding, textBreakStrategy, @@ -1338,6 +1344,7 @@ internal object TextLayoutManager { height: Float, heightYogaMeasureMode: YogaMeasureMode, minimumFontSizeAttr: Float, + minimumFontScale: Float, maximumNumberOfLines: Int, includeFontPadding: Boolean, textBreakStrategy: Int, @@ -1349,17 +1356,25 @@ 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) } + // 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 = + when { + !minimumFontSizeAttr.isNaN() -> minimumFontSizeAttr.toInt() + !minimumFontScale.isNaN() && minimumFontScale > 0f -> + max((minimumFontScale * currentFontSize).toInt(), absoluteMinimumFontSize) + else -> 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..1fba91156cb6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerMinimumFontScaleTest.kt @@ -0,0 +1,174 @@ +/* + * 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 `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) + + TextLayoutManager.adjustSpannableFontToFit( + text, + 10_000f, + YogaMeasureMode.EXACTLY, + 10_000f, + YogaMeasureMode.EXACTLY, + Float.NaN, + 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, + minimumFontSize: Float = Float.NaN, + ) { + TextLayoutManager.adjustSpannableFontToFit( + text, + 10_000f, + YogaMeasureMode.EXACTLY, + 1f, + YogaMeasureMode.EXACTLY, + minimumFontSize, + 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/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp index 793acf9476c4..af4c92f85611 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp @@ -59,6 +59,10 @@ SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const { "adjustsFontSizeToFit", adjustsFontSizeToFit, paragraphAttributes.adjustsFontSizeToFit), + debugStringConvertibleItem( + "minimumFontScale", + minimumFontScale, + paragraphAttributes.minimumFontScale), debugStringConvertibleItem( "minimumFontSize", minimumFontSize, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h index 7cd8252d7c8f..6ec769a5dcd0 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h @@ -69,6 +69,7 @@ class ParagraphAttributes : public DebugStringConvertible { /* * In case of font size adjustment enabled, defines the minimum font size. + * Deprecated in favor of minimumFontScale. */ Float minimumFontSize{std::numeric_limits::quiet_NaN()}; diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h index e77b21d33653..42c0dab9378b 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1196,6 +1196,7 @@ 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_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) { @@ -1211,6 +1212,7 @@ inline MapBuffer toMapBuffer(const ParagraphAttributes ¶graphAttributes) 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..3478df3f838b 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp @@ -57,6 +57,18 @@ TEST( 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/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index af5b225e8e7e..6a171457a679 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -453,8 +453,13 @@ - (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 = 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]; } 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 + + + + Can limit how small the text becomes with minimumFontScale + +