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 @@ -118,6 +118,9 @@ internal object TextLayoutManager {

private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"

// Large enough to avoid wrapping while leaving room for Android layout arithmetic.
private const val VISUAL_BOUNDS_PROBE_WIDTH = Int.MAX_VALUE / 2

private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()

// These wrappers mirror Android 15 APIs but use reflection because some internal targets still
Expand Down Expand Up @@ -861,18 +864,37 @@ internal object TextLayoutManager {
maxNumberOfLines: Int,
paint: TextPaint,
): Layout {
// If our text is boring, and fully fits in the available space, we can represent the text
// layout as a BoringLayout
val layoutWidth =
when {
widthYogaMeasureMode == YogaMeasureMode.EXACTLY -> ceil(width).toInt()
widthYogaMeasureMode == YogaMeasureMode.AT_MOST &&
boring != null &&
boring.width > floor(width) -> floor(width).toInt()
else -> {
val desiredWidth =
getDesiredWidth(
text,
boring,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
lineBreakStyle,
lineBreakWordStyle,
alignment,
justificationMode,
paint,
)
if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST) {
min(desiredWidth, floor(width).toInt())
} else {
desiredWidth
}
}
}
if (
boring != null &&
(widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || boring.width <= floor(width))
) {
// Guard uses floor() but layout width below uses ceil() for EXACTLY mode intentionally:
// text that barely fails the floor-based guard falls through to StaticLayout, which also
// ceils for EXACTLY — no wrapping results, just a slightly less optimal layout class in a
// rare subpixel edge case.
val layoutWidth =
if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) ceil(width).toInt() else boring.width
return BoringLayout.make(
text,
paint,
Expand All @@ -885,23 +907,6 @@ internal object TextLayoutManager {
)
}

// Layout.getDesiredWidth is a full measuring pass over the text, so only run it when its
// result can affect the layout width. It cannot when the width is EXACTLY, nor when the text
// was boring but did not fit: boring.width is the unwrapped width of the same text and we only
// get here if it exceeds floor(width), so min(desiredWidth, floor(width)) is floor(width).
val layoutWidth =
when {
widthYogaMeasureMode == YogaMeasureMode.EXACTLY -> ceil(width).toInt()
widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null -> floor(width).toInt()
else -> {
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST) {
min(desiredWidth, floor(width).toInt())
} else {
desiredWidth
}
}
}
// Bounds-based width is limited to text with RTL characters. An EXACTLY width usually comes
// from an earlier advance-based AT_MOST measurement, and LTR glyph bounds that are wider than
// their advances (some OEM fonts) would make a single line overflow that width and ellipsize.
Expand Down Expand Up @@ -1010,6 +1015,52 @@ internal object TextLayoutManager {
return ceil(drawingBounds.right - layout.width).toInt().coerceAtLeast(0)
}

private fun getDesiredWidth(
text: Spannable,
boring: BoringLayout.Metrics?,
includeFontPadding: Boolean,
textBreakStrategy: Int,
hyphenationFrequency: Int,
lineBreakStyle: Int,
lineBreakWordStyle: Int,
alignment: Layout.Alignment,
justificationMode: Int,
paint: TextPaint,
): Int {
val advanceWidth = boring?.width ?: ceil(Layout.getDesiredWidth(text, paint)).toInt()

if (
Build.VERSION.SDK_INT < VERSION_CODE_VANILLA_ICE_CREAM || setUseBoundsForWidthMethod == null
) {
return advanceWidth
}

val visualBoundsLayout =
buildLayout(
text,
VISUAL_BOUNDS_PROBE_WIDTH,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
lineBreakStyle,
lineBreakWordStyle,
alignment,
justificationMode,
null,
ReactConstants.UNSET,
paint,
enableStartOverhang = true,
shiftDrawingOffsetForStartOverhang = false,
)

var visualBoundsWidth = 0f
for (i in 0 until visualBoundsLayout.lineCount) {
visualBoundsWidth = max(visualBoundsWidth, visualBoundsLayout.getLineMax(i))
}

return max(advanceWidth, ceil(visualBoundsWidth).toInt())
}

private fun buildLayout(
text: Spannable,
layoutWidth: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import android.text.SpannableString
import android.text.TextPaint
import com.facebook.yoga.YogaMeasureMode
import kotlin.math.ceil
import kotlin.math.floor
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* Regression test for the "Keep watching" 1px text-wrap bug.
Expand Down Expand Up @@ -77,6 +79,65 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
.isGreaterThanOrEqualTo(renderedLineWidth)
}

@Test
@Config(sdk = [35])
fun `Android 15 EXACTLY mode keeps the width provided by Yoga`() {
val text = SpannableString("First line\nSecond line")
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
val exactWidth = 48.5f

val layout = invokeCreateLayout(text, exactWidth, paint)

assertThat(layout.width).isEqualTo(ceil(exactWidth).toInt())
}

@Test
@Config(sdk = [35])
fun `Android 15 AT_MOST mode does not exceed the width provided by Yoga`() {
val text = SpannableString("Prison Break")
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
val maxWidth = 8.5f

val layout = invokeCreateLayout(text, maxWidth, paint, YogaMeasureMode.AT_MOST)

assertThat(layout.width).isLessThanOrEqualTo(floor(maxWidth).toInt())
}

@Test
@Config(sdk = [35])
fun `Android 15 UNDEFINED mode does not shrink below advance width`() {
val text = SpannableString("Prison Break")
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
val advanceWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()

val layout = invokeCreateLayout(text, 0f, paint, YogaMeasureMode.UNDEFINED)

assertThat(layout.width).isGreaterThanOrEqualTo(advanceWidth)
}

@Test
@Config(sdk = [35])
fun `Android 15 UNDEFINED boring layout uses the visual probe width`() {
val text = SpannableString("Prison Break")
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
val boring = requireNotNull(BoringLayout.isBoring(text, paint)).apply { width = 1 }
val visualProbeWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()

assertThat(visualProbeWidth).isGreaterThan(boring.width)

val layout =
invokeCreateLayout(
text,
0f,
paint,
YogaMeasureMode.UNDEFINED,
boring,
)

assertThat(layout).isInstanceOf(BoringLayout::class.java)
assertThat(layout.width).isGreaterThanOrEqualTo(visualProbeWidth)
}

/**
* Default values mirror what measureText() passes in the production path for a plain
* single-paragraph label.
Expand All @@ -89,12 +150,14 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
text: SpannableString,
width: Float,
paint: TextPaint,
widthYogaMeasureMode: YogaMeasureMode = YogaMeasureMode.EXACTLY,
boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint),
): Layout =
TextLayoutManager.createLayout(
text,
BoringLayout.isBoring(text, paint),
boring,
width,
YogaMeasureMode.EXACTLY,
widthYogaMeasureMode,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Expand Down
Loading