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 @@ -84,6 +84,21 @@ static UIScrollViewIndicatorStyle RCTUIScrollViewIndicatorStyleFromProps(const S
userInfo:userInfo];
}

// Clamps a maintainVisibleContentPosition target offset to the scrollable range so that
// restoring a pre-mount offset after content shrinks does not overscroll.
static CGPoint RCTClampMaintainVisibleContentOffset(UIScrollView *scrollView, CGPoint offset)
{
UIEdgeInsets insets = scrollView.adjustedContentInset;

CGFloat minX = -insets.left;
CGFloat maxX = fmax(minX, scrollView.contentSize.width - scrollView.bounds.size.width + insets.right);

CGFloat minY = -insets.top;
CGFloat maxY = fmax(minY, scrollView.contentSize.height - scrollView.bounds.size.height + insets.bottom);

return CGPointMake(fmin(fmax(offset.x, minX), maxX), fmin(fmax(offset.y, minY), maxY));
}

@interface RCTScrollViewComponentView () <
UIScrollViewDelegate,
RCTScrollViewProtocol,
Expand All @@ -110,6 +125,7 @@ @implementation RCTScrollViewComponentView {
__weak UIView *_contentView;

CGRect _prevFirstVisibleFrame;
CGPoint _prevContentOffset;
__weak UIView *_firstVisibleView;
NSInteger _firstVisibleViewTag;

Expand Down Expand Up @@ -708,6 +724,7 @@ - (void)prepareForRecycle
self.frame = oldFrame;
_contentView = nil;
_prevFirstVisibleFrame = CGRectZero;
_prevContentOffset = CGPointZero;
_firstVisibleView = nil;
_firstVisibleViewTag = 0;
_virtualViewContainerState = nil;
Expand Down Expand Up @@ -1077,6 +1094,8 @@ - (void)_prepareForMaintainVisibleScrollPosition
}
if (hasNewView || ii == _contentView.subviews.count - 1) {
_prevFirstVisibleFrame = subview.frame;
// A smaller content size can clamp the live offset before the adjustment.
_prevContentOffset = _scrollView.contentOffset;
_firstVisibleView = subview;
_firstVisibleViewTag = subview.tag;
break;
Expand Down Expand Up @@ -1120,9 +1139,10 @@ - (void)_adjustForMaintainVisibleContentPosition
if (horizontal) {
CGFloat deltaX = _firstVisibleView.frame.origin.x - _prevFirstVisibleFrame.origin.x;
if (ABS(deltaX) > 0.5) {
CGFloat x = _scrollView.contentOffset.x;
CGFloat x = _prevContentOffset.x;
[self _forceDispatchNextScrollEvent];
_scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x + deltaX, _scrollView.contentOffset.y);
CGPoint targetOffset = CGPointMake(x + deltaX, _scrollView.contentOffset.y);
_scrollView.contentOffset = RCTClampMaintainVisibleContentOffset(_scrollView, targetOffset);
if (autoscrollThreshold) {
// If the offset WAS within the threshold of the start, animate to the start.
if (x <= autoscrollThreshold.value()) {
Expand All @@ -1134,9 +1154,10 @@ - (void)_adjustForMaintainVisibleContentPosition
CGRect newFrame = _firstVisibleView.frame;
CGFloat deltaY = newFrame.origin.y - _prevFirstVisibleFrame.origin.y;
if (ABS(deltaY) > 0.5) {
CGFloat y = _scrollView.contentOffset.y;
CGFloat y = _prevContentOffset.y;
[self _forceDispatchNextScrollEvent];
_scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x, _scrollView.contentOffset.y + deltaY);
CGPoint targetOffset = CGPointMake(_scrollView.contentOffset.x, y + deltaY);
_scrollView.contentOffset = RCTClampMaintainVisibleContentOffset(_scrollView, targetOffset);
if (autoscrollThreshold) {
// If the offset WAS within the threshold of the start, animate to the start.
if (y <= autoscrollThreshold.value()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,117 @@

@interface RCTScrollViewComponentView (Tests)
- (void)_keyboardWillChangeFrame:(NSNotification *)notification;
- (void)_prepareForMaintainVisibleScrollPosition;
- (void)_adjustForMaintainVisibleContentPosition;
@end

@interface RCTScrollViewComponentViewTests : XCTestCase
@end

@implementation RCTScrollViewComponentViewTests

- (void)testMaintainVisibleContentPositionAfterVerticalShrink
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
auto props = std::make_shared<ScrollViewProps>();
props->maintainVisibleContentPosition = facebook::react::ScrollViewMaintainVisibleContentPosition{};
[view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()];

RCTViewComponentView *contentView = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 1000)];
[view mountChildComponentView:contentView index:0];
UIView *anchor = [[UIView alloc] initWithFrame:CGRectMake(0, 800, 100, 40)];
anchor.tag = 42;
[contentView addSubview:anchor];

view.scrollView.contentSize = CGSizeMake(100, 1000);
view.scrollView.contentOffset = CGPointMake(0, 800);
[view _prepareForMaintainVisibleScrollPosition];

anchor.frame = CGRectMake(0, 300, 100, 40);
view.scrollView.contentSize = CGSizeMake(100, 400);
view.scrollView.contentOffset = CGPointZero;
[view _adjustForMaintainVisibleContentPosition];

XCTAssertEqualWithAccuracy(view.scrollView.contentOffset.y, 300, 0.5);
}

- (void)testMaintainVisibleContentPositionAfterHorizontalShrink
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
auto props = std::make_shared<ScrollViewProps>();
props->maintainVisibleContentPosition = facebook::react::ScrollViewMaintainVisibleContentPosition{};
[view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()];

RCTViewComponentView *contentView = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 1000, 100)];
[view mountChildComponentView:contentView index:0];
UIView *anchor = [[UIView alloc] initWithFrame:CGRectMake(800, 0, 40, 100)];
anchor.tag = 42;
[contentView addSubview:anchor];

view.scrollView.contentSize = CGSizeMake(1000, 100);
view.scrollView.contentOffset = CGPointMake(800, 0);
[view _prepareForMaintainVisibleScrollPosition];

anchor.frame = CGRectMake(300, 0, 40, 100);
view.scrollView.contentSize = CGSizeMake(400, 100);
view.scrollView.contentOffset = CGPointZero;
[view _adjustForMaintainVisibleContentPosition];

XCTAssertEqualWithAccuracy(view.scrollView.contentOffset.x, 300, 0.5);
}

