Skip to content

Commit b97bd12

Browse files
tomekzawmeta-codesync[bot]
authored andcommitted
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: bc40ec40dcafc692c081c54736f8f40a08cb684c
1 parent 02aef9b commit b97bd12

4 files changed

Lines changed: 33 additions & 2 deletions

File tree

‎packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ - (void)_updateTextViewFrame
188188
CGRect textViewFrame = self.bounds;
189189
CGRect drawingFrame = RCTCGRectFromRect(_layoutMetrics.getContentFrame());
190190

191+
// Lay out the text in the size the paragraph has in the shadow tree rather than in the current frame, which
192+
// LayoutAnimation interpolates. Otherwise, line breaks change on every frame and words jump between lines.
193+
// The text view clips the text to the current bounds.
194+
if (_textView.state) {
195+
drawingFrame.size = RCTCGSizeFromSize(_textView.state->getData().layoutSize);
196+
}
197+
191198
if (ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment() && _textView.state &&
192199
drawingFrame.size.height > 0) {
193200
const auto &stateData = _textView.state->getData();

‎packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) {
178178
auto& state = getStateData();
179179

180180
react_native_assert(textLayoutManager_);
181+
#ifdef ANDROID
181182
if (state.attributedString == content.attributedString) {
182183
return;
183184
}
@@ -187,6 +188,20 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) {
187188
content.attributedString,
188189
content.paragraphAttributes,
189190
textLayoutManager_});
191+
#else
192+
auto layoutSize = getLayoutMetrics().getContentFrame().size;
193+
if (state.attributedString == content.attributedString &&
194+
state.layoutSize == layoutSize) {
195+
return;
196+
}
197+
198+
setStateData(
199+
ParagraphState{
200+
.attributedString = content.attributedString,
201+
.paragraphAttributes = content.paragraphAttributes,
202+
.layoutManager = textLayoutManager_,
203+
.layoutSize = layoutSize});
204+
#endif
190205
}
191206

192207
MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout() {

‎packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ class ParagraphShadowNode
110110
void updateStateIfNeeded(const Content &content, const MeasuredPreparedTextLayout &layout);
111111

112112
/*
113-
* Creates a `State` object (with `AttributedText` and
114-
* `TextLayoutManager`) if needed.
113+
* Creates a `State` object (with `AttributedText`, `TextLayoutManager` and,
114+
* outside of Android, the size of the content frame) if needed.
115115
*/
116116
void updateStateIfNeeded(const Content &content);
117117

‎packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/ParagraphState.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <react/cxxstableapi/FrameworksGuard.h>
1111

1212
#include <React/Debug.h>
13+
#include <React/Graphics.h>
1314
#include <react/renderer/attributedstring/AttributedString.h>
1415
#include <react/renderer/attributedstring/ParagraphAttributes.h>
1516
#include <react/renderer/textlayoutmanager/TextLayoutManager.h>
@@ -39,6 +40,14 @@ class ParagraphState final {
3940
* `AttributedString`.
4041
*/
4142
std::weak_ptr<const TextLayoutManager> layoutManager;
43+
44+
/*
45+
* Size of the content frame that the paragraph was laid out with.
46+
* The mounted view can temporarily have a different size (e.g. while
47+
* LayoutAnimation interpolates its frame), so it lays out text in this size
48+
* to keep line breaks stable.
49+
*/
50+
Size layoutSize;
4251
};
4352

4453
} // namespace facebook::react

0 commit comments

Comments
 (0)