Skip to content

Commit cae7179

Browse files
sahrensfacebook-github-bot
authored andcommitted
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
1 parent b815eb5 commit cae7179

File tree

5 files changed

+261
-49
lines changed

5 files changed

+261
-49
lines changed

Libraries/Components/ScrollView/ScrollView.js

+13
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,19 @@ const ScrollView = createReactClass({
233233
* - `true`, deprecated, use 'always' instead
234234
*/
235235
keyboardShouldPersistTaps: PropTypes.oneOf(['always', 'never', 'handled', false, true]),
236+
/**
237+
* When non-null, the scroll view will adjust the scroll position so that the content at or
238+
* beyond the specified index that is currently visible will not change position. This is useful
239+
* for lists that are loading content in both directions, e.g. a chat thread, where new messages
240+
* coming in might otherwise cause the scroll position to jump. A value of 1 can be used to skip
241+
* a spinner that does not need to maintain position. The default value is null.
242+
*
243+
* Caveat: reordering elements in the scrollview with this enabled will probably cause jumpiness
244+
* and jank. It can be fixed, but there are currently no plans to do so.
245+
*
246+
* @platform ios
247+
*/
248+
maintainPositionAtOrBeyondIndex: PropTypes.number,
236249
/**
237250
* The maximum allowed zoom scale. The default value is 1.0.
238251
* @platform ios

RNTester/js/ScrollViewExample.js

+177-48
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
*/
1313
'use strict';
1414

15-
var React = require('react');
16-
var ReactNative = require('react-native');
17-
var {
18-
Platform,
15+
import type {StyleObj} from 'StyleSheetTypes';
16+
17+
const ActivityIndicator = require('ActivityIndicator');
18+
const Platform = require('Platform');
19+
const React = require('react');
20+
const ReactNative = require('react-native');
21+
const {
1922
ScrollView,
2023
StyleSheet,
2124
Text,
@@ -30,11 +33,11 @@ exports.description =
3033
'Component that enables scrolling through child components';
3134
exports.examples = [
3235
{
33-
title: '<ScrollView>',
36+
title: '<ScrollView>\n',
3437
description:
3538
'To make content scrollable, wrap it within a <ScrollView> component',
3639
render: function() {
37-
var _scrollView: ScrollView;
40+
let _scrollView: ScrollView;
3841
return (
3942
<View>
4043
<ScrollView
@@ -49,41 +52,38 @@ exports.examples = [
4952
style={styles.scrollView}>
5053
{THUMB_URLS.map(createThumbRow)}
5154
</ScrollView>
52-
<TouchableOpacity
53-
style={styles.button}
55+
<Button
56+
label="Scroll to top"
5457
onPress={() => {
5558
_scrollView.scrollTo({y: 0});
56-
}}>
57-
<Text>Scroll to top</Text>
58-
</TouchableOpacity>
59-
<TouchableOpacity
60-
style={styles.button}
59+
}}
60+
/>
61+
<Button
62+
label="Scroll to bottom"
6163
onPress={() => {
6264
_scrollView.scrollToEnd({animated: true});
63-
}}>
64-
<Text>Scroll to bottom</Text>
65-
</TouchableOpacity>
66-
<TouchableOpacity
67-
style={styles.button}
65+
}}
66+
/>
67+
<Button
68+
label="Flash scroll indicators"
6869
onPress={() => {
6970
_scrollView.flashScrollIndicators();
70-
}}>
71-
<Text>Flash scroll indicators</Text>
72-
</TouchableOpacity>
71+
}}
72+
/>
7373
</View>
7474
);
7575
},
7676
},
7777
{
78-
title: '<ScrollView> (horizontal = true)',
78+
title: '<ScrollView> (horizontal = true)\n',
7979
description:
8080
"You can display <ScrollView>'s child components horizontally rather than vertically",
8181
render: function() {
8282
function renderScrollView(
8383
title: string,
8484
addtionalStyles: typeof StyleSheet,
8585
) {
86-
var _scrollView: ScrollView;
86+
let _scrollView: ScrollView;
8787
return (
8888
<View style={addtionalStyles}>
8989
<Text style={styles.text}>{title}</Text>
@@ -96,27 +96,24 @@ exports.examples = [
9696
style={[styles.scrollView, styles.horizontalScrollView]}>
9797
{THUMB_URLS.map(createThumbRow)}
9898
</ScrollView>
99-
<TouchableOpacity
100-
style={styles.button}
99+
<Button
100+
label="Scroll to start"
101101
onPress={() => {
102102
_scrollView.scrollTo({x: 0});
103-
}}>
104-
<Text>Scroll to start</Text>
105-
</TouchableOpacity>
106-
<TouchableOpacity
107-
style={styles.button}
103+
}}
104+
/>
105+
<Button
106+
label="Scroll to end"
108107
onPress={() => {
109108
_scrollView.scrollToEnd({animated: true});
110-
}}>
111-
<Text>Scroll to end</Text>
112-
</TouchableOpacity>
113-
<TouchableOpacity
114-
style={styles.button}
109+
}}
110+
/>
111+
<Button
112+
label="Flash scroll indicators"
115113
onPress={() => {
116114
_scrollView.flashScrollIndicators();
117-
}}>
118-
<Text>Flash scroll indicators</Text>
119-
</TouchableOpacity>
115+
}}
116+
/>
120117
</View>
121118
);
122119
}
@@ -130,22 +127,144 @@ exports.examples = [
130127
},
131128
},
132129
];
130+
if (Platform.OS === 'ios') {
131+
exports.examples.push({
132+
title: '<ScrollView> smooth bi-directional content loading\n',
133+
description:
134+
'The `maintainPositionAtOrBeyondIndex` prop allows insertions to either end of the content ' +
135+
'without causing the visible content to jump. Re-ordering is not supported.',
136+
render: function() {
137+
let itemCount = 6;
138+
class AppendingList extends React.Component<{}, *> {
139+
state = {
140+
items: [...Array(itemCount)].map((_, ii) => (
141+
<Thumb msg={`Item ${ii}`} />
142+
)),
143+
};
144+
render() {
145+
return (
146+
<View>
147+
<ScrollView
148+
automaticallyAdjustContentInsets={false}
149+
maintainPositionAtOrBeyondIndex={1}
150+
style={styles.scrollView}>
151+
<ActivityIndicator style={{height: 40}} />
152+
{this.state.items.map(item =>
153+
React.cloneElement(item, {key: item.props.msg}),
154+
)}
155+
</ScrollView>
156+
<ScrollView
157+
horizontal={true}
158+
automaticallyAdjustContentInsets={false}
159+
maintainPositionAtOrBeyondIndex={1}
160+
style={[styles.scrollView, styles.horizontalScrollView]}>
161+
<ActivityIndicator style={{height: 40}} />
162+
{this.state.items.map(item =>
163+
React.cloneElement(item, {key: item.props.msg, style: null}),
164+
)}
165+
</ScrollView>
166+
<View style={styles.row}>
167+
<Button
168+
label="Add to top"
169+
onPress={() => {
170+
this.setState(state => {
171+
const idx = itemCount++;
172+
return {
173+
items: [
174+
<Thumb
175+
style={{paddingTop: idx * 5}}
176+
msg={`Item ${idx}`}
177+
/>,
178+
].concat(state.items),
179+
};
180+
});
181+
}}
182+
/>
183+
<Button
184+
label="Remove top"
185+
onPress={() => {
186+
this.setState(state => ({
187+
items: state.items.slice(1),
188+
}));
189+
}}
190+
/>
191+
<Button
192+
label="Change height top"
193+
onPress={() => {
194+
this.setState(state => ({
195+
items: [
196+
React.cloneElement(state.items[0], {
197+
style: {paddingBottom: Math.random() * 40},
198+
}),
199+
].concat(state.items.slice(1)),
200+
}));
201+
}}
202+
/>
203+
</View>
204+
<View style={styles.row}>
205+
<Button
206+
label="Add to end"
207+
onPress={() => {
208+
this.setState(state => ({
209+
items: state.items.concat(
210+
<Thumb msg={`Item ${itemCount++}`} />,
211+
),
212+
}));
213+
}}
214+
/>
215+
<Button
216+
label="Remove end"
217+
onPress={() => {
218+
this.setState(state => ({
219+
items: state.items.slice(0, -1),
220+
}));
221+
}}
222+
/>
223+
<Button
224+
label="Change height end"
225+
onPress={() => {
226+
this.setState(state => ({
227+
items: state.items.slice(0, -1).concat(
228+
React.cloneElement(
229+
state.items[state.items.length - 1],
230+
{
231+
style: {paddingBottom: Math.random() * 40},
232+
},
233+
),
234+
),
235+
}));
236+
}}
237+
/>
238+
</View>
239+
</View>
240+
);
241+
}
242+
}
243+
return <AppendingList />;
244+
},
245+
});
246+
}
133247