- (void)testMaintainVisibleContentPositionClampsVerticalOffsetAfterShrink
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
auto props = std::make_shared<ScrollViewProps>();
props->maintainVisibleContentPosition = facebook::react::ScrollViewMaintainVisibleContentPosition{};
[view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()];

RCTViewComponentView *contentView = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 1000)];
[view mountChildComponentView:contentView index:0];
UIView *anchor = [[UIView alloc] initWithFrame:CGRectMake(0, 800, 100, 40)];
anchor.tag = 42;
[contentView addSubview:anchor];

view.scrollView.contentSize = CGSizeMake(100, 1000);
view.scrollView.contentOffset = CGPointMake(0, 800);
[view _prepareForMaintainVisibleScrollPosition];

// The unclamped target (350) is past the max offset of 400 - 100 = 300.
anchor.frame = CGRectMake(0, 350, 100, 40);
view.scrollView.contentSize = CGSizeMake(100, 400);
view.scrollView.contentOffset = CGPointZero;
[view _adjustForMaintainVisibleContentPosition];

XCTAssertEqualWithAccuracy(view.scrollView.contentOffset.y, 300, 0.5);
}

- (void)testMaintainVisibleContentPositionClampsHorizontalOffsetAfterShrink
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
auto props = std::make_shared<ScrollViewProps>();
props->maintainVisibleContentPosition = facebook::react::ScrollViewMaintainVisibleContentPosition{};
[view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()];

RCTViewComponentView *contentView = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 1000, 100)];
[view mountChildComponentView:contentView index:0];
UIView *anchor = [[UIView alloc] initWithFrame:CGRectMake(800, 0, 40, 100)];
anchor.tag = 42;
[contentView addSubview:anchor];

view.scrollView.contentSize = CGSizeMake(1000, 100);
view.scrollView.contentOffset = CGPointMake(800, 0);
[view _prepareForMaintainVisibleScrollPosition];

// The unclamped target (350) is past the max offset of 400 - 100 = 300.
anchor.frame = CGRectMake(350, 0, 40, 100);
view.scrollView.contentSize = CGSizeMake(400, 100);
view.scrollView.contentOffset = CGPointZero;
[view _adjustForMaintainVisibleContentPosition];

XCTAssertEqualWithAccuracy(view.scrollView.contentOffset.x, 300, 0.5);
}

- (void)testAutomaticallyAdjustKeyboardInsetsAcrossRecycling
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
var config: Config? = null
private var firstVisibleViewRef: WeakReference<View>? = null
private var prevFirstVisibleFrame: Rect? = null
private var scrollYBeforeLayoutClamp: Int? = null
private var isListening = false

private val contentView: ReactViewGroup?
Expand Down Expand Up @@ -87,6 +88,17 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
uIManager.removeUIManagerEventListener(this)
}

/**
* Called by ReactScrollView just before it clamps scrollY because the content got smaller. This
* happens during mounting, after any queued view commands have run, so it is the offset the
* anchor delta should be applied to in didMountItems.
*/
fun onWillClampScrollY(scrollY: Int) {
if (scrollYBeforeLayoutClamp == null) {
scrollYBeforeLayoutClamp = scrollY
}
}

private fun updateScrollPositionInternal() {
val config = config ?: return
val firstVisibleViewRef = firstVisibleViewRef ?: return
Expand All @@ -110,7 +122,7 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
} else {
val deltaY = newFrame.top - prevFirstVisibleFrame.top
if (deltaY != 0) {
val scrollY = scrollView.scrollY
val scrollY = scrollYBeforeLayoutClamp ?: scrollView.scrollY
scrollView.scrollToPreservingMomentum(scrollView.scrollX, scrollY + deltaY)
this.prevFirstVisibleFrame = newFrame
if (config.autoScrollToTopThreshold != null && scrollY <= config.autoScrollToTopThreshold) {
Expand Down Expand Up @@ -149,11 +161,13 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
}

override fun willMountItems(uiManager: UIManager) {
scrollYBeforeLayoutClamp = null
computeTargetView()
}

override fun didMountItems(uiManager: UIManager) {
updateScrollPositionInternal()
scrollYBeforeLayoutClamp = null
}

override fun didDispatchMountItems(uiManager: UIManager) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
val currentScrollY = scrollY
val maxScrollY = getMaxScrollY()
if (currentScrollY > maxScrollY) {
maintainVisibleContentPositionHelper?.onWillClampScrollY(currentScrollY)
scrollTo(scrollX, maxScrollY)
}
}
Expand Down
Loading
Loading