From 48908035377b3c4646f327e6d3c5ba26356e551d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Oct 2026 17:32:34 +0300 Subject: [PATCH] Fix iOS maintainVisibleContentPosition when the anchor straddling the top edge is resized Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ScrollView/RCTScrollViewComponentView.mm | 41 ++++++++++ .../RCTScrollViewComponentViewTests.mm | 74 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index ffd10bc6f236..3d57666914ba 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -112,6 +112,11 @@ @implementation RCTScrollViewComponentView { CGRect _prevFirstVisibleFrame; __weak UIView *_firstVisibleView; NSInteger _firstVisibleViewTag; + // The view after _firstVisibleView, recorded only when _firstVisibleView straddles the leading + // edge of the viewport and this one starts inside it. + CGRect _prevNextVisibleFrame; + __weak UIView *_nextVisibleView; + NSInteger _nextVisibleViewTag; CGFloat _endDraggingSensitivityMultiplier; @@ -713,6 +718,9 @@ - (void)prepareForRecycle _prevFirstVisibleFrame = CGRectZero; _firstVisibleView = nil; _firstVisibleViewTag = 0; + _prevNextVisibleFrame = CGRectZero; + _nextVisibleView = nil; + _nextVisibleViewTag = 0; _virtualViewContainerState = nil; } @@ -1069,6 +1077,9 @@ - (void)_prepareForMaintainVisibleScrollPosition BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; int minIdx = props.maintainVisibleContentPosition.value().minIndexForVisible; + _prevNextVisibleFrame = CGRectZero; + _nextVisibleView = nil; + _nextVisibleViewTag = 0; for (NSUInteger ii = minIdx; ii < _contentView.subviews.count; ++ii) { // Find the first view that is partially or fully visible. UIView *subview = _contentView.subviews[ii]; @@ -1082,6 +1093,22 @@ - (void)_prepareForMaintainVisibleScrollPosition _prevFirstVisibleFrame = subview.frame; _firstVisibleView = subview; _firstVisibleViewTag = subview.tag; + // If the anchor straddles the leading edge, also record the view after it, as long as that + // one starts inside the viewport. See _adjustForMaintainVisibleContentPosition. + if (ii + 1 < _contentView.subviews.count) { + UIView *nextView = _contentView.subviews[ii + 1]; + CGFloat offset = horizontal ? _scrollView.contentOffset.x : _scrollView.contentOffset.y; + CGFloat length = horizontal ? _scrollView.bounds.size.width : _scrollView.bounds.size.height; + CGFloat start = horizontal ? CGRectGetMinX(subview.frame) : CGRectGetMinY(subview.frame); + CGFloat nextStart = horizontal ? CGRectGetMinX(nextView.frame) : CGRectGetMinY(nextView.frame); + // Half a point of slack, as for the delta below: after a correction, the next view's origin + // and the offset can differ by a rounding error in either direction. + if (start < offset && nextStart > offset - 0.5 && nextStart < offset + length) { + _prevNextVisibleFrame = nextView.frame; + _nextVisibleView = nextView; + _nextVisibleViewTag = nextView.tag; + } + } break; } } @@ -1094,6 +1121,20 @@ - (void)_adjustForMaintainVisibleContentPosition return; } + // The anchor is the first view that is partially or fully visible. If it straddles the leading + // edge and changed size in this transaction, its origin cannot say how far the content after it + // moved: a VirtualizedList spacer standing in for unmounted cells keeps its origin when it is + // re-estimated, while every cell after it moves. The same goes for a straddling anchor that was + // unmounted or recycled. In those cases anchor on the view after it instead. An anchor that only + // moved, or did not change at all, is still measured itself. + if (_nextVisibleView != nil && _nextVisibleView.tag == _nextVisibleViewTag && + (_firstVisibleView == nil || _firstVisibleView.tag != _firstVisibleViewTag || + !CGSizeEqualToSize(_firstVisibleView.frame.size, _prevFirstVisibleFrame.size))) { + _prevFirstVisibleFrame = _prevNextVisibleFrame; + _firstVisibleView = _nextVisibleView; + _firstVisibleViewTag = _nextVisibleViewTag; + } + // Abort if no first visible view (e.g., list was empty during mount) if (!_firstVisibleView) { return; diff --git a/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm index 82b350755668..28dcc20394b0 100644 --- a/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm @@ -6,11 +6,13 @@ */ #import +#import #import #import #import using facebook::react::Props; +using facebook::react::ScrollViewMaintainVisibleContentPosition; using facebook::react::ScrollViewProps; using facebook::react::ScrollViewShadowNode; @@ -23,8 +25,35 @@ return props; } +static UIView *makeContentSubview(NSInteger tag, CGFloat y, CGFloat height) +{ + UIView *subview = [[UIView alloc] initWithFrame:CGRectMake(0, y, 100, height)]; + subview.tag = tag; + return subview; +} + +// A 100pt tall scroll view with maintainVisibleContentPosition, scrolled to y=450, whose first subview (0-500) +// straddles the top edge and whose second subview (500-600) starts inside the viewport. +static RCTScrollViewComponentView *makeScrollViewWithStraddlingAnchor(UIView *straddling, UIView *next) +{ + RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + auto props = std::make_shared(); + props->maintainVisibleContentPosition = ScrollViewMaintainVisibleContentPosition{}; + [view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()]; + + RCTViewComponentView *contentView = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 1000)]; + [view mountChildComponentView:contentView index:0]; + [contentView addSubview:straddling]; + [contentView addSubview:next]; + view.scrollView.contentSize = CGSizeMake(100, 1000); + view.scrollView.contentOffset = CGPointMake(0, 450); + return view; +} + @interface RCTScrollViewComponentView (Tests) - (void)_keyboardWillChangeFrame:(NSNotification *)notification; +- (void)_prepareForMaintainVisibleScrollPosition; +- (void)_adjustForMaintainVisibleContentPosition; @end @interface RCTScrollViewComponentViewTests : XCTestCase @@ -61,6 +90,51 @@ - (void)testAutomaticallyAdjustKeyboardInsetsAcrossRecycling XCTAssertEqual(view.scrollView.contentInset.bottom, 50); } +- (void)testMaintainVisibleContentPositionWhenStraddlingAnchorIsResized +{ + // Like a VirtualizedList spacer re-estimated after a prepend: it keeps its origin while the row after it moves. + UIView *spacer = makeContentSubview(1, 0, 500); + UIView *row = makeContentSubview(2, 500, 100); + RCTScrollViewComponentView *view = makeScrollViewWithStraddlingAnchor(spacer, row); + + [view _prepareForMaintainVisibleScrollPosition]; + spacer.frame = CGRectMake(0, 0, 100, 200); + row.frame = CGRectMake(0, 200, 100, 100); + [view _adjustForMaintainVisibleContentPosition]; + + XCTAssertEqual(view.scrollView.contentOffset.y, 150); +} + +- (void)testMaintainVisibleContentPositionWhenStraddlingAnchorIsRemoved +{ + UIView *straddling = makeContentSubview(1, 0, 500); + UIView *row = makeContentSubview(2, 500, 100); + RCTScrollViewComponentView *view = makeScrollViewWithStraddlingAnchor(straddling, row); + + [view _prepareForMaintainVisibleScrollPosition]; + // Unmounting enqueues the view for recycling, which resets its tag. + [straddling removeFromSuperview]; + straddling.tag = 0; + row.frame = CGRectMake(0, 200, 100, 100); + [view _adjustForMaintainVisibleContentPosition]; + + XCTAssertEqual(view.scrollView.contentOffset.y, 150); +} + +- (void)testMaintainVisibleContentPositionWhenContentIsInsertedBelowStraddlingAnchor +{ + // Rows inserted below a view that straddles the top edge. It keeps its size, so it stays the anchor (#43203). + UIView *straddling = makeContentSubview(1, 0, 500); + UIView *row = makeContentSubview(2, 500, 100); + RCTScrollViewComponentView *view = makeScrollViewWithStraddlingAnchor(straddling, row); + + [view _prepareForMaintainVisibleScrollPosition]; + row.frame = CGRectMake(0, 600, 100, 100); + [view _adjustForMaintainVisibleContentPosition]; + + XCTAssertEqual(view.scrollView.contentOffset.y, 450); +} + @end #endif