Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -112,13 +114,29 @@ 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 {
private val startOverhangMethods: List<java.lang.reflect.Method>? 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
}
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}