-
Notifications
You must be signed in to change notification settings - Fork 25.3k
Fix minimumFontScale with adjustsFontSizeToFit in the New Architecture
#58492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
79be074
815a9e5
e6a8a06
1cb6e66
7e2827e
3cd5ab0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
|
@@ -585,9 +597,7 @@ public void setEllipsizeLocation(@Nullable TextUtils.TruncateAt ellipsizeLocatio | |
| public void updateView() { | ||
| @Nullable | ||
| TextUtils.TruncateAt ellipsizeLocation = | ||
| mNumberOfLines == ViewDefaults.NUMBER_OF_LINES || mAdjustsFontSizeToFit | ||
| ? null | ||
| : mEllipsizeLocation; | ||
|
Comment on lines
-588
to
-590
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why this behavioural chagne?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is for the case where text reaches The original Android implementation did this to avoid some incorrect ellipsizing cases (#26389 (comment)), with overflow at the minimum scale being a known tradeoff. Since It's kept as a separate commit, so I can split it out if you'd prefer. |
||
| mNumberOfLines == ViewDefaults.NUMBER_OF_LINES ? null : mEllipsizeLocation; | ||
| setEllipsize(ellipsizeLocation); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,6 +97,7 @@ internal object TextLayoutManager { | |
| const val PA_KEY_MAXIMUM_FONT_SIZE: Int = 7 | ||
| const val PA_KEY_TEXT_ALIGN_VERTICAL: Int = 8 | ||
| const val PA_KEY_TEXT_WIDTH_MODE: Int = 9 | ||
|
Comment on lines
98
to
99
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: re-order
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has been fixed. |
||
| const val PA_KEY_MINIMUM_FONT_SCALE: Int = 10 | ||
|
|
||
| private val TAG: String = TextLayoutManager::class.java.simpleName | ||
|
|
||
|
|
@@ -1050,6 +1051,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, | ||
|
|
@@ -1058,6 +1063,7 @@ internal object TextLayoutManager { | |
| height, | ||
| heightYogaMeasureMode, | ||
| minimumFontSize, | ||
| minimumFontScale, | ||
| maximumNumberOfLines, | ||
| includeFontPadding, | ||
| textBreakStrategy, | ||
|
|
@@ -1210,6 +1216,7 @@ internal object TextLayoutManager { | |
| height: Float, | ||
| heightYogaMeasureMode: YogaMeasureMode, | ||
| minimumFontSizeAttr: Float, | ||
| minimumFontScale: Float, | ||
| maximumNumberOfLines: Int, | ||
| includeFontPadding: Boolean, | ||
| textBreakStrategy: Int, | ||
|
|
@@ -1221,17 +1228,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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a public API change - please avoid. Can you add back the old API as deprecated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
setMinimumFontSize()was added back as deprecated, andsetMinimumFontScale()was added alongside it.minimumFontSizestill takes precedence when explicitly set, so existing callers keep the old behavior.