Skip to content

Commit 631fd89

Browse files
hryhoriiK97meta-codesync[bot]
authored andcommitted
Skip redrawing iOS Text when only its position changes (#59155)
Summary: `RCTParagraphComponentView` draws its text in a child `RCTParagraphTextView`, positioned at the paragraph's content frame within the view's own bounds. **Current behavior.** The view requests a redraw of its text on every layout metrics update and on every state update. Both fire for a paragraph that has merely moved: - the mounting layer sends layout metrics whenever any metric differs, including the frame's origin alone; - a relayout gives the paragraph a new state even when its text is unchanged, because `AttributedString` equality includes each fragment's `parentShadowView`, whose layout metrics changed, so `ParagraphShadowNode::updateStateIfNeeded` sees a different string. So when the content above a paragraph changes height, every paragraph below it re-runs TextKit layout and glyph rasterization, although its bounds, padding and text are unchanged. A row inserted at the top of a non-virtualized list redraws every label under it. **With this change.** A redraw is requested only when something the drawing depends on changed: - in `updateLayoutMetrics:`, the view's frame size, its content frame (which carries the content insets the drawing origin is derived from), or the pixel scale factor; - in `updateState:`, the text content (`AttributedString::isContentEqual`, which compares strings and text attributes but not layout metrics) or the paragraph attributes. A string containing inline views keeps redrawing on every state, since attachment sizes come from the layout metrics the content comparison ignores. A recycled view always receives a new state before it is displayed, and the first state of a view always redraws, so neither comparison can skip a needed redraw. **Why.** Text drawing is the most expensive per-view work on the main thread during a reflow, and none of it is needed when the paragraph only moved. ## Changelog: [IOS] [FIXED] - Text no longer redraws when only its position changes, such as when content above it changes height Pull Request resolved: #59155 Test Plan: - `node scripts/clang-format.js --check` on the changed file passes. - RNTester built with `xcodebuild` in Release for a physical iPhone 15 Pro; the Text examples render as before, including after content above a paragraph changes height, after a paragraph's own size changes, and after a text change with unchanged metrics. - Screenshots of a 1000-label list and of a set of edge cases (right-aligned text with an inline view, centered multi-line text, empty text with an explicit size, truncation across nested colours, RTL with an inline view, clipped fixed height, two-line tail ellipsis) are byte-identical before and after. - **Measurement.** RNTester Playground with 1000 unique single-line and wrapping labels mounted in a `ScrollView`; a "reflow" toggles the height of a spacer above the labels from 0 to 40 pt, so every label moves and none changes size. Timed from Fabric's transaction telemetry, a run-loop observer after the CoreAnimation commit (mount end to display), a timer around `-[RCTParagraphTextView drawRect:]`, and the `touchend` Event Timing entry for the press (press to mount end). The two arms are the same binary with the new checks switched by an environment variable. iPhone 15 Pro, Release, medians of 5 presses: | reflow with 1000 labels mounted | before | this PR | change | |---|--:|--:|--:| | text redraws | 1001 | 1 | | | `drawRect` total, system font | 63.0 ms | 0.7 ms | −99% | | `drawRect` total, `fontFamily: 'Avenir'` | 51.1 ms | 0.1 ms | −100% | | mount end to display, system font | 115.6 ms | 1.7 ms | −99% | | mount end to display, Avenir | 106.7 ms | 1.0 ms | −99% | | press to mount end, system font | 180.1 ms | 67.4 ms | −63% | | press to mount end, Avenir | 176.4 ms | 68.0 ms | −61% | The one remaining redraw is the harness's own status label, whose text changes. The table above is from manual presses. The same sequence was then driven by [Argent](https://github.com/software-mansion/argent) on the device: with this change it again redrew 0 to 4 labels per reflow and measured 33 to 74 ms press-to-mount across ten reflows; without it, the reflows that redrew measured 150 to 200 ms press-to-mount, matching the manual figures. Mount and unmount figures agreed between the two methods within noise. Press-to-mount also drops because the main thread no longer spends the previous frame drawing before it can run the next mount. Mounting, unmounting and text changes are unchanged within noise: mounting the same 1000 labels measures 113 ms to display in both arms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: cipolleschi Differential Revision: D126286390 Pulled By: javache fbshipit-source-id: 33d6ee192672321f5f9990ec23b435e068ee97fc
1 parent ac766e6 commit 631fd89

1 file changed

Lines changed: 35 additions & 3 deletions

File tree

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ - (CGRect)drawingFrameForAttributedString:(const facebook::react::AttributedStri
3434

3535
@end
3636

37+
static BOOL RCTAttributedStringHasAttachments(const AttributedString &attributedString)
38+
{
39+
for (const auto &fragment : attributedString.getFragments()) {
40+
if (fragment.isAttachment()) {
41+
return YES;
42+
}
43+
}
44+
return NO;
45+
}
46+
3747
// ParagraphTextView is an auxiliary view we set as contentView so the drawing
3848
// can happen on top of the layers manipulated by RCTViewComponentView (the parent view)
3949
@interface RCTParagraphTextView : UIView
@@ -135,8 +145,23 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
135145

136146
- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState
137147
{
138-
_textView.state = std::static_pointer_cast<const ParagraphShadowNode::ConcreteState>(state);
139-
[_textView setNeedsDisplay];
148+
auto newState = std::static_pointer_cast<const ParagraphShadowNode::ConcreteState>(state);
149+
// A relayout yields a new state even for unchanged text (the attributed
150+
// string records fragment layout metrics); redraw only when content or
151+
// paragraph attributes changed. Attachments are sized from those metrics.
152+
BOOL needsDisplay = YES;
153+
if (newState && _textView.state) {
154+
const auto &newData = newState->getData();
155+
// Compare with the state the view last drew, which recycling can make differ from `oldState`.
156+
const auto &oldData = _textView.state->getData();
157+
needsDisplay = !newData.attributedString.isContentEqual(oldData.attributedString) ||
158+
newData.paragraphAttributes != oldData.paragraphAttributes ||
159+
RCTAttributedStringHasAttachments(newData.attributedString);
160+
}
161+
_textView.state = newState;
162+
if (needsDisplay) {
163+
[_textView setNeedsDisplay];
164+
}
140165

141166
// If the attributed string has changed, we need to notify the accessibility system that something changed,
142167
// otherwise it may hold on to stale values (this happens most often when an element is updated async)
@@ -161,11 +186,18 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
161186
- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
162187
oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics
163188
{
189+
// Drawing depends on size, content frame and scale, not on position.
190+
BOOL needsDisplay = _layoutMetrics.frame.size != layoutMetrics.frame.size ||
191+
_layoutMetrics.getContentFrame() != layoutMetrics.getContentFrame() ||
192+
_layoutMetrics.pointScaleFactor != layoutMetrics.pointScaleFactor;
193+
164194
// Using stored `_layoutMetrics` as `oldLayoutMetrics` here to avoid
165195
// re-applying individual sub-values which weren't changed.
166196
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:_layoutMetrics];
167197
_textView.layoutMetrics = _layoutMetrics;
168-
[_textView setNeedsDisplay];
198+
if (needsDisplay) {
199+
[_textView setNeedsDisplay];
200+
}
169201
}
170202

171203
- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask

0 commit comments

Comments
 (0)