Skip to content

Commit c931fa8

Browse files
chrfalchreact-native-bot
authored andcommitted
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 f2fabd1 commit c931fa8

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
@@ -620,15 +620,89 @@ internal object TextLayoutManager {
620620
)
621621
}
622622

623-
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
623+
// Pre-Android 15: Use existing advance-based logic
624+
if (
625+
Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM ||
626+
!ReactNativeFeatureFlags.fixTextClippingAndroid15useBoundsForWidth()
627+
) {
628+
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
629+
630+
val layoutWidth =
631+
when (widthYogaMeasureMode) {
632+
YogaMeasureMode.EXACTLY -> floor(width).toInt()
633+
YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
634+
else -> desiredWidth
635+
}
636+
return buildLayout(
637+
text,
638+
layoutWidth,
639+
includeFontPadding,
640+
textBreakStrategy,
641+
hyphenationFrequency,
642+
alignment,
643+
justificationMode,
644+
ellipsizeMode,
645+
maxNumberOfLines,
646+
paint,
647+
)
648+
}
649+
650+
// Android 15+: Need to account for visual bounds
651+
// Step 1: Create unconstrained layout to get visual bounds width
652+
val unconstrainedLayout =
653+
buildLayout(
654+
text,
655+
Int.MAX_VALUE / 2,
656+
includeFontPadding,
657+
textBreakStrategy,
658+
hyphenationFrequency,
659+
alignment,
660+
justificationMode,
661+
null,
662+
ReactConstants.UNSET,
663+
paint,
664+
)
665+
666+
// Calculate visual bounds width from unconstrained layout
667+
var desiredVisualWidth = 0f
668+
for (i in 0 until unconstrainedLayout.lineCount) {
669+
val lineWidth = unconstrainedLayout.getLineRight(i) - unconstrainedLayout.getLineLeft(i)
670+
desiredVisualWidth = max(desiredVisualWidth, lineWidth)
671+
}
624672

625673
val layoutWidth =
626674
when (widthYogaMeasureMode) {
627-
YogaMeasureMode.EXACTLY -> floor(width).toInt()
628-
YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
629-
else -> desiredWidth
675+
YogaMeasureMode.AT_MOST -> min(ceil(desiredVisualWidth).toInt(), floor(width).toInt())
676+
else -> ceil(desiredVisualWidth).toInt()
630677
}
631678

679+
// Step 2: Create final layout with correct width
680+
return buildLayout(
681+
text,
682+
layoutWidth,
683+
includeFontPadding,
684+
textBreakStrategy,
685+
hyphenationFrequency,
686+
alignment,
687+
justificationMode,
688+
ellipsizeMode,
689+
maxNumberOfLines,
690+
paint,
691+
)
692+
}
693+
694+
private fun buildLayout(
695+
text: Spannable,
696+
layoutWidth: Int,
697+
includeFontPadding: Boolean,
698+
textBreakStrategy: Int,
699+
hyphenationFrequency: Int,
700+
alignment: Layout.Alignment,
701+
justificationMode: Int,
702+
ellipsizeMode: TextUtils.TruncateAt?,
703+
maxNumberOfLines: Int,
704+
paint: TextPaint,
705+
): Layout {
632706
val builder =
633707
StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth)
634708
.setAlignment(alignment)
@@ -649,6 +723,13 @@ internal object TextLayoutManager {
649723
builder.setUseLineSpacingFromFallbacks(true)
650724
}
651725

726+
if (
727+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
728+
ReactNativeFeatureFlags.fixTextClippingAndroid15useBoundsForWidth()
729+
) {
730+
builder.setUseBoundsForWidth(true)
731+
}
732+
652733
return builder.build()
653734
}
654735

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)