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 4a900ffcf711..0da3c2b4f709 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 @@ -40,6 +40,7 @@ import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.ReactAccessibilityDelegate +import com.facebook.react.util.AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan import com.facebook.react.views.text.internal.span.CustomLineHeightSpan import com.facebook.react.views.text.internal.span.CustomStyleSpan @@ -764,13 +765,32 @@ internal object TextLayoutManager { ) } - val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() - val layoutWidth = when (widthYogaMeasureMode) { YogaMeasureMode.EXACTLY -> ceil(width).toInt() - YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt()) - else -> desiredWidth + YogaMeasureMode.AT_MOST -> + min( + getDesiredWidth( + text, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + paint, + ), + floor(width).toInt(), + ) + else -> + getDesiredWidth( + text, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + paint, + ) } return buildLayout( text, @@ -786,6 +806,47 @@ internal object TextLayoutManager { ) } + private fun getDesiredWidth( + text: Spannable, + includeFontPadding: Boolean, + textBreakStrategy: Int, + hyphenationFrequency: Int, + alignment: Layout.Alignment, + justificationMode: Int, + paint: TextPaint, + ): Int { + val advanceWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() + + if ( + Build.VERSION.SDK_INT < VERSION_CODE_VANILLA_ICE_CREAM || + setUseBoundsForWidthMethod == null + ) { + return advanceWidth + } + + val visualBoundsLayout = + buildLayout( + text, + Int.MAX_VALUE / 2, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + null, + ReactConstants.UNSET, + paint, + useBoundsForWidth = true, + ) + + 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, @@ -797,6 +858,7 @@ internal object TextLayoutManager { ellipsizeMode: TextUtils.TruncateAt?, maxNumberOfLines: Int, paint: TextPaint, + useBoundsForWidth: Boolean = false, ): Layout { val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth) @@ -818,6 +880,10 @@ internal object TextLayoutManager { builder.setUseLineSpacingFromFallbacks(true) } + if (useBoundsForWidth) { + setUseBoundsForWidthMethod?.invoke(builder, true) + } + return builder.build() } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt index 33e00f0af10c..3d5f2ab395db 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt @@ -15,9 +15,11 @@ import android.text.TextPaint import android.text.TextUtils 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.annotation.Config import org.robolectric.RobolectricTestRunner /** @@ -77,6 +79,42 @@ 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) + } + /** * Invokes the private TextLayoutManager.createLayout via reflection. We can't call it directly * because it's `private` (friend_paths only opens up `internal`). Default values mirror what @@ -90,6 +128,7 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest { text: SpannableString, width: Float, paint: TextPaint, + widthYogaMeasureMode: YogaMeasureMode = YogaMeasureMode.EXACTLY, ): Layout { val boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint) val method = @@ -117,7 +156,7 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest { text, boring, width, - YogaMeasureMode.EXACTLY, + widthYogaMeasureMode, /* includeFontPadding = */ false, /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,