Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -713,6 +718,9 @@ - (void)prepareForRecycle
_prevFirstVisibleFrame = CGRectZero;
_firstVisibleView = nil;
_firstVisibleViewTag = 0;
_prevNextVisibleFrame = CGRectZero;
_nextVisibleView = nil;
_nextVisibleViewTag = 0;
_virtualViewContainerState = nil;
}

Expand Down Expand Up @@ -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];
Expand All @@ -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;
}
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
*/

#import <React/RCTScrollViewComponentView.h>
#import <React/RCTViewComponentView.h>
#import <XCTest/XCTest.h>
#import <react/renderer/components/scrollview/ScrollViewProps.h>
#import <react/renderer/components/scrollview/ScrollViewShadowNode.h>

using facebook::react::Props;
using facebook::react::ScrollViewMaintainVisibleContentPosition;
using facebook::react::ScrollViewProps;
using facebook::react::ScrollViewShadowNode;

Expand All @@ -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<ScrollViewProps>();
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
Expand Down Expand Up @@ -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
Loading