Repository navigation
Commit b97bd12
Keep iOS text line breaks stable while LayoutAnimation resizes a paragraph (#59162)
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`
Pull Request resolved: #59162
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.
<img width="1100" height="398" alt="expand-ba-frame" src="https://github.com/user-attachments/assets/a0ef8fad-5c70-4c67-a9a2-0e2cd2ee156f" />
Width 50% → 100%. Before: the text re-wraps on every frame. After: it keeps its final line breaks and is uncovered from the right.
<img width="1100" height="398" alt="width-ba-frame" src="https://github.com/user-attachments/assets/6c04b9e9-d03d-4751-894e-12443065af37" />
Videos of both are at the end of the test plan.
Repro used for the issue and the videos:
```tsx
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):
https://github.com/user-attachments/assets/e33bd41a-b427-4e76-b936-219f03489685
Width animation, before (left) and after (right):
https://github.com/user-attachments/assets/439db994-47a0-4f61-8259-3d8bcbcff2d8
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed By: cipolleschi
Differential Revision: D126054764
Pulled By: javache
fbshipit-source-id: bc40ec40dcafc692c081c54736f8f40a08cb684c1 parent 02aef9b commit b97bd12
4 files changed
Lines changed: 33 additions & 2 deletions
File tree
- packages/react-native
- ReactCommon/react/renderer/components/text
- platform/cxx/react/renderer/components/text
- React/Fabric/Mounting/ComponentViews/Text
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
188 | 188 | | |
189 | 189 | | |
190 | 190 | | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
191 | 198 | | |
192 | 199 | | |
193 | 200 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
| 181 | + | |
181 | 182 | | |
182 | 183 | | |
183 | 184 | | |
| |||
187 | 188 | | |
188 | 189 | | |
189 | 190 | | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
190 | 205 | | |
191 | 206 | | |
192 | 207 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
113 | | - | |
114 | | - | |
| 113 | + | |
| 114 | + | |
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
13 | 14 | | |
14 | 15 | | |
15 | 16 | | |
| |||
39 | 40 | | |
40 | 41 | | |
41 | 42 | | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
42 | 51 | | |
43 | 52 | | |
44 | 53 | | |
0 commit comments