Skip to content

Commit 308705e

Browse files
committed
fix(android): measure text visual bounds safely
1 parent 29ab78a commit 308705e

2 files changed

Lines changed: 110 additions & 5 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.facebook.react.uimanager.PixelUtil
4040
import com.facebook.react.uimanager.PixelUtil.dpToPx
4141
import com.facebook.react.uimanager.PixelUtil.pxToDp
4242
import com.facebook.react.uimanager.ReactAccessibilityDelegate
43+
import com.facebook.react.util.AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM
4344
import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan
4445
import com.facebook.react.views.text.internal.span.CustomLineHeightSpan
4546
import com.facebook.react.views.text.internal.span.CustomStyleSpan
@@ -764,13 +765,32 @@ internal object TextLayoutManager {
764765
)
765766
}
766767

767-
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
768-
769768
val layoutWidth =
770769
when (widthYogaMeasureMode) {
771770
YogaMeasureMode.EXACTLY -> ceil(width).toInt()
772-
YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
773-
else -> desiredWidth
771+
YogaMeasureMode.AT_MOST ->
772+
min(
773+
getDesiredWidth(
774+
text,
775+
includeFontPadding,
776+
textBreakStrategy,
777+
hyphenationFrequency,
778+
alignment,
779+
justificationMode,
780+
paint,
781+
),
782+
floor(width).toInt(),
783+
)
784+
else ->
785+
getDesiredWidth(
786+
text,
787+
includeFontPadding,
788+
textBreakStrategy,
789+
hyphenationFrequency,
790+
alignment,
791+
justificationMode,
792+
paint,
793+
)
774794
}
775795
return buildLayout(
776796
text,
@@ -786,6 +806,47 @@ internal object TextLayoutManager {
786806
)
787807
}
788808

809+
private fun getDesiredWidth(
810+
text: Spannable,
811+
includeFontPadding: Boolean,
812+
textBreakStrategy: Int,
813+
hyphenationFrequency: Int,
814+
alignment: Layout.Alignment,
815+
justificationMode: Int,
816+
paint: TextPaint,
817+
): Int {
818+
val advanceWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
819+
820+
if (
821+
Build.VERSION.SDK_INT < VERSION_CODE_VANILLA_ICE_CREAM ||
822+
setUseBoundsForWidthMethod == null
823+
) {
824+
return advanceWidth
825+
}
826+
827+
val visualBoundsLayout =
828+
buildLayout(
829+
text,
830+
Int.MAX_VALUE / 2,
831+
includeFontPadding,
832+
textBreakStrategy,
833+
hyphenationFrequency,
834+
alignment,
835+
justificationMode,
836+
null,
837+
ReactConstants.UNSET,
838+
paint,
839+
useBoundsForWidth = true,
840+
)
841+
842+
var visualBoundsWidth = 0f
843+
for (i in 0 until visualBoundsLayout.lineCount) {
844+
visualBoundsWidth = max(visualBoundsWidth, visualBoundsLayout.getLineMax(i))
845+
}
846+
847+
return max(advanceWidth, ceil(visualBoundsWidth).toInt())
848+
}
849+
789850
private fun buildLayout(
790851
text: Spannable,
791852
layoutWidth: Int,
@@ -797,6 +858,7 @@ internal object TextLayoutManager {
797858
ellipsizeMode: TextUtils.TruncateAt?,
798859
maxNumberOfLines: Int,
799860
paint: TextPaint,
861+
useBoundsForWidth: Boolean = false,
800862
): Layout {
801863
val builder =
802864
StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth)
@@ -818,6 +880,10 @@ internal object TextLayoutManager {
818880
builder.setUseLineSpacingFromFallbacks(true)
819881
}
820882

883+
if (useBoundsForWidth) {
884+
setUseBoundsForWidthMethod?.invoke(builder, true)
885+
}
886+
821887
return builder.build()
822888
}
823889

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import android.text.TextPaint
1515
import android.text.TextUtils
1616
import com.facebook.yoga.YogaMeasureMode
1717
import kotlin.math.ceil
18+
import kotlin.math.floor
1819
import org.assertj.core.api.Assertions.assertThat
1920
import org.junit.Test
2021
import org.junit.runner.RunWith
22+
import org.robolectric.annotation.Config
2123
import org.robolectric.RobolectricTestRunner
2224

2325
/**
@@ -77,6 +79,42 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
7779
.isGreaterThanOrEqualTo(renderedLineWidth)
7880
}
7981

82+
@Test
83+
@Config(sdk = [35])
84+
fun `Android 15 EXACTLY mode keeps the width provided by Yoga`() {
85+
val text = SpannableString("First line\nSecond line")
86+
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
87+
val exactWidth = 48.5f
88+
89+
val layout = invokeCreateLayout(text, exactWidth, paint)
90+
91+
assertThat(layout.width).isEqualTo(ceil(exactWidth).toInt())
92+
}
93+
94+
@Test
95+
@Config(sdk = [35])
96+
fun `Android 15 AT_MOST mode does not exceed the width provided by Yoga`() {
97+
val text = SpannableString("Prison Break")
98+
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
99+
val maxWidth = 8.5f
100+
101+
val layout = invokeCreateLayout(text, maxWidth, paint, YogaMeasureMode.AT_MOST)
102+
103+
assertThat(layout.width).isLessThanOrEqualTo(floor(maxWidth).toInt())
104+
}
105+
106+
@Test
107+
@Config(sdk = [35])
108+
fun `Android 15 UNDEFINED mode does not shrink below advance width`() {
109+
val text = SpannableString("Prison Break")
110+
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
111+
val advanceWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
112+
113+
val layout = invokeCreateLayout(text, 0f, paint, YogaMeasureMode.UNDEFINED)
114+
115+
assertThat(layout.width).isGreaterThanOrEqualTo(advanceWidth)
116+
}
117+
80118
/**
81119
* Invokes the private TextLayoutManager.createLayout via reflection. We can't call it directly
82120
* because it's `private` (friend_paths only opens up `internal`). Default values mirror what
@@ -90,6 +128,7 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
90128
text: SpannableString,
91129
width: Float,
92130
paint: TextPaint,
131+
widthYogaMeasureMode: YogaMeasureMode = YogaMeasureMode.EXACTLY,
93132
): Layout {
94133
val boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint)
95134
val method =
@@ -117,7 +156,7 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
117156
text,
118157
boring,
119158
width,
120-
YogaMeasureMode.EXACTLY,
159+
widthYogaMeasureMode,
121160
/* includeFontPadding = */ false,
122161
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
123162
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,

0 commit comments

Comments
 (0)