134-
class Thumb extends React.Component<$FlowFixMeProps, $FlowFixMeState> {
135-
shouldComponentUpdate(nextProps, nextState) {
136-
return false;
137-
}
138-
248+
class Thumb extends React.PureComponent<{|
249+
source?: string | number,
250+
msg?: string,
251+
style?: StyleObj,
252+
|}> {
139253
render() {
254+
const {source} = this.props;
140255
return (
141-
<View style={styles.thumb}>
142-
<Image style={styles.img} source={this.props.source} />
256+
<View style={[styles.thumb, this.props.style]}>
257+
<Image
258+
style={styles.img}
259+
source={source == null ? THUMB_URLS[6] : source}
260+
/>
261+
<Text>{this.props.msg}</Text>
143262
</View>
144263
);
145264
}
146265
}
147266

148-
var THUMB_URLS = [
267+
let THUMB_URLS = [
149268
require('./Thumbnails/like.png'),
150269
require('./Thumbnails/dislike.png'),
151270
require('./Thumbnails/call.png'),
@@ -162,9 +281,15 @@ var THUMB_URLS = [
162281

163282
THUMB_URLS = THUMB_URLS.concat(THUMB_URLS); // double length of THUMB_URLS
164283

165-
var createThumbRow = (uri, i) => <Thumb key={i} source={uri} />;
284+
const createThumbRow = (uri, i) => <Thumb key={i} source={uri} />;
166285

167-
var styles = StyleSheet.create({
286+
const Button = ({label, onPress}) => (
287+
<TouchableOpacity style={styles.button} onPress={onPress}>
288+
<Text>{label}</Text>
289+
</TouchableOpacity>
290+
);
291+
292+
const styles = StyleSheet.create({
168293
scrollView: {
169294
backgroundColor: '#eeeeee',
170295
height: 300,
@@ -184,6 +309,10 @@ var styles = StyleSheet.create({
184309
backgroundColor: '#cccccc',
185310
borderRadius: 3,
186311
},
312+
row: {
313+
flexDirection: 'row',
314+
justifyContent: 'space-around',
315+
},
187316
thumb: {
188317
margin: 5,
189318
padding: 5,

React/Views/ScrollView/RCTScrollView.h

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
@property (nonatomic, assign) BOOL DEPRECATED_sendUpdatedChildFrames;
4646
@property (nonatomic, assign) NSTimeInterval scrollEventThrottle;
4747
@property (nonatomic, assign) BOOL centerContent;
48+
@property (nonatomic, copy) NSNumber *maintainPositionAtOrBeyondIndex;
4849
@property (nonatomic, assign) int snapToInterval;
4950
@property (nonatomic, copy) NSString *snapToAlignment;
5051

0 commit comments

Comments
 (0)