Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/react-native/Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,6 @@ export type TextPropsAndroid = {
/**
* Smallest possible font scale when `adjustsFontSizeToFit` is enabled
* (values 0.01-1.0).
*
* @platform ios
*/
minimumFontScale?: ?number,
};
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -6044,6 +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 setMinimumFontScale (F)V
public fun setMinimumFontSize (F)V
public fun setNumberOfLines (I)V
public fun setOverflow (Ljava/lang/String;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -239,6 +241,7 @@ protected void onDraw(Canvas canvas) {
getHeight(),
YogaMeasureMode.EXACTLY,
mMinimumFontSize,
mMinimumFontScale,
mNumberOfLines,
getIncludeFontPadding(),
getBreakStrategy(),
Expand Down Expand Up @@ -540,11 +543,20 @@ public void setFontSize(float fontSize) {
applyTextAttributes();
}

/**
* @deprecated Use {@link #setMinimumFontScale(float)} instead.
*/
@Deprecated
public void setMinimumFontSize(float minimumFontSize) {

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Author

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, and setMinimumFontScale() was added alongside it. minimumFontSize still takes precedence when explicitly set, so existing callers keep the old behavior.

mMinimumFontSize = minimumFontSize;
mShouldAdjustSpannableFontSize = true;
}

public void setMinimumFontScale(float minimumFontScale) {
mMinimumFontScale = minimumFontScale;
mShouldAdjustSpannableFontSize = true;
}

@Override
public void setIncludeFontPadding(boolean includepad) {
super.setIncludeFontPadding(includepad);
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this behavioural chagne?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for the case where text reaches minimumFontScale and still doesn't fit. Android currently clears ellipsizeMode whenever adjustsFontSizeToFit is enabled, so that overflow gets clipped instead.

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 minimumFontScale is now actually respected, I kept this change so overflow at the floor uses the requested ellipsizeMode, which matches iOS.

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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: re-order

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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

Expand Down Expand Up @@ -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,
Expand All @@ -1058,6 +1063,7 @@ internal object TextLayoutManager {
height,
heightYogaMeasureMode,
minimumFontSize,
minimumFontScale,
maximumNumberOfLines,
includeFontPadding,
textBreakStrategy,
Expand Down Expand Up @@ -1210,6 +1216,7 @@ internal object TextLayoutManager {
height: Float,
heightYogaMeasureMode: YogaMeasureMode,
minimumFontSizeAttr: Float,
minimumFontScale: Float,
maximumNumberOfLines: Int,
includeFontPadding: Boolean,
textBreakStrategy: Int,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ 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
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
Expand All @@ -32,6 +36,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)
Expand Down Expand Up @@ -70,7 +84,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)
Expand Down Expand Up @@ -109,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),
Expand Down
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
}
}
Loading