Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.view.AccessibilityDelegateCompat;
Expand All @@ -46,10 +47,12 @@
import com.facebook.react.uimanager.style.BorderStyle;
import com.facebook.react.uimanager.style.LogicalEdge;
import com.facebook.react.uimanager.style.Overflow;
import com.facebook.react.util.AndroidVersion;
import com.facebook.react.views.text.internal.span.CanvasEffectSpan;
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan;
import com.facebook.react.views.text.internal.span.ReactTagSpan;
import com.facebook.yoga.YogaMeasureMode;
import java.lang.reflect.Method;

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

// TextView.setUseBoundsForWidth (API 35+). Looked up reflectively because some internal targets
// compile against an SDK older than 35 (see AndroidVersion).
private static final @Nullable Method SET_USE_BOUNDS_FOR_WIDTH = resolveSetUseBoundsForWidth();

private int mNumberOfLines;
private @Nullable TextUtils.TruncateAt mEllipsizeLocation;
private boolean mAdjustsFontSizeToFit;
Expand All @@ -76,9 +83,42 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie

public ReactTextView(Context context) {
super(context);
matchLineBreakingToMeasurement();
initView();
}

private static @Nullable Method resolveSetUseBoundsForWidth() {
if (Build.VERSION.SDK_INT < AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM) {
return null;
}
try {
return TextView.class.getMethod("setUseBoundsForWidth", boolean.class);
} catch (NoSuchMethodException e) {
return null;
}
}

/**
* Keeps this view's line breaking on the same basis as TextLayoutManager's measurement.
*
* <p>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. For a font whose ink overhangs its advance, a line that fit at
* measure time can wrap at draw time; the extra line lands outside the measured height and is
* never seen. Drawing with advance-based breaking makes the painted line count match the
* measured one.
*/
private void matchLineBreakingToMeasurement() {
if (SET_USE_BOUNDS_FOR_WIDTH == null) {
return;
}
try {
SET_USE_BOUNDS_FOR_WIDTH.invoke(this, false);
} catch (ReflectiveOperationException e) {
FLog.w(ReactConstants.TAG, "Could not disable useBoundsForWidth on ReactTextView", e);
}
}

/**
* Set all default values here as opposed to in the constructor or field defaults. It is important
* that these properties are set during the constructor, but also on-demand whenever an existing
Expand Down Expand Up @@ -151,6 +191,7 @@ private void initView() {
}

setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE);
matchLineBreakingToMeasurement();
updateView(); // call after changing ellipsizeLocation in particular
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
class ReactTextViewTest {
Expand Down Expand Up @@ -86,6 +87,28 @@ class ReactTextViewTest {
assertThat(fontSizeWhenShort).isLessThan(fontSizeWhenTall)
}

@Test
@Config(sdk = [35])
fun breaksLinesOnAdvancesLikeMeasurementOnApi35() {
// TextLayoutManager measures with an advance-based StaticLayout; on API 35+ a TextView in an
// app targeting 35+ defaults to bounds-based breaking, which can wrap one more line at draw
// than at measure and clip it. The view must opt out so both agree.
val view = ReactTextView(RuntimeEnvironment.getApplication())

assertThat(view.useBoundsForWidth).isFalse()
}

@Test
@Config(sdk = [35])
fun recyclingRestoresAdvanceBasedLineBreaking() {
val view = ReactTextView(RuntimeEnvironment.getApplication())
view.useBoundsForWidth = true

view.recycleView()

assertThat(view.useBoundsForWidth).isFalse()
}

private fun layoutAndDraw(view: TestReactTextView, width: Int, height: Int) {
view.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
Expand Down
54 changes: 54 additions & 0 deletions packages/rn-tester/js/examples/Text/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,51 @@ const examples = [
);
},
},
{
title: 'Android 15+ glyph overhang (last line must not disappear)',
name: 'androidGlyphOverhangLineBreaking',
render(): React.Node {
// Android's generic `cursive` family (Dancing Script) has glyphs whose
// ink extends past their advance. In a shrink-wrapping container the view
// is measured on advances; if the drawn TextView breaks lines on bounds
// instead, the trailing colored "f" wraps to a line outside the measured
// height and is never painted. Every row must show its green "f".
const rows = [
'Enjoy your',
'Enjoy your coffee',
'Enjoy your coffee, my',
'Enjoy your morning coffee, my friend',
];
return (
<View>
{rows.map(text => (
<View key={text} style={{flexDirection: 'row', gap: 8}}>
<View style={styles.overhangBubble}>
<Text
allowFontScaling={false}
style={{fontFamily: 'cursive', fontSize: 18, lineHeight: 27}}>
{text}
<Text style={{color: 'green'}}> f</Text>
</Text>
</View>
<View style={styles.overhangBubble}>
<Text
allowFontScaling={false}
style={{fontSize: 18, lineHeight: 27}}>
{text}
<Text style={{color: 'green'}}> f</Text>
</Text>
</View>
</View>
))}
<RNTesterText style={{marginTop: 8}}>
Left: cursive (overhangs). Right: default font (control). A missing
green f on the left is the bug.
</RNTesterText>
</View>
);
},
},
{
title: 'Text metrics legend',
name: 'textMetricLegend',
Expand Down Expand Up @@ -1846,6 +1891,15 @@ const examples = [
];

const styles = StyleSheet.create({
overhangBubble: {
alignSelf: 'flex-start',
maxWidth: '48%',
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 8,
marginBottom: 8,
},
backgroundColorText: {
left: 5,
backgroundColor: 'rgba(100, 100, 100, 0.3)',
Expand Down
Loading