From 17a144fde58b29a84026783fb7fbe3bd65266b8e Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Sat, 22 Aug 2026 20:06:56 +0700 Subject: [PATCH] Fix Android RTL start overhang clipping --- .../react/views/text/TextLayoutManager.kt | 82 ++++++++++++- .../TextLayoutManagerStartOverhangTest.kt | 113 ++++++++++++++++++ 2 files changed, 189 insertions(+), 6 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 66e6f24fbb0a..920e45c1311b 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 @@ -112,13 +114,29 @@ 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 { + private val startOverhangMethods: List? by lazy { try { - StaticLayout.Builder::class - .java - .getMethod("setUseBoundsForWidth", Boolean::class.javaPrimitiveType) + listOf( + StaticLayout.Builder::class + .java + .getMethod("setUseBoundsForWidth", Boolean::class.javaPrimitiveType), + 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 } @@ -826,7 +844,8 @@ internal object TextLayoutManager { YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt()) else -> desiredWidth } - return buildLayout( + val enableStartOverhang = widthYogaMeasureMode == YogaMeasureMode.EXACTLY + var layout = buildLayout( text, layoutWidth, includeFontPadding, @@ -837,7 +856,50 @@ internal object TextLayoutManager { 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. + val rightOverhang = if (enableStartOverhang) getRtlRightOverhang(layout) else 0 + if (rightOverhang in 1 until layoutWidth) { + layout = buildLayout( + text, + layoutWidth - rightOverhang, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency, + alignment, + justificationMode, + ellipsizeMode, + maxNumberOfLines, + paint, + enableStartOverhang, + ) + } + return 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( @@ -851,6 +913,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) @@ -872,6 +935,13 @@ 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) { + startOverhangMethods?.forEach { it.invoke(builder, enableStartOverhang) } + } + 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 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 + } +}