Skip to content

Keep iOS text line breaks stable while LayoutAnimation resizes a paragraph - #59162

Open
tomekzaw wants to merge 1 commit into
react:mainfrom
tomekzaw:ios-text-layout-animation-line-breaks
Open

tomekzaw wants to merge 1 commit into
react:mainfrom
tomekzaw:ios-text-layout-animation-line-breaks

Conversation

@tomekzaw

@tomekzaw tomekzaw commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Fixes #59161.

When LayoutAnimation changes the height of a <Text> on iOS (for example a "read more" toggle from numberOfLines={2} to no limit), words jump between lines during the animation instead of the text unrolling with its final line breaks.

Current behavior. LayoutAnimation interpolates the paragraph's LayoutMetrics on every frame. RCTParagraphComponentView uses that intermediate content frame as its drawing frame, so drawRect: lays the text out again in a container of the in-between size. With no line limit, RCTTextLayoutManager sets the container's lineBreakMode to NSLineBreakByClipping, which applies to the last line that fits: that line isn't word-wrapped, it takes the rest of the text and clips it at the right edge. So on every frame the last visible line shows words that belong to the next line, cut off at the edge, and they jump down once the next line is revealed. Lines also appear only once they fit completely. Width animations have the same problem sideways: the text re-wraps on every frame.

The old architecture didn't have this problem. RCTTextShadowView laid the text out once, at the final frame size, and the animation only changed the view's frame. Android (with enableLayoutAnimationsOnAndroid) also unrolls height changes correctly.

With this change. The cxx ParagraphState (used outside of Android) stores the size of the content frame the paragraph was laid out with. ParagraphShadowNode sets it in layout(), where the layout metrics are final. RCTParagraphComponentView lays the text out in that size and lets the text view clip it to its current bounds. LayoutAnimation already builds every intermediate view from the final one and never interpolates state ("keep the mounting layer consistent with the final state"), so this needs no animation-specific code.

When nothing is animating, the stored size equals the current content frame size: layout metrics are only set together with a layout() call (YogaLayoutableShadowNode::layout, layoutTree). Static rendering is therefore unchanged, including text whose frame is shorter than its content.

Android keeps its current state handling (#ifdef ANDROID): its views don't use the size, and a state update there goes through JNI and resets the text.

I also tried the one-line alternative of using NSLineBreakByWordWrapping for unlimited lines. It stops the words from jumping, but lines still pop in only once they fit, and it changes the static rendering of text whose frame is shorter than its content.

Cost. A paragraph whose size changes without a content change now gets a new ParagraphState on iOS. Such a size change already re-measures and redraws the text, and on current main the attributed string comparison in updateStateIfNeeded fails after layout anyway (see #59063), so iOS already creates a new state on most layouts.

Changelog:

[IOS] [FIXED] - Fix words jumping between lines while LayoutAnimation resizes a Text

Test Plan:

Before (left) and after (right), RNTester Playground, iPhone Air simulator (iOS 27.0), 3-second linear LayoutAnimation.

Expand from numberOfLines={2} to no limit. Before: the last visible line is clipped at the edge and there is a gap below it. After: the final lines are revealed, and the partially visible line is clipped mid-line.

expand-ba-frame

Width 50% → 100%. Before: the text re-wraps on every frame. After: it keeps its final line breaks and is uncovered from the right.

width-ba-frame

Videos of both are at the end of the test plan.

Repro used for the issue and the videos:

const TEXT = Array.from({length: 40}, (_, i) => `word${i + 1}`).join(' ');

function Playground() {
  const [expanded, setExpanded] = useState(false);
  return (
    <View style={{padding: 20}}>
      <Pressable
        onPress={() => {
          LayoutAnimation.configureNext({
            duration: 3000,
            update: {type: LayoutAnimation.Types.linear},
          });
          setExpanded(value => !value);
        }}>
        <Text>{expanded ? 'Collapse' : 'Expand'}</Text>
      </Pressable>
      <Text style={{fontSize: 22}} numberOfLines={expanded ? undefined : 2}>
        {TEXT}
      </Text>
    </View>
  );
}
  • Static rendering. Full-resolution screenshots of a screen with five static cases were pixel-identical before and after; only the status-bar clock differed. The cases were height shorter than the text, numberOfLines={2} with ellipsis, padding + border + textAlign: 'center' + lineHeight, nested styles with an inline view, and flexShrink in a fixed-height container.
  • Fantom. yarn fantom packages/react-native/Libraries/Text/__tests__/Text-itest.js packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js gives 157 passed. Fantom builds the same non-Android ParagraphState path as iOS. To build the tester locally I had to add -Wno-error=deprecated-literal-operator, because the bundled nlohmann/json.hpp fails with Xcode 27's clang; that's unrelated to this change.
  • Cost. I used a temporary Fantom benchmark, not part of this PR, that resizes a container of 100 or 1000 texts without changing their content. It ran in dev mode, since optimized mode isn't supported in OSS, 5 interleaved runs per variant. Medians were 4.07 ms → 3.44 ms for 100 texts and 50.9 ms → 42.2 ms for 1000 texts. Run-to-run noise (about ±30%) was larger than any difference.
  • Android. ./gradlew :packages:rn-tester:android:app:installDebug -PreactNativeArchitectures=arm64-v8a builds. With enableLayoutAnimationsOnAndroid forced on locally, the expand animation unrolls correctly before and after this change. I noticed separately that Android re-wraps text during width animations, because SurfaceMountingManager.updateLayout calls measure() on every frame and TextView rebuilds its layout there; the old architecture doesn't. This PR doesn't address that.
  • Formatting. clang-format 21.1.2 (--dry-run -Werror) passes on the changed files.

Expand, before (left) and after (right):

ios-expand-before-after.mp4

Width animation, before (left) and after (right):

ios-width-before-after.mp4

🤖 Generated with Claude Code

…graph

LayoutAnimation interpolates a paragraph's frame on every frame, and
RCTParagraphComponentView laid out its text in that intermediate size. With
an unlimited number of lines the text container clips its last line instead
of wrapping it, so the last visible line showed words from the next line,
cut off at the edge, and they jumped down once the next line was revealed.

ParagraphState (outside of Android) now stores the size of the content frame
the paragraph was laid out with, and the view lays out text in that size and
clips it to its current bounds, like the old architecture did. LayoutAnimation
keeps the final state while it interpolates the frame, so this needs no
animation-specific code.

Fixes react#59161

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 17, 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 17, 2026

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.

[iOS][Fabric] Words jump between lines while a <Text> grows with LayoutAnimation

1 participant