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
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import org.json.JSONArray
import org.json.JSONObject

Expand Down Expand Up @@ -1060,7 +1061,21 @@ internal object TextLayoutManager {
paint: TextPaint,
): Unit {
var boring = isBoring(text, paint)
var layout: Layout
var layout =
createLayout(
text,
boring,
width,
widthYogaMeasureMode,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
alignment,
justificationMode,
null,
ReactConstants.UNSET,
paint,
)

// Minimum font size is 4pts to match the iOS implementation.
val minimumFontSize =
Expand All @@ -1073,6 +1088,13 @@ internal object TextLayoutManager {
currentFontSize = max(currentFontSize, span.size).toInt()
}

if (
!layoutExceedsFontSizeFitConstraints(
text, layout, width, height, heightYogaMeasureMode, maximumNumberOfLines)
) {
return
}

var intervalStart = minimumFontSize
var intervalEnd = currentFontSize
var previousFontSize = currentFontSize
Expand All @@ -1087,12 +1109,14 @@ internal object TextLayoutManager {
val currentFontSize = (intervalStart + intervalEnd + 1) / 2

val ratio = currentFontSize.toFloat() / previousFontSize.toFloat()
paint.textSize = max((paint.textSize * ratio).toInt(), minimumFontSize).toFloat()
paint.textSize = scaleFontSizeForFontSizeFit(paint.textSize, ratio, minimumFontSize).toFloat()

val sizeSpans = text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java)
for (span in sizeSpans) {
text.setSpan(
ReactAbsoluteSizeSpan(max((span.size * ratio).toInt(), minimumFontSize)),
ReactAbsoluteSizeSpan(
scaleFontSizeForFontSizeFit(span.size.toFloat(), ratio, minimumFontSize)
),
text.getSpanStart(span),
text.getSpanEnd(span),
text.getSpanFlags(span),
Expand Down Expand Up @@ -1123,17 +1147,10 @@ internal object TextLayoutManager {
break
}

val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width
val exceedsHeight =
heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height
val exceedsMaximumNumberOfLines =
maximumNumberOfLines != ReactConstants.UNSET &&
maximumNumberOfLines != 0 &&
layout.lineCount > maximumNumberOfLines

if (
currentFontSize > minimumFontSize &&
(exceedsMaximumNumberOfLines || exceedsHeight || singleLineTextExceedsWidth)
layoutExceedsFontSizeFitConstraints(
text, layout, width, height, heightYogaMeasureMode, maximumNumberOfLines)
) {
// Text doesn't fit the constraints. If intervalEnd - intervalStart == 1, it's known that
// the correct font size is intervalStart. Set intervalEnd to match intervalStart and do one
Expand All @@ -1148,6 +1165,26 @@ internal object TextLayoutManager {
}
}

private fun layoutExceedsFontSizeFitConstraints(
text: Spannable,
layout: Layout,
width: Float,
height: Float,
heightYogaMeasureMode: YogaMeasureMode,
maximumNumberOfLines: Int,
): Boolean =
(maximumNumberOfLines != ReactConstants.UNSET &&
maximumNumberOfLines != 0 &&
layout.lineCount > maximumNumberOfLines) ||
(heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height) ||
(text.length == 1 && layout.getLineWidth(0) > width)

internal fun scaleFontSizeForFontSizeFit(
fontSize: Float,
ratio: Float,
minimumFontSize: Int,
): Int = max((fontSize * ratio).roundToInt(), minimumFontSize)

@JvmStatic
@OptIn(UnstableReactNativeAPI::class)
fun measureText(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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.annotation.SuppressLint
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.views.text.internal.span.ReactAbsoluteSizeSpan
import com.facebook.yoga.YogaMeasureMode
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class TextLayoutManagerAdjustFontSizeToFitTest {

@Test
@SuppressLint("InlinedApi")
fun `adjustSpannableFontToFit keeps original font size when text already fits`() {
val text = spannableWithFontSize("Already fits", 22)
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 22f }

adjustSpannableFontToFit(
text,
/* width = */ 1000f,
/* height = */ 1000f,
paint,
)

assertThat(paint.textSize).isEqualTo(22f)
assertThat(text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).single().size)
.isEqualTo(22)
}

@Test
@SuppressLint("InlinedApi")
fun `adjustSpannableFontToFit does not compound rounding down while shrinking`() {
var scaledFontSize = 22
var previousFontSize = 22

for (currentFontSize in listOf(13, 18, 20, 21, 22)) {
val ratio = currentFontSize.toFloat() / previousFontSize.toFloat()
scaledFontSize =
TextLayoutManager.scaleFontSizeForFontSizeFit(scaledFontSize.toFloat(), ratio, 4)
previousFontSize = currentFontSize
}

assertThat(scaledFontSize).isEqualTo(22)
}

private fun spannableWithFontSize(text: String, fontSize: Int): SpannableString =
SpannableString(text).apply {
setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}

@SuppressLint("InlinedApi")
private fun adjustSpannableFontToFit(
text: SpannableString,
width: Float,
height: Float,
paint: TextPaint,
) {
TextLayoutManager.adjustSpannableFontToFit(
text,
width,
YogaMeasureMode.EXACTLY,
height,
YogaMeasureMode.EXACTLY,
/* minimumFontSizeAttr = */ 4f,
ReactConstants.UNSET,
/* includeFontPadding = */ false,
Layout.BREAK_STRATEGY_HIGH_QUALITY,
Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
paint,
)
}
}