From fa81b3d5265d2f361492633a9a94c79437e38888 Mon Sep 17 00:00:00 2001 From: abbo Date: Fri, 25 Sep 2026 08:14:05 -0700 Subject: [PATCH 1/3] Generated from a GitHub Pull Request. Run 'jf sync' on this diff to load the correct commit data. Differential Revision: D121672279 --- .../react/views/text/TextLayoutManager.kt | 120 ++++++++++++++++-- .../TextLayoutManagerStartOverhangTest.kt | 113 +++++++++++++++++ 2 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt 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 cd1674a3950f..8e4d2665bd1c 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 @@ -9,6 +9,7 @@ package com.facebook.react.views.text import android.content.res.AssetManager import android.graphics.Color +import android.graphics.RectF import android.graphics.Typeface import android.os.Build import android.text.BoringLayout @@ -41,6 +42,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 @@ -114,7 +116,7 @@ internal object TextLayoutManager { private val tagToSpannableCache = ConcurrentHashMap() - // Lazily cached Method for StaticLayout.Builder.setUseBoundsForWidth (API 35+). + // Lazily cached methods for showing glyph ink that overhangs the start of a line (API 35+). // Reflection is needed because some internal targets compile against an SDK older than 35. private val setUseBoundsForWidthMethod: java.lang.reflect.Method? by lazy { try { @@ -126,6 +128,27 @@ internal object TextLayoutManager { } } + private val setShiftDrawingOffsetForStartOverhangMethod: java.lang.reflect.Method? by lazy { + try { + StaticLayout.Builder::class + .java + .getMethod( + "setShiftDrawingOffsetForStartOverhang", + Boolean::class.javaPrimitiveType, + ) + } catch (_: ReflectiveOperationException) { + null + } + } + + private val computeDrawingBoundingBoxMethod: java.lang.reflect.Method? by lazy { + try { + Layout::class.java.getMethod("computeDrawingBoundingBox") + } catch (_: ReflectiveOperationException) { + null + } + } + fun setCachedSpannableForTag(reactTag: Int, sp: Spannable) { tagToSpannableCache[reactTag] = sp } @@ -831,18 +854,79 @@ internal object TextLayoutManager { YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt()) else -> desiredWidth } - return buildLayout( - text, - layoutWidth, - includeFontPadding, - textBreakStrategy, - hyphenationFrequency, - alignment, - justificationMode, - ellipsizeMode, - maxNumberOfLines, - paint, - ) + val enableStartOverhang = widthYogaMeasureMode == YogaMeasureMode.EXACTLY + val layout = + buildLayout( + text, + layoutWidth, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + ellipsizeMode, + maxNumberOfLines, + paint, + enableStartOverhang, + ) + + // 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) { + adjustLayoutForRtlRightOverhang(layout, layoutWidth) { adjustedWidth -> + buildLayout( + text, + adjustedWidth, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + ellipsizeMode, + maxNumberOfLines, + paint, + enableStartOverhang, + ) + } + } else { + layout + } + } + + @VisibleForTesting + internal fun adjustLayoutForRtlRightOverhang( + layout: Layout, + layoutWidth: Int, + rebuild: (Int) -> Layout, + ): Layout { + val rightOverhang = getRtlRightOverhang(layout) + return if (rightOverhang in 1 until layoutWidth) { + rebuild(layoutWidth - rightOverhang) + } else { + layout + } + } + + @VisibleForTesting + internal fun getRtlRightOverhang(layout: Layout): Int { + if ( + Build.VERSION.SDK_INT < VERSION_CODE_VANILLA_ICE_CREAM || + layout.lineCount == 0 || + (0 until layout.lineCount).any { + layout.getParagraphDirection(it) != Layout.DIR_RIGHT_TO_LEFT + } + ) { + return 0 + } + + val drawingBounds = + try { + computeDrawingBoundingBoxMethod?.invoke(layout) as? RectF + } catch (_: ReflectiveOperationException) { + null + } ?: return 0 + + return ceil(drawingBounds.right - layout.width).toInt().coerceAtLeast(0) } private fun buildLayout( @@ -856,6 +940,7 @@ internal object TextLayoutManager { ellipsizeMode: TextUtils.TruncateAt?, maxNumberOfLines: Int, paint: TextPaint, + enableStartOverhang: Boolean, ): Layout { val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth) @@ -877,6 +962,14 @@ internal object TextLayoutManager { builder.setUseLineSpacingFromFallbacks(true) } + // Android shifts negative (left-side) start overhang itself. RTL start overhang is on the + // right, so createLayout reserves that space in a second pass while preserving the EXACT Yoga + // measurement returned to the caller. + if (Build.VERSION.SDK_INT >= VERSION_CODE_VANILLA_ICE_CREAM) { + setUseBoundsForWidthMethod?.invoke(builder, enableStartOverhang) + setShiftDrawingOffsetForStartOverhangMethod?.invoke(builder, enableStartOverhang) + } + return builder.build() } @@ -1108,6 +1201,7 @@ internal object TextLayoutManager { ellipsizeMode, maximumNumberOfLines, paint, + /* enableStartOverhang = */ false, ) if (calculateLineCount(tightenedLayout, maximumNumberOfLines) == lineCount) { layout = tightenedLayout 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 new file mode 100644 index 000000000000..cde30f138633 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt @@ -0,0 +1,113 @@ +/* + * 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.graphics.RectF +import android.text.BoringLayout +import android.text.Layout +import android.text.SpannableString +import android.text.TextPaint +import android.text.TextUtils +import com.facebook.yoga.YogaMeasureMode +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class TextLayoutManagerStartOverhangTest { + + @Test + @Config(sdk = [35]) + fun `EXACTLY mode enables Android 15 start overhang support`() { + val layout = createLayout(YogaMeasureMode.EXACTLY) + + assertThat(getBooleanLayoutProperty(layout, "getUseBoundsForWidth")).isTrue() + assertThat(getBooleanLayoutProperty(layout, "getShiftDrawingOffsetForStartOverhang")).isTrue() + } + + @Test + @Config(sdk = [35]) + fun `AT_MOST mode keeps advance based width measurement`() { + val layout = createLayout(YogaMeasureMode.AT_MOST) + + assertThat(getBooleanLayoutProperty(layout, "getUseBoundsForWidth")).isFalse() + assertThat(getBooleanLayoutProperty(layout, "getShiftDrawingOffsetForStartOverhang")).isFalse() + } + + @Test + @Config(sdk = [35]) + fun `RTL right overhang is rounded up to reserve whole pixels`() { + val layout = mock() + whenever(layout.lineCount).thenReturn(2) + whenever(layout.width).thenReturn(200) + whenever(layout.getParagraphDirection(any())).thenReturn(Layout.DIR_RIGHT_TO_LEFT) + whenever(layout.computeDrawingBoundingBox()).thenReturn(RectF(10f, 0f, 207.1f, 40f)) + + assertThat(TextLayoutManager.getRtlRightOverhang(layout)).isEqualTo(8) + } + + @Test + @Config(sdk = [34]) + fun `EXACTLY mode remains supported before Android 15`() { + val layout = createLayout(YogaMeasureMode.EXACTLY) + + assertThat(layout.width).isEqualTo(LAYOUT_WIDTH.toInt()) + } + + private fun createLayout(widthMode: YogaMeasureMode): Layout { + val text = SpannableString("\u0622\u064a\u0629 \u0627\u0644\u0643\u0631\u0633\u064a") + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f } + val method = + TextLayoutManager::class + .java + .getDeclaredMethod( + "createLayout", + android.text.Spannable::class.java, + BoringLayout.Metrics::class.java, + java.lang.Float.TYPE, + YogaMeasureMode::class.java, + java.lang.Boolean.TYPE, + java.lang.Integer.TYPE, + java.lang.Integer.TYPE, + Layout.Alignment::class.java, + java.lang.Integer.TYPE, + TextUtils.TruncateAt::class.java, + java.lang.Integer.TYPE, + TextPaint::class.java, + ) + .apply { isAccessible = true } + + return method.invoke( + TextLayoutManager, + text, + null, + LAYOUT_WIDTH, + widthMode, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ 2, + paint, + ) as Layout + } + + private fun getBooleanLayoutProperty(layout: Layout, methodName: String): Boolean = + layout.javaClass.getMethod(methodName).invoke(layout) as Boolean + + private companion object { + const val LAYOUT_WIDTH = 200f + } +} From 9b7ce0cc711d8f506851b278e270a95f0560fa37 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 25 Sep 2026 08:18:12 -0700 Subject: [PATCH 2/3] Encapsulate Android 15 text-layout reflection Summary: Wrap the three Android 15 text-layout APIs behind typed private helpers that mirror their platform signatures. This keeps reflection isolated and makes replacing each helper with a direct API call a local change once all targets compile against Android 15 or later. Changelog: [Internal] Differential Revision: D121810388 --- .../react/views/text/TextLayoutManager.kt | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) 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 8e4d2665bd1c..53a67377158f 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 @@ -116,8 +116,8 @@ internal object TextLayoutManager { private val tagToSpannableCache = ConcurrentHashMap() - // Lazily cached methods for showing glyph ink that overhangs the start of a line (API 35+). - // Reflection is needed because some internal targets compile against an SDK older than 35. + // These wrappers mirror Android 15 APIs but use reflection because some internal targets still + // compile against Android 14. They return null when the API is unavailable or cannot be invoked. private val setUseBoundsForWidthMethod: java.lang.reflect.Method? by lazy { try { StaticLayout.Builder::class @@ -128,6 +128,16 @@ internal object TextLayoutManager { } } + private fun setUseBoundsForWidth( + builder: StaticLayout.Builder, + useBoundsForWidth: Boolean, + ): StaticLayout.Builder? = + try { + setUseBoundsForWidthMethod?.invoke(builder, useBoundsForWidth) as? StaticLayout.Builder + } catch (_: ReflectiveOperationException) { + null + } + private val setShiftDrawingOffsetForStartOverhangMethod: java.lang.reflect.Method? by lazy { try { StaticLayout.Builder::class @@ -141,6 +151,19 @@ internal object TextLayoutManager { } } + private fun setShiftDrawingOffsetForStartOverhang( + builder: StaticLayout.Builder, + shiftDrawingOffsetForStartOverhang: Boolean, + ): StaticLayout.Builder? = + try { + setShiftDrawingOffsetForStartOverhangMethod?.invoke( + builder, + shiftDrawingOffsetForStartOverhang, + ) as? StaticLayout.Builder + } catch (_: ReflectiveOperationException) { + null + } + private val computeDrawingBoundingBoxMethod: java.lang.reflect.Method? by lazy { try { Layout::class.java.getMethod("computeDrawingBoundingBox") @@ -149,6 +172,13 @@ internal object TextLayoutManager { } } + private fun computeDrawingBoundingBox(layout: Layout): RectF? = + try { + computeDrawingBoundingBoxMethod?.invoke(layout) as? RectF + } catch (_: ReflectiveOperationException) { + null + } + fun setCachedSpannableForTag(reactTag: Int, sp: Spannable) { tagToSpannableCache[reactTag] = sp } @@ -919,12 +949,7 @@ internal object TextLayoutManager { return 0 } - val drawingBounds = - try { - computeDrawingBoundingBoxMethod?.invoke(layout) as? RectF - } catch (_: ReflectiveOperationException) { - null - } ?: return 0 + val drawingBounds = computeDrawingBoundingBox(layout) ?: return 0 return ceil(drawingBounds.right - layout.width).toInt().coerceAtLeast(0) } @@ -966,8 +991,8 @@ internal object TextLayoutManager { // right, so createLayout reserves that space in a second pass while preserving the EXACT Yoga // measurement returned to the caller. if (Build.VERSION.SDK_INT >= VERSION_CODE_VANILLA_ICE_CREAM) { - setUseBoundsForWidthMethod?.invoke(builder, enableStartOverhang) - setShiftDrawingOffsetForStartOverhangMethod?.invoke(builder, enableStartOverhang) + setUseBoundsForWidth(builder, enableStartOverhang) + setShiftDrawingOffsetForStartOverhang(builder, enableStartOverhang) } return builder.build() From cc28911bedf7b211a6dcdde1a20fd7c3e04b5634 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 25 Sep 2026 10:17:38 -0700 Subject: [PATCH 3/3] Guard RTL overhang handling for fixed-line text Summary: Add Android regression coverage for exactly constrained RTL text that reserves start-side ink overhang while enforcing a maximum line count and ellipsizing overflow. Also cover the mixed-direction exclusion, where a shared right-side reservation must not be applied. Changelog: [Internal] Differential Revision: D121670747 --- .../TextLayoutManagerStartOverhangTest.kt | 78 +++++++++++++++++-- 1 file changed, 72 insertions(+), 6 deletions(-) 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 cde30f138633..2f23db9c3047 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 @@ -7,6 +7,7 @@ package com.facebook.react.views.text +import android.annotation.SuppressLint import android.graphics.RectF import android.text.BoringLayout import android.text.Layout @@ -24,6 +25,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@SuppressLint("NewApi") class TextLayoutManagerStartOverhangTest { @Test @@ -56,6 +58,64 @@ class TextLayoutManagerStartOverhangTest { assertThat(TextLayoutManager.getRtlRightOverhang(layout)).isEqualTo(8) } + @Test + @Config(sdk = [35]) + fun `RTL overhang reservation preserves max lines and ellipsis`() { + val initialLayout = mock() + whenever(initialLayout.lineCount).thenReturn(2) + whenever(initialLayout.width).thenReturn(LAYOUT_WIDTH.toInt()) + whenever(initialLayout.getParagraphDirection(any())).thenReturn(Layout.DIR_RIGHT_TO_LEFT) + whenever(initialLayout.computeDrawingBoundingBox()) + .thenReturn(RectF(10f, 0f, LAYOUT_WIDTH + 7.1f, 40f)) + var rebuiltWidth = 0 + + val layout = + TextLayoutManager.adjustLayoutForRtlRightOverhang( + initialLayout, + LAYOUT_WIDTH.toInt(), + ) { adjustedWidth -> + rebuiltWidth = adjustedWidth + createLayout( + YogaMeasureMode.EXACTLY, + text = + SpannableString( + listOf( + "\u200Ffirst paragraph", + "\u200Fsecond paragraph", + "\u200Fthird paragraph", + ) + .joinToString("\n"), + ), + layoutWidth = adjustedWidth.toFloat(), + ellipsizeMode = TextUtils.TruncateAt.END, + maxNumberOfLines = 2, + ) + } + + assertThat(rebuiltWidth).isEqualTo(192) + assertThat(layout.width).isEqualTo(rebuiltWidth) + assertThat(layout.lineCount).isEqualTo(2) + assertThat(layout.getEllipsisCount(layout.lineCount - 1)).isGreaterThan(0) + } + + @Test + @Config(sdk = [35]) + fun `mixed direction text does not reserve RTL right overhang`() { + val initialLayout = mock() + whenever(initialLayout.lineCount).thenReturn(2) + whenever(initialLayout.width).thenReturn(200) + whenever(initialLayout.getParagraphDirection(0)).thenReturn(Layout.DIR_RIGHT_TO_LEFT) + whenever(initialLayout.getParagraphDirection(1)).thenReturn(Layout.DIR_LEFT_TO_RIGHT) + whenever(initialLayout.computeDrawingBoundingBox()).thenReturn(RectF(10f, 0f, 208f, 40f)) + + val layout = + TextLayoutManager.adjustLayoutForRtlRightOverhang(initialLayout, 200) { + throw AssertionError("Mixed-direction text must not be rebuilt") + } + + assertThat(layout).isSameAs(initialLayout) + } + @Test @Config(sdk = [34]) fun `EXACTLY mode remains supported before Android 15`() { @@ -64,9 +124,15 @@ class TextLayoutManagerStartOverhangTest { assertThat(layout.width).isEqualTo(LAYOUT_WIDTH.toInt()) } - private fun createLayout(widthMode: YogaMeasureMode): Layout { - val text = SpannableString("\u0622\u064a\u0629 \u0627\u0644\u0643\u0631\u0633\u064a") - val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f } + private fun createLayout( + widthMode: YogaMeasureMode, + text: SpannableString = + SpannableString("\u0622\u064a\u0629 \u0627\u0644\u0643\u0631\u0633\u064a"), + layoutWidth: Float = LAYOUT_WIDTH, + ellipsizeMode: TextUtils.TruncateAt? = null, + maxNumberOfLines: Int = 2, + paint: TextPaint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f }, + ): Layout { val method = TextLayoutManager::class .java @@ -91,15 +157,15 @@ class TextLayoutManagerStartOverhangTest { TextLayoutManager, text, null, - LAYOUT_WIDTH, + layoutWidth, widthMode, /* includeFontPadding = */ false, /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, Layout.Alignment.ALIGN_NORMAL, /* justificationMode = */ 0, - /* ellipsizeMode = */ null, - /* maxNumberOfLines = */ 2, + /* ellipsizeMode = */ ellipsizeMode, + /* maxNumberOfLines = */ maxNumberOfLines, paint, ) as Layout }