Skip to content

Draw ellipsized text without shaping its hidden text on Android - #59644

Open
hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:android-ellipsized-text-trim
Open

hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:android-ellipsized-text-trim

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android, the last line of an ellipsized layout keeps all the text after the ellipsis. StaticLayout puts the rest of the text on that line and TextLine replaces the hidden part with zero-width characters when drawing, so every time the layout is drawn, all of the hidden text is shaped again on the main thread, most of it missing minikin's layout cache. For a long text cut to a few lines, such as a message, comment or article preview with numberOfLines, that is most of the work of drawing it.

On a moto g57 power, a screen of 60 one-line texts with long hidden text spent 54% of its main thread constructing minikin::LayoutPieces under PreparedLayoutTextView.onDraw, and cutting the same strings to 80 characters in JS made the main thread 40% cheaper.

With this change, behind a new flag, enableEllipsizedTextTrimmingAndroid (off by default), when a prepared layout is ellipsized at the end, TextLayoutManager.createPreparedLayout wraps it in a new EllipsisTrimmedLayout: a delegating Layout, like MutableSpannableLayout, whose text stops right after the ellipsis character and whose line metrics all come from the original layout. Nothing is laid out again, and the visible lines are the same. Measurement gets the same line metrics, and onTextLayout lines are unchanged.

