Skip to content

Commit dcdb52b

Browse files
idoyanameta-codesync[bot]
authored andcommitted
fix(android): keep ReactTextView line breaking advance-based on Android 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): ![before](https://raw.githubusercontent.com/idoyana/react-native/pr-assets/android-text-line-breaking/before-cursive-api36.png) **After** (same example, this branch): ![after](https://raw.githubusercontent.com/idoyana/react-native/pr-assets/android-text-line-breaking/after-cursive-api36.png) ### 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
1 parent 025a862 commit dcdb52b

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import android.view.MotionEvent;
2525
import android.view.View;
2626
import android.view.ViewGroup;
27+
import android.widget.TextView;
2728
import androidx.annotation.Nullable;
2829
import androidx.appcompat.widget.AppCompatTextView;
2930
import androidx.core.view.AccessibilityDelegateCompat;
@@ -46,10 +47,12 @@
4647
import com.facebook.react.uimanager.style.BorderStyle;
4748
import com.facebook.react.uimanager.style.LogicalEdge;
4849
import com.facebook.react.uimanager.style.Overflow;
50+
import com.facebook.react.util.AndroidVersion;
4951
import com.facebook.react.views.text.internal.span.CanvasEffectSpan;
5052
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan;
5153
import com.facebook.react.views.text.internal.span.ReactTagSpan;
5254
import com.facebook.yoga.YogaMeasureMode;
55+
import java.lang.reflect.Method;
5356

5457
@Nullsafe(Nullsafe.Mode.LOCAL)
5558
public class ReactTextView extends AppCompatTextView implements ReactCompoundView {
@@ -60,6 +63,10 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
6063
// https://github.com/aosp-mirror/platform_frameworks_base/blob/master/core/java/android/widget/TextView.java#L854
6164
private static final int DEFAULT_GRAVITY = Gravity.TOP | Gravity.START;
6265

66+
// TextView.setUseBoundsForWidth (API 35+). Looked up reflectively because some internal targets
67+
// compile against an SDK older than 35 (see AndroidVersion).
68+
private static final @Nullable Method SET_USE_BOUNDS_FOR_WIDTH = resolveSetUseBoundsForWidth();
69+
6370
private int mNumberOfLines;
6471
private @Nullable TextUtils.TruncateAt mEllipsizeLocation;
6572
private boolean mAdjustsFontSizeToFit;
@@ -74,11 +81,44 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
7481
private @Nullable Spannable mSpanned;
7582
private @Nullable PreparedLayout mPreparedLayout;
7683

84+
@SuppressWarnings("this-escape")
7785
public ReactTextView(Context context) {
7886
super(context);
7987
initView();
8088
}
8189

90+
private static @Nullable Method resolveSetUseBoundsForWidth() {
91+
if (Build.VERSION.SDK_INT < AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM) {
92+
return null;
93+
}
94+
try {
95+
return TextView.class.getMethod("setUseBoundsForWidth", boolean.class);
96+
} catch (NoSuchMethodException e) {
97+
return null;
98+
}
99+
}
100+
101+
/**
102+
* Keeps this view's line breaking on the same basis as TextLayoutManager's measurement.
103+
*
104+
* <p>On Android 15+ a TextView in an app targeting API 35+ breaks lines on glyph bounds
105+
* (TextView#USE_BOUNDS_FOR_WIDTH), while TextLayoutManager measures with a StaticLayout that
106+
* breaks on glyph advances. For a font whose ink overhangs its advance, a line that fit at
107+
* measure time can wrap at draw time; the extra line lands outside the measured height and is
108+
* never seen. Drawing with advance-based breaking makes the painted line count match the measured
109+
* one.
110+
*/
111+
private void matchLineBreakingToMeasurement() {
112+
if (SET_USE_BOUNDS_FOR_WIDTH == null) {
113+
return;
114+
}
115+
try {
116+
SET_USE_BOUNDS_FOR_WIDTH.invoke(this, false);
117+
} catch (ReflectiveOperationException e) {
118+
FLog.w(ReactConstants.TAG, "Could not disable useBoundsForWidth on ReactTextView", e);
119+
}
120+
}
121+
82122
/**
83123
* Set all default values here as opposed to in the constructor or field defaults. It is important
84124
* that these properties are set during the constructor, but also on-demand whenever an existing
@@ -97,6 +137,7 @@ private void initView() {
97137
mOverflow = Overflow.VISIBLE;
98138
mSpanned = null;
99139
mPreparedLayout = null;
140+
matchLineBreakingToMeasurement();
100141
}
101142

102143
/* package */ void recycleView() {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import org.junit.Test
2727
import org.junit.runner.RunWith
2828
import org.robolectric.RobolectricTestRunner
2929
import org.robolectric.RuntimeEnvironment
30+
import org.robolectric.annotation.Config
3031

3132
@RunWith(RobolectricTestRunner::class)
3233
class ReactTextViewTest {
@@ -86,6 +87,28 @@ class ReactTextViewTest {
8687
assertThat(fontSizeWhenShort).isLessThan(fontSizeWhenTall)
8788
}
8889

90+
@Test
91+
@Config(sdk = [35])
92+
fun breaksLinesOnAdvancesLikeMeasurementOnApi35() {
93+
// TextLayoutManager measures with an advance-based StaticLayout; on API 35+ a TextView in an
94+
// app targeting 35+ defaults to bounds-based breaking, which can wrap one more line at draw
95+
// than at measure and clip it. The view must opt out so both agree.
96+
val view = ReactTextView(RuntimeEnvironment.getApplication())
97+
98+
assertThat(view.useBoundsForWidth).isFalse()
99+
}
100+
101+
@Test
102+
@Config(sdk = [35])
103+
fun recyclingRestoresAdvanceBasedLineBreaking() {
104+
val view = ReactTextView(RuntimeEnvironment.getApplication())
105+
view.useBoundsForWidth = true
106+
107+
view.recycleView()
108+
109+
assertThat(view.useBoundsForWidth).isFalse()
110+
}
111+
89112
private fun layoutAndDraw(view: TestReactTextView, width: Int, height: Int) {
90113
view.measure(
91114
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,51 @@ const examples = [
14501450
);
14511451
},
14521452
},
1453+
{
1454+
title: 'Android 15+ glyph overhang (last line must not disappear)',
1455+
name: 'androidGlyphOverhangLineBreaking',
1456+
render(): React.Node {
1457+
// Android's generic `cursive` family (Dancing Script) has glyphs whose
1458+
// ink extends past their advance. In a shrink-wrapping container the view
1459+
// is measured on advances; if the drawn TextView breaks lines on bounds
1460+
// instead, the trailing colored "f" wraps to a line outside the measured
1461+
// height and is never painted. Every row must show its green "f".
1462+
const rows = [
1463+
'Enjoy your',
1464+
'Enjoy your coffee',
1465+
'Enjoy your coffee, my',
1466+
'Enjoy your morning coffee, my friend',
1467+
];
1468+
return (
1469+
<View>
1470+
{rows.map(text => (
1471+
<View key={text} style={{flexDirection: 'row', gap: 8}}>
1472+
<View style={styles.overhangBubble}>
1473+
<Text
1474+
allowFontScaling={false}
1475+
style={{fontFamily: 'cursive', fontSize: 18, lineHeight: 27}}>
1476+
{text}
1477+
<Text style={{color: 'green'}}> f</Text>
1478+
</Text>
1479+
</View>
1480+
<View style={styles.overhangBubble}>
1481+
<Text
1482+
allowFontScaling={false}
1483+
style={{fontSize: 18, lineHeight: 27}}>
1484+
{text}
1485+
<Text style={{color: 'green'}}> f</Text>
1486+
</Text>
1487+
</View>
1488+
</View>
1489+
))}
1490+
<RNTesterText style={{marginTop: 8}}>
1491+
Left: cursive (overhangs). Right: default font (control). A missing
1492+
green f on the left is the bug.
1493+
</RNTesterText>
1494+
</View>
1495+
);
1496+
},
1497+
},
14531498
{
14541499
title: 'Text metrics legend',
14551500
name: 'textMetricLegend',
@@ -1854,6 +1899,15 @@ const examples = [
18541899
];
18551900

18561901
const styles = StyleSheet.create({
1902+
overhangBubble: {
1903+
alignSelf: 'flex-start',
1904+
maxWidth: '48%',
1905+
borderWidth: 1,
1906+
borderColor: '#ccc',
1907+
borderRadius: 8,
1908+
padding: 8,
1909+
marginBottom: 8,
1910+
},
18571911
backgroundColorText: {
18581912
left: 5,
18591913
backgroundColor: 'rgba(100, 100, 100, 0.3)',

0 commit comments

Comments
 (0)