mirror of
https://github.com/react/react-native.git
synced 2026-09-28 13:23:09 +08:00
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:
committed by
meta-codesync[bot]
parent
1d13ec9889
commit
57f408012d
+107
-13
@@ -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
|
||||
|
||||
+113
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user