Fix RTL start overhang clipping on Android 15+ (#58072)

Summary:
Fixes https://github.com/react/react-native/issues/58064.

Android 15 added glyph-bounds APIs for `StaticLayout`, but `Layout.draw()` only shifts drawing when ink extends past the left edge. In an exactly constrained RTL paragraph, leading ink extends past the right edge instead, so fonts such as Amiri still clip their first glyph at a wrapped line start.

This change:

- enables `setUseBoundsForWidth` and `setShiftDrawingOffsetForStartOverhang` for exactly constrained `StaticLayout`s on API 35+ using the existing reflection approach;
- measures the actual RTL drawing bounds and reserves any right-side overhang in a focused second layout pass while keeping the original exact width reported to Yoga;
- leaves `AT_MOST` and unconstrained text on the existing advance-based measurement path.

On the issue reproducer, the first layout was 1280 px wide while its glyph bounds extended to x=1287.07. Reserving 8 px in the text layout keeps the complete alif-madda ink inside the unchanged 1280 px React Native view.

## Changelog:

[ANDROID] [FIXED] - Prevent RTL line-start glyph ink from clipping on Android 15 and later

Pull Request resolved: https://github.com/react/react-native/pull/58072

Test Plan:
- `JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests com.facebook.react.views.text.TextLayoutManagerStartOverhangTest -Preact.internal.useHermesStable=true --no-daemon` — BUILD SUCCESSFUL
- `JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.views.text.*' -Preact.internal.useHermesStable=true --no-daemon` — BUILD SUCCESSFUL
- `JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :packages:react-native:ReactAndroid:ktfmtCheck -Preact.internal.useHermesStable=true --no-daemon --rerun-tasks` — BUILD SUCCESSFUL
- Built and installed RNTester on a Pixel 9 Pro emulator running Android 16 / API 36 with `enablePreparedTextLayout` enabled and the Amiri font from the mandatory reproducer. Before the fix, ink reached and was cut at the final pixel column; after the fix, the complete stroke renders inside the tinted Text bounds.

### Visual evidence

**Original clipping reproduction — before and after**

{F1997326330}

**Fixed-line validation at 605 px with `maxLines=5` — before and after**

{F1997312819}

Reviewed By: javache

Differential Revision: D121672279

Pulled By: Abbondanzo

fbshipit-source-id: a66b1a11b434043b849ac71519c58ce7b60b118c
This commit is contained in:
Ngoc Le
2026-09-25 11:59:44 -07:00
committed by meta-codesync[bot]
parent 1d13ec9889
commit 57f408012d
2 changed files with 220 additions and 13 deletions
@@ -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<Int, Spannable>()
// 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
@@ -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<Layout>()
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
}
}