fix(android): keep ReactTextView line breaking advance-based on Android 15+ so the last line is not clipped - #58280
Conversation
…id 15+ On Android 15+ a TextView in an app targeting API 35+ breaks lines on glyph bounds (TextView#USE_BOUNDS_FOR_WIDTH), while TextLayoutManager measures with a StaticLayout that breaks on glyph advances. With enablePreparedTextLayout off, ReactTextView draws its own TextView layout, so a font whose ink overhangs its advance can wrap one more line at draw than at measure; that line lands outside the measured height and the last word disappears. Opt ReactTextView out of bounds-based breaking, in the constructor and again on recycle, so the painted line count matches the measured one. Resolved reflectively like TextLayoutManager's own SDK-35 lookup, since internal targets compile against an older SDK. Adds an rn-tester Text example that reproduces the clipping with the stock `cursive` family, and Robolectric tests for the view default and the recycle path. Fixes react#56402 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8dc996d to
b423735
Compare
|
The single red check ( |
javache
left a comment
There was a problem hiding this comment.
Why do we need to use reflection if we have the Build version SDK check?
Do we need to upgrade the RN build SDK version? cc @cortinico
|
The |
Summary
On Android 15+ (API 35), an app that targets API 35+ gets bounds-based line breaking in every
TextViewby default — the platform compat changeTextView#USE_BOUNDS_FOR_WIDTH:React Native measures
<Text>inTextLayoutManagerwith aStaticLayoutthat breaks lines on glyph advances (buildLayoutnever setssetUseBoundsForWidth). WithenablePreparedTextLayoutoff (the default), the pixels on screen come fromReactTextView's ownTextViewlayout —ReactTextView.setText()hands the Spannable toTextViewandonDraw()defers tosuper.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
ReactTextViewout of bounds-based breaking so the drawn layout uses the same advance-based line breaking as measurement. Applied in the constructor and again inrecycleView()so recycled views cannot drift. The call is resolved reflectively, following the existingsetUseBoundsForWidthpattern inTextLayoutManager, because some internal targets compile against an SDK older than 35 (seeAndroidVersion).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/UNDEFINEDmeasurement, which does not reach a width-constrained paragraph whose lines are re-broken by theTextViewat 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/#54871.
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)
Test Plan
Deterministic repro (stock emulator, no custom font)
API 35/36 AVD, app targeting API 35+. Android's generic
cursivefamily (Dancing Script) overhangs heavily. Inside a shrink-wrapping container:Before: the green
fis not painted. The view is sized for it (measure), but theTextViewbreaks the line on bounds, wraps thefto 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 thef— while a control row with a non-overhanging font (Roboto) always keeps it.After: the
fis painted on the first line.Before (rn-tester
Textexample, API 36 emulator — the cursive column loses itsfon 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 greenf.Unit tests
ReactTextViewTest:breaksLinesOnAdvancesLikeMeasurementOnApi35— a freshly constructedReactTextViewreportsuseBoundsForWidth == falseon API 35.recyclingRestoresAdvanceBasedLineBreaking— afteruseBoundsForWidth = true,recycleView()restoresfalse.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
cursivefamily as shown here.🤖 Generated with Claude Code