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 f3bac78200a..f3774ce77c8 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 @@ -903,6 +903,24 @@ internal object TextLayoutManager { enableStartOverhang, ) + // Android 15/16 leak RTL line bounds into later LTR lines, shifting the whole drawing right. + if (enableStartOverhang && hasMixedParagraphDirections(layout)) { + return buildLayout( + text, + layoutWidth, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + ellipsizeMode, + maxNumberOfLines, + paint, + enableStartOverhang, + shiftDrawingOffsetForStartOverhang = false, + ) + } + // Layout.draw shifts negative (left-side) overhang, but RTL line starts can overflow to the // right. Reserve that ink inside an EXACT layout without changing the width reported to Yoga. return if (enableStartOverhang) { @@ -940,6 +958,15 @@ internal object TextLayoutManager { } } + @VisibleForTesting + internal fun hasMixedParagraphDirections(layout: Layout): Boolean { + if (Build.VERSION.SDK_INT < VERSION_CODE_VANILLA_ICE_CREAM || layout.lineCount == 0) { + return false + } + val firstDirection = layout.getParagraphDirection(0) + return (1 until layout.lineCount).any { layout.getParagraphDirection(it) != firstDirection } + } + @VisibleForTesting internal fun getRtlRightOverhang(layout: Layout): Int { if ( @@ -969,6 +996,7 @@ internal object TextLayoutManager { maxNumberOfLines: Int, paint: TextPaint, enableStartOverhang: Boolean, + shiftDrawingOffsetForStartOverhang: Boolean = enableStartOverhang, ): Layout { val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth) @@ -995,7 +1023,7 @@ internal object TextLayoutManager { // measurement returned to the caller. if (Build.VERSION.SDK_INT >= VERSION_CODE_VANILLA_ICE_CREAM) { setUseBoundsForWidth(builder, enableStartOverhang) - setShiftDrawingOffsetForStartOverhang(builder, enableStartOverhang) + setShiftDrawingOffsetForStartOverhang(builder, shiftDrawingOffsetForStartOverhang) } return builder.build() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt index 2f23db9c304..121c10777e7 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt @@ -116,6 +116,36 @@ class TextLayoutManagerStartOverhangTest { assertThat(layout).isSameAs(initialLayout) } + @Test + @Config(sdk = [35]) + fun `mixed paragraph directions disable the start overhang drawing shift`() { + // The blank line between the RTL paragraphs has no strong character, so it is laid out LTR. + val layout = + createLayout( + YogaMeasureMode.EXACTLY, + text = SpannableString("שלום\n\nעולם"), + maxNumberOfLines = 0, + ) + + assertThat(TextLayoutManager.hasMixedParagraphDirections(layout)).isTrue() + assertThat(getBooleanLayoutProperty(layout, "getUseBoundsForWidth")).isTrue() + assertThat(getBooleanLayoutProperty(layout, "getShiftDrawingOffsetForStartOverhang")).isFalse() + } + + @Test + @Config(sdk = [35]) + fun `single paragraph direction keeps the start overhang drawing shift`() { + val layout = + createLayout( + YogaMeasureMode.EXACTLY, + text = SpannableString("שלום\nעולם"), + maxNumberOfLines = 0, + ) + + assertThat(TextLayoutManager.hasMixedParagraphDirections(layout)).isFalse() + assertThat(getBooleanLayoutProperty(layout, "getShiftDrawingOffsetForStartOverhang")).isTrue() + } + @Test @Config(sdk = [34]) fun `EXACTLY mode remains supported before Android 15`() {