Skip to content

Commit 19b73cf

Browse files
committed
Fix text clipping on Android 15+ due to useBoundsForWidth change (#54721)
Summary: Android 15 (API 35) changed TextView's default behavior to use visual glyph bounds for width calculation (useBoundsForWidth=true). This causes text clipping for italic fonts and other typefaces where glyphs extend beyond their advance width: https://developer.android.com/about/versions/15/behavior-changes-15 This PR uses a two-pass layout approach on Android 15+: 1. Create an unconstrained layout with setUseBoundsForWidth(true) to measure the actual visual bounds width 2. Create the final layout using the visual bounds width as layoutWidth This ensures the container is sized correctly to accommodate the full visual extent of the text, preventing clipping while maintaining backwards compatibility with earlier Android versions. Performance impact: - On Android 14 and below: No change - On Android 15+: Creates two StaticLayout instances instead of one for text with unconstrained or AT_MOST width. This overhead is mitigated by React Native's text measurement caching in the C++ layer, which avoids redundant measurements. Text with EXACTLY width mode is unaffected as it only requires a single layout pass. Closes #53286 Thanks a lot to intergalacticspacehighway for finding the root cause and creating a proof of concept fix. ## Changelog: [ANDROID] [FIXED] - Fix text clipping on Android 15+ due to useBoundsForWidth change Pull Request resolved: #54721 Test Plan: Tested on RNTester + Android 16 on physical device: | before | after | | -- | | {F1983971719} | {F1983971720} | Also tested on the reproducer provided by #53286 | before | after | | -- | | {F1983971723} | {F1983971718} | Reviewed By: javache Differential Revision: D88001642 Pulled By: cortinico fbshipit-source-id: 4bc20ef6da47bd4141f6b19fcc3e93ea7f0c343c
1 parent a23dd04 commit 19b73cf

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

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

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -542,15 +542,89 @@ internal object TextLayoutManager {
542542
text, paint, layoutWidth, alignment, 1f, 0f, boring, includeFontPadding)
543543
}
544544

545-
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
545+
// Pre-Android 15: Use existing advance-based logic
546+
if (
547+
Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM ||
548+
!ReactNativeFeatureFlags.fixTextClippingAndroid15useBoundsForWidth()
549+
) {
550+
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
551+
552+
val layoutWidth =
553+
when (widthYogaMeasureMode) {
554+
YogaMeasureMode.EXACTLY -> floor(width).toInt()
555+
YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
556+
else -> desiredWidth
557+
}
558+
return buildLayout(
559+
text,
560+
layoutWidth,
561+
includeFontPadding,
562+
textBreakStrategy,
563+
hyphenationFrequency,
564+
alignment,
565+
justificationMode,
566+
ellipsizeMode,
567+
maxNumberOfLines,
568+
paint,
569+
)
570+
}
571+
572+
// Android 15+: Need to account for visual bounds
573+
// Step 1: Create unconstrained layout to get visual bounds width
574+
val unconstrainedLayout =
575+
buildLayout(
576+
text,
577+
Int.MAX_VALUE / 2,
578+
includeFontPadding,
579+
textBreakStrategy,
580+
hyphenationFrequency,
581+
alignment,
582+
justificationMode,
583+
null,
584+
ReactConstants.UNSET,
585+
paint,
586+
)
587+
588+
// Calculate visual bounds width from unconstrained layout
589+
var desiredVisualWidth = 0f
590+
for (i in 0 until unconstrainedLayout.lineCount) {
591+
val lineWidth = unconstrainedLayout.getLineRight(i) - unconstrainedLayout.getLineLeft(i)
592+
desiredVisualWidth = max(desiredVisualWidth, lineWidth)
593+
}
546594

547595
val layoutWidth =
548596
when (widthYogaMeasureMode) {
549-
YogaMeasureMode.EXACTLY -> floor(width).toInt()
550-
YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
551-
else -> desiredWidth
597+
YogaMeasureMode.AT_MOST -> min(ceil(desiredVisualWidth).toInt(), floor(width).toInt())
598+
else -> ceil(desiredVisualWidth).toInt()
552599
}
553600

601+
// Step 2: Create final layout with correct width
602+
return buildLayout(
603+
text,
604+
layoutWidth,
605+
includeFontPadding,
606+
textBreakStrategy,
607+
hyphenationFrequency,
608+
alignment,
609+
justificationMode,
610+
ellipsizeMode,
611+
maxNumberOfLines,
612+
paint,
613+
)
614+
}
615+
616+
private fun buildLayout(
617+
text: Spannable,
618+
layoutWidth: Int,
619+
includeFontPadding: Boolean,
620+
textBreakStrategy: Int,
621+
hyphenationFrequency: Int,
622+
alignment: Layout.Alignment,
623+
justificationMode: Int,
624+
ellipsizeMode: TextUtils.TruncateAt?,
625+
maxNumberOfLines: Int,
626+
paint: TextPaint,
627+
): Layout {
554628
val builder =
555629
StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth)
556630
.setAlignment(alignment)
@@ -571,6 +645,13 @@ internal object TextLayoutManager {
571645
builder.setUseLineSpacingFromFallbacks(true)
572646
}
573647

648+
if (
649+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
650+
ReactNativeFeatureFlags.fixTextClippingAndroid15useBoundsForWidth()
651+
) {
652+
builder.setUseBoundsForWidth(true)
653+
}
654+
574655
return builder.build()
575656
}
576657

packages/rn-tester/js/examples/Text/TextExample.android.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,10 @@ const examples = [
14081408
<RNTesterText style={{fontStyle: 'normal'}}>
14091409
Move fast and be normal
14101410
</RNTesterText>
1411+
<RNTesterText style={{fontStyle: 'italic'}}>
1412+
Move fast and be italic, but just be longer so that you don't fit on
1413+
a single line and make sure text is not truncated.
1414+
</RNTesterText>
14111415
</>
14121416
);
14131417
},

0 commit comments

Comments
 (0)