The original layout is kept when the hidden text matters for more than drawing:

  • the text is justified or hyphenated (justified lines and hyphens are drawn from the layout's justification mode and hyphen edits, which a delegating layout doesn't have);
  • the text has a right-to-left or directional formatting character anywhere, including inside bidi isolates (bidirectional lines are laid out in runs over the whole line, which can put a hidden run before visible ones, and on Android 15+ such text is drawn with bounds-based width, which a delegating layout doesn't have);
  • the text has letter spacing (before Android 15, letter spacing also spaces the hidden characters);
  • the hidden part has a link (exposed to accessibility), a replacement span such as an inline view (measured or drawn over its whole range), or a text effect or touchable span (which work on the range they were given).

Line widths (getLineMax, getLineWidth, getLineLeft, getLineRight) are computed from the trimmed text rather than delegated, since StaticLayout computes them by shaping the whole last line, hidden text included. Selectable text, which lays the text out again in a TextView, still gets the whole text (EllipsisTrimmedLayout.untrimmedText). PreparedLayout.sourceText, which accessibility and the view hierarchy read, is still the full text.

An alternative would be to keep PreparedLayout.layout as the original and add a separate layout used only for drawing and hit-testing, which would make some of these exclusions unnecessary but changes PreparedLayout; this PR keeps the existing shape.

Changelog:

[ANDROID] [ADDED] - enableEllipsizedTextTrimmingAndroid feature flag: draw ellipsized text without shaping its hidden text

Test Plan:

  • New EllipsisTrimmedLayoutTest, run on API 28, 30, 34 and 35:
    • one ellipsized line, three lines with bold and colored text crossing the ellipsis, text with hidden paragraphs, and centered and end-aligned text draw the same pixels as the original layout (and draw something), with the same lines, ellipsis, baselines, line widths and touch offsets on the last line, and text that ends right after the ellipsis; so does text with a stateful span when both layouts go through MutableSpannableLayout;
    • text that isn't ellipsized, hides nothing but the ellipsis, is ellipsized in the middle, is justified or hyphenated, is RTL, has RTL characters only in its hidden text or inside a bidi isolate, has letter spacing (left-aligned and centered), or has a link (also one crossing the ellipsis), an inline view, another replacement span, a text effect or a touchable span in its hidden text is kept, and a link in the visible text is kept;
    • prepared layouts are trimmed only with the feature flag on, and untrimmedText returns the whole text.
  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.views.text.*' passes (177 tests).
  • yarn format-check-kotlin reports no changes.
  • On an emulator (Android 16, Pixel_9_Pro AVD), with one RNTester release APK in which the flag is replaced by a system property, so both arms are the same binary:
    • RNTester's Text examples filtered to numberOfLines (one- and two-line ellipsized text, a large font, the onTextLayout examples with tail and head ellipsis): the screen is pixel-identical with the trimming off and on, including the onTextLayout lines it prints.
    • Playground screens where 60 rows change every 100 ms, and a feed of long posts cut to 3 lines that scrolls by itself; main and JS thread CPU time from /proc over 20 s, means of three alternating rounds:
Screen Main thread JS thread
60 ellipsized texts with long hidden text, restyled (layouts reused) 344 → 144 ticks (−58%) 401 → 416 (+4%)
The same with new text on every change (laid out again) 378 → 165 ticks (−56%) 1,026 → 1,034 (+1%)
Colored text without ellipsis (control) 211 → 197 (noise, ranges overlap) 389 → 360
Feed of long posts, scrolling 603 → 540 ticks (−10%) 1,093 → 1,098 (0%)

These rounds ran before the review changes above, which don't apply to these screens (no RTL text or letter spacing). With the final version, the numberOfLines screen is again pixel-identical, and two more alternating rounds gave −54% and −81% (restyled), −60% and −52% (new text), −7% and −16% (feed) on the main thread, with the control within noise.

  • On a device, with an earlier version of this change (no flag; trims the same texts in these screens, none of which has RTL text, letter spacing, replacement spans in hidden text or selectable text): two RNTester release APKs built from the same checkout, without and with the change, installed alternately on a moto g57 power (Android 16). A screen with static ellipsized text was pixel-identical between the two builds, and a nested <Text onPress> link on it fired with both.
Screen (60 rows changing every 100 ms, 20 s) Main thread JS thread
60 ellipsized texts with long hidden text, restyled 324 → 180 ticks (−44%) 334 → 348 (+4%)
The same with new text on every change 318 → 171 ticks (−46%) 610 → 678 (+11%, more updates committed)
Colored text without ellipsis (control) 210 → 214 (+2%) 334 → 336 (+1%)
Feed of 1,000 long posts cut to 3 lines, scrolling by itself, 30 s, four alternating rounds Without With Change
Main thread CPU (ticks) 604 555 −8%
Janky frames (dumpsys gfxinfo) 9.0 3.8 −58%
JS thread CPU (ticks) 1,255 1,253 0%

The emulator renders too few frames to measure jank, so the janky-frame numbers are from the device only.

🤖 Generated with Claude Code

The last line of an ellipsized layout keeps all the text after the
ellipsis, as zero-width characters, and Android shapes all of it every time
the layout is drawn. For a long text cut to a few lines, that is most of the
work of drawing it, on the main thread.

Behind the new enableEllipsizedTextTrimmingAndroid flag, when a prepared
layout is ellipsized at the end, wrap it in EllipsisTrimmedLayout, a
delegating Layout like MutableSpannableLayout whose text stops right after
the ellipsis character and whose line metrics come from the original layout,
so nothing is laid out again. The original layout is kept when the text is
justified or hyphenated, has right-to-left characters, is centered or
end-aligned with letter spacing in its hidden part, or when its hidden part
has links, replacement spans (such as inline views), text effects or
touchable spans. Selectable text, which lays the text out again in a
TextView, still gets the whole text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 29, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 29, 2026
- Keep the whole text when it has any right-to-left or directional
  formatting character. TextDirectionHeuristics.ANYRTL_LTR skips bidi
  isolates, but their runs are still reordered, so a hidden run could come
  before visible ones and stop the trimmed line from drawing them.
- Keep the whole text when it has letter spacing, whatever the alignment:
  before Android 15 the hidden characters are spaced too.
- Don't delegate getLineMax, getLineWidth, getLineLeft and getLineRight:
  StaticLayout computes them by shaping its whole last line, hidden text
  included, so this layout computes them from its own, shorter text.
- Check justification and hyphenation in createIfNeeded, look for all the
  hidden span types in one pass, and move the flag check to
  TextLayoutManager.layoutForDrawing and the selectable-text lookup to
  EllipsisTrimmedLayout.untrimmedText, so both can be tested.
- Test isolates, letter spacing, justification and hyphenation, stateful
  spans through MutableSpannableLayout, the feature flag and
  untrimmedText.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@meta-codesync

meta-codesync Bot commented Oct 30, 2026

Copy link
Copy Markdown

@andrewdacenko has imported this pull request. If you are a Meta employee, you can view this in D128621322.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant