Fix Android 15 text clipping with safe visual bounds measurement - #57117
TorinAsakura wants to merge 5 commits into
Conversation
|
This should be merged ASAP! |
|
I don't have enough experience to speak about whether the code itself is correct but I tested out the code by patching it into my application and it fixes both the issue where a part of the text is cut off and replaced by empty space and the issue where the text is visible but it's split into 2 lines even though it shouldn't be and there is enough space for it 👍 |
|
@chrfalch Can anyone please take a look at this PR? Thanks |
j-piasecki
left a comment
There was a problem hiding this comment.
Could you post before & after screenshots along with the code you used to test?
Heave you checked if this is still needed as of React Native 0.87?
| // rare subpixel edge case. | ||
| val layoutWidth = | ||
| if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) ceil(width).toInt() else boring.width | ||
| return BoringLayout.make( |
There was a problem hiding this comment.
What about boring layouts?
There was a problem hiding this comment.
After the order swap (layoutWidth being declared before BoringLayout.make early return), the BoringLayout fast path is no longer fast. It requires a StaticLayout build (buildLayout inside getDesiredWidth) in addition to the BoringLayout.
There was a problem hiding this comment.
After the order swap (layoutWidth being declared before
BoringLayout.makeearly return), theBoringLayoutfast path is no longer fast. It requires aStaticLayoutbuild (buildLayoutinsidegetDesiredWidth) in addition to theBoringLayout.
I think we can avoid the extra StaticLayout here. BoringLayout.isBoring already gives us both the advance width and the drawing bounds from the same TextLine.metrics call, and TextView uses those bounds directly when it measures boring text. So for this path we could calculate the width from BoringLayout.Metrics and keep the fast path, while leaving non-boring text on the current probe path.
The catch is that [this code is still compiled internally against SDK 34](
The current boring test would need changing too. Right now it sets Metrics.width to 1 and checks that the probe wins, so it basically locks us into the extra layout. I’d replace it with cases around the actual bounds, the Yoga modes, and the API 34 fallback.
There’s also the left overhang. Public BoringLayout.make doesn’t enable shiftDrawingOffsetForStartOverhang, so using the bounds would reserve enough width and fix the right edge, but it wouldn’t move a glyph that starts left of zero. I’d leave that out of this change unless you expect this PR to match TextView on that side too.
If that direction makes sense, I’ll rework the patch and tests around it.
I checked the React Native 0.87.1 and i see TextLayoutManager still use the unconstrained width through Layout.getDesiredWidth, without accounting for Android 15 I also reproduced the issue on the latest main on Android 15/API 35 and the NotoSans matrix from the original reproducer. The baseline clips or unnecessarily wraps the smaller italic cells, while the same fixture with this PR applied keeps “Prison Break” on one line. Before: After: |
…id 15+ so the last line is not clipped (#58280) Summary: On Android 15+ (API 35), an app that targets API 35+ gets bounds-based line breaking in every `TextView` by default — the platform compat change `TextView#USE_BOUNDS_FOR_WIDTH`: ```java // frameworks/base/core/java/android/widget/TextView.java ChangeId EnabledSince(targetSdkVersion = VERSION_CODES.VANILLA_ICE_CREAM) public static final long USE_BOUNDS_FOR_WIDTH = 63938206; … if (!hasUseBoundForWidthValue) { mUseBoundsForWidth = CompatChanges.isChangeEnabled(USE_BOUNDS_FOR_WIDTH); } ``` React Native measures `<Text>` in `TextLayoutManager` with a `StaticLayout` that breaks lines on glyph **advances** (`buildLayout` never sets `setUseBoundsForWidth`). With `enablePreparedTextLayout` off (the default), the pixels on screen come from `ReactTextView`'s own `TextView` layout — `ReactTextView.setText()` hands the Spannable to `TextView` and `onDraw()` defers to `super.onDraw()`. That layout breaks lines on glyph **bounds**. So measurement and painting disagree on where lines break. For any font whose ink overhangs its advance (script/cursive fonts, several OEM system fonts, emoji fallbacks), a line that fits at measure time can wrap at draw time. The extra line lands outside the Yoga-measured height and is simply never seen: **the last word of a `<Text>` disappears**, while the view is sized as if it were there. This is the mechanism behind #56402 / #53286 (and the shape of #57957: content-sized parent, last line gone). It is independent of `lineHeight`, and it affects both shrink-wrapped single-line text and width-constrained wrapped paragraphs. ## The fix Opt `ReactTextView` out of bounds-based breaking so the drawn layout uses the same advance-based line breaking as measurement. Applied in the constructor and again in `recycleView()` so recycled views cannot drift. The call is resolved reflectively, following the existing `setUseBoundsForWidth` pattern in `TextLayoutManager`, because some internal targets compile against an SDK older than 35 (see `AndroidVersion`). This keeps the final layout on the advance-based behavior React Native has always had — the same principle #57117 states for the layouts it builds — but applies it where the pixels actually come from. It is complementary to #57117: that PR widens the *desired* width for `AT_MOST`/`UNDEFINED` measurement, which does not reach a width-constrained paragraph whose lines are re-broken by the `TextView` at draw time; this change makes both paths agree regardless of constraint mode. Trade-off: React Native forgoes Android 15's automatic reservation of overhang space at the edges of a line (glyph ink may be clipped at the view edge as it was before Android 15). That is the pre-existing behavior on every prior Android version, and strictly better than losing whole words. A follow-up could make *measurement* bounds-aware instead (platform parity), but that changes wrapping app-wide and was the direction of the reverted #54721. Fixes #56402 Related: #53286, #57957, #57117, #56864 ## Changelog: [ANDROID] [FIXED] - Text: the last line no longer disappears on Android 15+ when a font's glyphs overhang their advance (ReactTextView now breaks lines on advances, matching measurement) Pull Request resolved: #58280 Test Plan: ### Deterministic repro (stock emulator, no custom font) API 35/36 AVD, app targeting API 35+. Android's generic `cursive` family (Dancing Script) overhangs heavily. Inside a shrink-wrapping container: ```tsx <View style={{ alignSelf: 'flex-start' }}> <Text style={{ fontFamily: 'cursive', fontSize: 18, lineHeight: 27 }} allowFontScaling={false}> Enjoy your coffee<Text style={{ color: 'green' }}> f</Text> </Text> </View> ``` **Before:** the green `f` is not painted. The view is sized for it (measure), but the `TextView` breaks the line on bounds, wraps the `f` to a second line, and that line is outside the measured height. Which strings trip it depends on where the bounds-based break falls relative to the advance-based one — in the rn-tester example below two of the four cursive rows lose the `f` — while a control row with a non-overhanging font (Roboto) always keeps it. **After:** the `f` is painted on the first line. **Before** (rn-tester `Text` example, API 36 emulator — the cursive column loses its `f` on two of the four rows; the default-font control column keeps every one):  **After** (same example, this branch):  ### rn-tester `Text` → **"Android 15+ glyph overhang (last line must not disappear)"** — the rows above, cursive on the left with a default-font control on the right. Every row must show its green `f`. ### Unit tests `ReactTextViewTest`: - `breaksLinesOnAdvancesLikeMeasurementOnApi35` — a freshly constructed `ReactTextView` reports `useBoundsForWidth == false` on API 35. - `recyclingRestoresAdvanceBasedLineBreaking` — after `useBoundsForWidth = true`, `recycleView()` restores `false`. Below API 35 the reflective lookup returns null and the view is untouched. ### Origin Reported in production by a user on a Samsung SM-A566B (Android 16, One UI system font): trailing words vanished from chat messages while the message bubble was sized for the full text. Pinning a bundled font (Alef) in the app made it stop — consistent with the mechanism above — and the same symptom then reproduced on an AOSP emulator with the `cursive` family as shown here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: christophpurrer Differential Revision: D118632937 Pulled By: javache fbshipit-source-id: bb60e5549132c22d635c7ef96a9dba940e61b8e1


Summary:
Fixes #56402.
Fixes #57110.
Close torin-asakura/workspace#116.
Close torin-asakura/workspace#117.
Android 15+ can clip text whose visual glyph bounds are wider than its advance-based desired width. The previous visual-bounds path fixed that by enabling
setUseBoundsForWidth(true)on the finalStaticLayout, but that path also changed final layout behavior and was later removed after multiline and wrapping regressions.This keeps the final layout on the existing advance-based behavior, but uses an Android 15+ visual-bounds probe only when React Native needs to compute the desired width for
AT_MOSTorUNDEFINEDmeasure modes.EXACTLYcontinues to use the width from Yoga directly. The desired width is never allowed to shrink below the old advance-based width.Changelog:
[ANDROID] [FIXED] - Prevent Android 15+ text clipping without changing final StaticLayout bounds mode
Test Plan:
RNTester visual repro on a Pixel 7 API 35 emulator. Both runs used the same fixture and NotoSans font assets. The baseline is
mainatd6ba88e; the after screenshot uses the same fixture with this PR's fix applied locally.Fixture:
Details
Visual evidence:
Before
After