Permalink
Browse files

rename and extend new maintain visible content position feature

Summary:
Builds off of cae7179

- Make the prop a dictionary for more configuration options
- Rename `maintainPositionAtOrBeyondIndex` -> `maintainVisibleContentPosition` + `minIndexForVisible`
- Add autoscroll threshold feature

Given the async native of RN JS and background layout, there is no way to trigger the scrollTo from JS without risking a delay, so we add the feature in native code.

== Test Plan ==
ScrollViewExample:
https://youtu.be/pmY8pxC9PRs

Reviewed By: shergin

Differential Revision: D6729160

fbshipit-source-id: 70f9bae460ce84567857a4f696da78ce9b3b834c
  • Loading branch information...
sahrens authored and facebook-github-bot committed Jan 18, 2018
1 parent 7e7d00a commit 65184ec6b0ef2d136c0db239d65e0624efac8a2d
@@ -234,18 +234,33 @@ const ScrollView = createReactClass({
*/
keyboardShouldPersistTaps: PropTypes.oneOf(['always', 'never', 'handled', false, true]),
/**
* When non-null, the scroll view will adjust the scroll position so that the content at or
* beyond the specified index that is currently visible will not change position. This is useful
* for lists that are loading content in both directions, e.g. a chat thread, where new messages
* coming in might otherwise cause the scroll position to jump. A value of 1 can be used to skip
* a spinner that does not need to maintain position. The default value is null.
* When set, the scroll view will adjust the scroll position so that the first child that is
* currently visible and at or beyond `minIndexForVisible` will not change position. This is
* useful for lists that are loading content in both directions, e.g. a chat thread, where new
* messages coming in might otherwise cause the scroll position to jump. A value of 0 is common,
* but other values such as 1 can be used to skip loading spinners or other content that should
* not maintain position.
*
* Caveat: reordering elements in the scrollview with this enabled will probably cause jumpiness
* and jank. It can be fixed, but there are currently no plans to do so.
* The optional `autoscrollToTopThreshold` can be used to make the content automatically scroll
* to the top after making the adjustment if the user was within the threshold of the top before
* the adjustment was made. This is also useful for chat-like applications where you want to see
* new messages scroll into place, but not if the user has scrolled up a ways and it would be
* disruptive to scroll a bunch.
*
* Caveat 1: Reordering elements in the scrollview with this enabled will probably cause
* jumpiness and jank. It can be fixed, but there are currently no plans to do so. For now,
* don't re-order the content of any ScrollViews or Lists that use this feature.
*
* Caveat 2: This simply uses `contentOffset` and `frame.origin` in native code to compute
* visibility. Occlusion, transforms, and other complexity won't be taken into account as to
* whether content is "visible" or not.
*
* @platform ios
*/
maintainPositionAtOrBeyondIndex: PropTypes.number,
maintainVisibleContentPosition: PropTypes.shape({
minIndexForVisible: PropTypes.number.isRequired,
autoscrollToTopThreshold: PropTypes.number,
}),
/**
* The maximum allowed zoom scale. The default value is 1.0.
* @platform ios
@@ -131,7 +131,7 @@ if (Platform.OS === 'ios') {
exports.examples.push({
title: '<ScrollView> smooth bi-directional content loading\n',
description:
'The `maintainPositionAtOrBeyondIndex` prop allows insertions to either end of the content ' +
'The `maintainVisibleContentPosition` prop allows insertions to either end of the content ' +
'without causing the visible content to jump. Re-ordering is not supported.',
render: function() {
let itemCount = 6;
@@ -146,7 +146,10 @@ if (Platform.OS === 'ios') {
<View>
<ScrollView
automaticallyAdjustContentInsets={false}
maintainPositionAtOrBeyondIndex={1}
maintainVisibleContentPosition={{
minIndexForVisible: 1,
autoscrollToTopThreshold: 10,
}}
style={styles.scrollView}>
<ActivityIndicator style={{height: 40}} />
{this.state.items.map(item =>
@@ -156,9 +159,12 @@ if (Platform.OS === 'ios') {
<ScrollView
horizontal={true}
automaticallyAdjustContentInsets={false}
maintainPositionAtOrBeyondIndex={1}
maintainVisibleContentPosition={{
minIndexForVisible: 1,
autoscrollToTopThreshold: 10,
}}
style={[styles.scrollView, styles.horizontalScrollView]}>
<ActivityIndicator style={{height: 40}} />
<ActivityIndicator style={{width: 40}} />
{this.state.items.map(item =>
React.cloneElement(item, {key: item.props.msg, style: null}),
)}
@@ -45,7 +45,7 @@
@property (nonatomic, assign) BOOL DEPRECATED_sendUpdatedChildFrames;
@property (nonatomic, assign) NSTimeInterval scrollEventThrottle;
@property (nonatomic, assign) BOOL centerContent;
@property (nonatomic, copy) NSNumber *maintainPositionAtOrBeyondIndex;
@property (nonatomic, copy) NSDictionary *maintainVisibleContentPosition;
@property (nonatomic, assign) int snapToInterval;
@property (nonatomic, copy) NSString *snapToAlignment;
@@ -911,16 +911,16 @@ - (void)updateContentOffsetIfNeeded
}
}
// maintainPositionAtOrBeyondIndex is used to allow seamless loading of content from both ends of
// maintainVisibleContentPosition is used to allow seamless loading of content from both ends of
// the scrollview without the visible content jumping in position.
- (void)setMaintainPositionAtOrBeyondIndex:(NSNumber *)maintainPositionAtOrBeyondIndex
- (void)setMaintainVisibleContentPosition:(NSDictionary *)maintainVisibleContentPosition
{
if (maintainPositionAtOrBeyondIndex != nil) {
if (maintainVisibleContentPosition != nil && _maintainVisibleContentPosition == nil) {
[_eventDispatcher.bridge.uiManager.observerCoordinator addObserver:self];
} else {
} else if (maintainVisibleContentPosition == nil && _maintainVisibleContentPosition != nil) {
[_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
}
_maintainPositionAtOrBeyondIndex = maintainPositionAtOrBeyondIndex;
_maintainVisibleContentPosition = maintainVisibleContentPosition;
}
#pragma mark - RCTUIManagerObserver
@@ -930,7 +930,7 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
RCTAssertUIManagerQueue();
[manager prependUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
BOOL horz = [self isHorizontal:self->_scrollView];
NSUInteger minIdx = [self->_maintainPositionAtOrBeyondIndex integerValue];
NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
// Find the first entirely visible view. This must be done after we update the content offset
// or it will tend to grab rows that were made visible by the shift in position
@@ -946,9 +946,10 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
}
}];
[manager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
if (self->_maintainPositionAtOrBeyondIndex == nil) {
if (self->_maintainVisibleContentPosition == nil) {
return; // The prop might have changed in the previous UIBlocks, so need to abort here.
}
NSNumber *autoscrollThreshold = self->_maintainVisibleContentPosition[@"autoscrollToTopThreshold"];
// TODO: detect and handle/ignore re-ordering
if ([self isHorizontal:self->_scrollView]) {
CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x;
@@ -957,15 +958,27 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
self->_scrollView.contentOffset.x + deltaX,
self->_scrollView.contentOffset.y
);
if (autoscrollThreshold != nil) {
// If the offset WAS within the threshold of the start, animate to the start.
if (self->_scrollView.contentOffset.x - deltaX <= [autoscrollThreshold integerValue]) {
[self scrollToOffset:CGPointMake(0, self->_scrollView.contentOffset.y) animated:YES];
}
}
}
} else {
CGRect newFrame = self->_firstVisibleView.frame;
CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;
if (ABS(deltaY) > 0.1 || deltaY != 0.0) {
if (ABS(deltaY) > 0.1) {
self->_scrollView.contentOffset = CGPointMake(
self->_scrollView.contentOffset.x,
self->_scrollView.contentOffset.y + deltaY
);
if (autoscrollThreshold != nil) {
// If the offset WAS within the threshold of the start, animate to the start.
if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollThreshold integerValue]) {
[self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES];
}
}
}
}
}];
@@ -62,7 +62,7 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(bouncesZoom, BOOL)
RCT_EXPORT_VIEW_PROPERTY(canCancelContentTouches, BOOL)
RCT_EXPORT_VIEW_PROPERTY(centerContent, BOOL)
RCT_EXPORT_VIEW_PROPERTY(maintainPositionAtOrBeyondIndex, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(maintainVisibleContentPosition, NSDictionary)
RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustContentInsets, BOOL)
RCT_EXPORT_VIEW_PROPERTY(decelerationRate, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(directionalLockEnabled, BOOL)

13 comments on commit 65184ec

@sahrens

This comment has been minimized.

Contributor

sahrens replied Jan 19, 2018

cc @janicduplessis, @erictraut, @browniefed: y'all might be interested in this. Working on Android now.

@sahrens

This comment has been minimized.

Contributor

sahrens replied Jan 19, 2018

Let me know if you have any feedback on the API or anything.

@janicduplessis

This comment has been minimized.

Collaborator

janicduplessis replied Jan 19, 2018

@sahrens Nice, I'll have to implement proper pagination on comment threads to make use of this now 😀

@sahrens

This comment has been minimized.

Contributor

sahrens replied Jan 19, 2018

Note that depending on the application, you might want to use LayoutAnimation instead - e.g. tapping "show more" explicitly vs. automatically loading on scroll.

@asdanilenk

This comment has been minimized.

asdanilenk replied Jan 19, 2018

@sahrens any plans to integrate this into VirtualizedList somehow?

@sahrens

This comment has been minimized.

Contributor

sahrens replied Jan 19, 2018

It already works - FlatList and friends use ScrollView under the hood so almost all ScrollView features work out of the box.

@asdanilenk

This comment has been minimized.

asdanilenk replied Jan 19, 2018

Thanks! Will have to try it.

@guysegal

This comment has been minimized.

guysegal replied Mar 9, 2018

Is there a plan to implement it on Android soon?

@guysegal

This comment has been minimized.

guysegal replied Apr 12, 2018

@sahrens this is amazing, we tried to implement something similar but with no success..
Are you actively working on the Android solution? Is there a way to assist?

@giantss

This comment has been minimized.

giantss replied Jun 8, 2018

@sahrens Thank you very much for your solution, A better experience would be for the user to pull down to the top to load the history chat and the message won't beat, like onEndReached the effect of overlapping the bottom of the data is the same.

@marsonmao

This comment has been minimized.

marsonmao replied Jul 9, 2018

Looking forward to the android version!!

@bishalshr

This comment has been minimized.

bishalshr replied Oct 4, 2018

@sahrens
maintainVisibleContentPosition={{ minIndexForVisible: 0, }} isn't working for prepending the first set of data. It works well after that.

@chinalwb

This comment has been minimized.

chinalwb replied Nov 5, 2018

@sahrens I am running into the same issue with @bishalshr , I had reported the issue [FlatList] The maintainVisibleContentPosition doesn't work for the 1st time change. #19621 but nobody cares my complain.

Please sign in to comment.