new feature to support smooth bi-directional content loading
Summary: == Problem / Background == Most lists paginate in a single direction (standard infinite list), but some paginate in both directions. Most common example is a chat thread where new messages show up on the bottom, and old content can be loaded by scrolling up. Comment threads are another example. Right now, adding content to the bottom of a scroll view is smooth - the content doesn't jump. But when adding to the top of the scrollview, the content gets pushed down, which is jarring (note this may appear reversed because of inverting the list which is common for chat applications). == Approach == The basic idea is simple - we set a flag in JS, then for every uimanager transaction, we record which is the first eligible and visible view in the ScrollView, and compare it's new origin to the old one. If it has changed, we update the contentOffset of the ScrollView to compensate. This is done by observing `willPerformMounting` directly (only from scrollviews that have this new property set), and then observing the prev state with prependUIBlock and making the update synchronously in addUIBlock to avoid any flicker. There is also a way to skip views that we don't care about, like a spinner at the top of the view that we don't want to stay in place - we actually want it to get pushed up by the new content, replaced visually in the viewport. == Notes == Most chat applications will probably want to do a scrollToTop when new content comes in and the user is already scrolled at or near the bottom. This is glitchy if visible children are re-ordered, which could be fixed with additional logic, but it doesn't come up in the type of applications we're targetting here so punting on that. == Test Plan == https://youtu.be/4GcqDGz9eOE Reviewed By: shergin Differential Revision: D6696921 fbshipit-source-id: 822e7dfcb207006cd1ba098356324ea81f619428
This commit is contained in:
parent
b815eb59be
commit
cae7179c94
|
@ -233,6 +233,19 @@ const ScrollView = createReactClass({
|
|||
* - `true`, deprecated, use 'always' instead
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @platform ios
|
||||
*/
|
||||
maintainPositionAtOrBeyondIndex: PropTypes.number,
|
||||
/**
|
||||
* The maximum allowed zoom scale. The default value is 1.0.
|
||||
* @platform ios
|
||||
|
|
|
@ -12,10 +12,13 @@
|
|||
*/
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
var ReactNative = require('react-native');
|
||||
var {
|
||||
Platform,
|
||||
import type {StyleObj} from 'StyleSheetTypes';
|
||||
|
||||
const ActivityIndicator = require('ActivityIndicator');
|
||||
const Platform = require('Platform');
|
||||
const React = require('react');
|
||||
const ReactNative = require('react-native');
|
||||
const {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
|
@ -30,11 +33,11 @@ exports.description =
|
|||
'Component that enables scrolling through child components';
|
||||
exports.examples = [
|
||||
{
|
||||
title: '<ScrollView>',
|
||||
title: '<ScrollView>\n',
|
||||
description:
|
||||
'To make content scrollable, wrap it within a <ScrollView> component',
|
||||
render: function() {
|
||||
var _scrollView: ScrollView;
|
||||
let _scrollView: ScrollView;
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
|
@ -49,33 +52,30 @@ exports.examples = [
|
|||
style={styles.scrollView}>
|
||||
{THUMB_URLS.map(createThumbRow)}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
<Button
|
||||
label="Scroll to top"
|
||||
onPress={() => {
|
||||
_scrollView.scrollTo({y: 0});
|
||||
}}>
|
||||
<Text>Scroll to top</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Scroll to bottom"
|
||||
onPress={() => {
|
||||
_scrollView.scrollToEnd({animated: true});
|
||||
}}>
|
||||
<Text>Scroll to bottom</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Flash scroll indicators"
|
||||
onPress={() => {
|
||||
_scrollView.flashScrollIndicators();
|
||||
}}>
|
||||
<Text>Flash scroll indicators</Text>
|
||||
</TouchableOpacity>
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '<ScrollView> (horizontal = true)',
|
||||
title: '<ScrollView> (horizontal = true)\n',
|
||||
description:
|
||||
"You can display <ScrollView>'s child components horizontally rather than vertically",
|
||||
render: function() {
|
||||
|
@ -83,7 +83,7 @@ exports.examples = [
|
|||
title: string,
|
||||
addtionalStyles: typeof StyleSheet,
|
||||
) {
|
||||
var _scrollView: ScrollView;
|
||||
let _scrollView: ScrollView;
|
||||
return (
|
||||
<View style={addtionalStyles}>
|
||||
<Text style={styles.text}>{title}</Text>
|
||||
|
@ -96,27 +96,24 @@ exports.examples = [
|
|||
style={[styles.scrollView, styles.horizontalScrollView]}>
|
||||
{THUMB_URLS.map(createThumbRow)}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
<Button
|
||||
label="Scroll to start"
|
||||
onPress={() => {
|
||||
_scrollView.scrollTo({x: 0});
|
||||
}}>
|
||||
<Text>Scroll to start</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Scroll to end"
|
||||
onPress={() => {
|
||||
_scrollView.scrollToEnd({animated: true});
|
||||
}}>
|
||||
<Text>Scroll to end</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Flash scroll indicators"
|
||||
onPress={() => {
|
||||
_scrollView.flashScrollIndicators();
|
||||
}}>
|
||||
<Text>Flash scroll indicators</Text>
|
||||
</TouchableOpacity>
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
@ -130,22 +127,144 @@ exports.examples = [
|
|||
},
|
||||
},
|
||||
];
|
||||
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 ' +
|
||||
'without causing the visible content to jump. Re-ordering is not supported.',
|
||||
render: function() {
|
||||
let itemCount = 6;
|
||||
class AppendingList extends React.Component<{}, *> {
|
||||
state = {
|
||||
items: [...Array(itemCount)].map((_, ii) => (
|
||||
<Thumb msg={`Item ${ii}`} />
|
||||
)),
|
||||
};
|
||||
render() {
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
automaticallyAdjustContentInsets={false}
|
||||
maintainPositionAtOrBeyondIndex={1}
|
||||
style={styles.scrollView}>
|
||||
<ActivityIndicator style={{height: 40}} />
|
||||
{this.state.items.map(item =>
|
||||
React.cloneElement(item, {key: item.props.msg}),
|
||||
)}
|
||||
</ScrollView>
|
||||
<ScrollView
|
||||
horizontal={true}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
maintainPositionAtOrBeyondIndex={1}
|
||||
style={[styles.scrollView, styles.horizontalScrollView]}>
|
||||
<ActivityIndicator style={{height: 40}} />
|
||||
{this.state.items.map(item =>
|
||||
React.cloneElement(item, {key: item.props.msg, style: null}),
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={styles.row}>
|
||||
<Button
|
||||
label="Add to top"
|
||||
onPress={() => {
|
||||
this.setState(state => {
|
||||
const idx = itemCount++;
|
||||
return {
|
||||
items: [
|
||||
<Thumb
|
||||
style={{paddingTop: idx * 5}}
|
||||
msg={`Item ${idx}`}
|
||||
/>,
|
||||
].concat(state.items),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Remove top"
|
||||
onPress={() => {
|
||||
this.setState(state => ({
|
||||
items: state.items.slice(1),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Change height top"
|
||||
onPress={() => {
|
||||
this.setState(state => ({
|
||||
items: [
|
||||
React.cloneElement(state.items[0], {
|
||||
style: {paddingBottom: Math.random() * 40},
|
||||
}),
|
||||
].concat(state.items.slice(1)),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Button
|
||||
label="Add to end"
|
||||
onPress={() => {
|
||||
this.setState(state => ({
|
||||
items: state.items.concat(
|
||||
<Thumb msg={`Item ${itemCount++}`} />,
|
||||
),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Remove end"
|
||||
onPress={() => {
|
||||
this.setState(state => ({
|
||||
items: state.items.slice(0, -1),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Change height end"
|
||||
onPress={() => {
|
||||
this.setState(state => ({
|
||||
items: state.items.slice(0, -1).concat(
|
||||
React.cloneElement(
|
||||
state.items[state.items.length - 1],
|
||||
{
|
||||
style: {paddingBottom: Math.random() * 40},
|
||||
},
|
||||
),
|
||||
),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <AppendingList />;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class Thumb extends React.Component<$FlowFixMeProps, $FlowFixMeState> {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
class Thumb extends React.PureComponent<{|
|
||||
source?: string | number,
|
||||
msg?: string,
|
||||
style?: StyleObj,
|
||||
|}> {
|
||||
render() {
|
||||
const {source} = this.props;
|
||||
return (
|
||||
<View style={styles.thumb}>
|
||||
<Image style={styles.img} source={this.props.source} />
|
||||
<View style={[styles.thumb, this.props.style]}>
|
||||
<Image
|
||||
style={styles.img}
|
||||
source={source == null ? THUMB_URLS[6] : source}
|
||||
/>
|
||||
<Text>{this.props.msg}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var THUMB_URLS = [
|
||||
let THUMB_URLS = [
|
||||
require('./Thumbnails/like.png'),
|
||||
require('./Thumbnails/dislike.png'),
|
||||
require('./Thumbnails/call.png'),
|
||||
|
@ -162,9 +281,15 @@ var THUMB_URLS = [
|
|||
|
||||
THUMB_URLS = THUMB_URLS.concat(THUMB_URLS); // double length of THUMB_URLS
|
||||
|
||||
var createThumbRow = (uri, i) => <Thumb key={i} source={uri} />;
|
||||
const createThumbRow = (uri, i) => <Thumb key={i} source={uri} />;
|
||||
|
||||
var styles = StyleSheet.create({
|
||||
const Button = ({label, onPress}) => (
|
||||
<TouchableOpacity style={styles.button} onPress={onPress}>
|
||||
<Text>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
backgroundColor: '#eeeeee',
|
||||
height: 300,
|
||||
|
@ -184,6 +309,10 @@ var styles = StyleSheet.create({
|
|||
backgroundColor: '#cccccc',
|
||||
borderRadius: 3,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
},
|
||||
thumb: {
|
||||
margin: 5,
|
||||
padding: 5,
|
||||
|
|
|
@ -45,6 +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, assign) int snapToInterval;
|
||||
@property (nonatomic, copy) NSString *snapToAlignment;
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
#import "RCTEventDispatcher.h"
|
||||
#import "RCTLog.h"
|
||||
#import "RCTUIManager.h"
|
||||
#import "RCTUIManagerObserverCoordinator.h"
|
||||
#import "RCTUIManagerUtils.h"
|
||||
#import "RCTUtils.h"
|
||||
#import "UIView+Private.h"
|
||||
#import "UIView+React.h"
|
||||
|
@ -355,9 +357,15 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init)
|
|||
|
||||
@end
|
||||
|
||||
@interface RCTScrollView () <RCTUIManagerObserver>
|
||||
|
||||
@end
|
||||
|
||||
@implementation RCTScrollView
|
||||
{
|
||||
RCTEventDispatcher *_eventDispatcher;
|
||||
CGRect _prevFirstVisibleFrame;
|
||||
__weak UIView *_firstVisibleView;
|
||||
RCTCustomScrollView *_scrollView;
|
||||
UIView *_contentView;
|
||||
NSTimeInterval _lastScrollDispatchTime;
|
||||
|
@ -492,6 +500,7 @@ static inline void RCTApplyTranformationAccordingLayoutDirection(UIView *view, U
|
|||
- (void)dealloc
|
||||
{
|
||||
_scrollView.delegate = nil;
|
||||
[_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews
|
||||
|
@ -666,7 +675,6 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
|
|||
_lastScrollDispatchTime = now;
|
||||
_allowNextScrollNoMatterWhat = NO;
|
||||
}
|
||||
|
||||
RCT_FORWARD_SCROLL_EVENT(scrollViewDidScroll:scrollView);
|
||||
}
|
||||
|
||||
|
@ -903,6 +911,66 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
|
|||
}
|
||||
}
|
||||
|
||||
// maintainPositionAtOrBeyondIndex 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
|
||||
{
|
||||
if (maintainPositionAtOrBeyondIndex != nil) {
|
||||
[_eventDispatcher.bridge.uiManager.observerCoordinator addObserver:self];
|
||||
} else {
|
||||
[_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
|
||||
}
|
||||
_maintainPositionAtOrBeyondIndex = maintainPositionAtOrBeyondIndex;
|
||||
}
|
||||
|
||||
#pragma mark - RCTUIManagerObserver
|
||||
|
||||
- (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
|
||||
{
|
||||
RCTAssertUIManagerQueue();
|
||||
[manager prependUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
||||
BOOL horz = [self isHorizontal:self->_scrollView];
|
||||
NSUInteger minIdx = [self->_maintainPositionAtOrBeyondIndex 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
|
||||
UIView *subview = self->_contentView.subviews[ii];
|
||||
if ((horz
|
||||
? subview.frame.origin.x >= self->_scrollView.contentOffset.x
|
||||
: subview.frame.origin.y >= self->_scrollView.contentOffset.y) ||
|
||||
ii == self->_contentView.subviews.count - 1) {
|
||||
self->_prevFirstVisibleFrame = subview.frame;
|
||||
self->_firstVisibleView = subview;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}];
|
||||
[manager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
||||
if (self->_maintainPositionAtOrBeyondIndex == nil) {
|
||||
return; // The prop might have changed in the previous UIBlocks, so need to abort here.
|
||||
}
|
||||
// TODO: detect and handle/ignore re-ordering
|
||||
if ([self isHorizontal:self->_scrollView]) {
|
||||
CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x;
|
||||
if (ABS(deltaX) > 0.1) {
|
||||
self->_scrollView.contentOffset = CGPointMake(
|
||||
self->_scrollView.contentOffset.x + deltaX,
|
||||
self->_scrollView.contentOffset.y
|
||||
);
|
||||
}
|
||||
} else {
|
||||
CGRect newFrame = self->_firstVisibleView.frame;
|
||||
CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;
|
||||
if (ABS(deltaY) > 0.1 || deltaY != 0.0) {
|
||||
self->_scrollView.contentOffset = CGPointMake(
|
||||
self->_scrollView.contentOffset.x,
|
||||
self->_scrollView.contentOffset.y + deltaY
|
||||
);
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// Note: setting several properties of UIScrollView has the effect of
|
||||
// resetting its contentOffset to {0, 0}. To prevent this, we generate
|
||||
// setters here that will record the contentOffset beforehand, and
|
||||
|
|
|
@ -62,6 +62,7 @@ RCT_EXPORT_VIEW_PROPERTY(bounces, BOOL)
|
|||
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(automaticallyAdjustContentInsets, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(decelerationRate, CGFloat)
|
||||
RCT_EXPORT_VIEW_PROPERTY(directionalLockEnabled, BOOL)
|
||||
|
|
Loading…
Reference in New Issue