├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── index.ts ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.vscode 3 | /index.d.ts 4 | /index.js 5 | /index.js.map -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.vscode 3 | tsconfig.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 André Staltz (staltz.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PullFlatList 2 | 3 | ``` 4 | npm install --save pull-flat-list 5 | ``` 6 | 7 | A React Native component as a variant of FlatList, which takes a pull-stream as prop and automatically pulls from that when the scroll position gets closer to the end. 8 | 9 | ## Usage 10 | 11 | ```js 12 | import PullFlatList from 'pull-flat-list'; 13 | 14 | // ... then in a render function ... 15 | pull.values(['one', 'two', 'three'])} 17 | renderItem={({ item }) => {item.key}} 18 | />; 19 | ``` 20 | 21 | ## Props 22 | 23 | * `getScrollStream` (required) Factory function which returns a pull stream to be used when scrolling the FlatList, to pull more items and append them to the list. **Note!** This prop is not the pull stream directly, it's a function that returns a pull stream. 24 | * `getPrefixStream` (optional) Factory function which returns a pull stream to be used to prepend items to the FlatList, regardless of scrolling. 25 | * `pullAmount` (optional, default is 30) How many items to pull from the pull stream when the scroll position reaches the end. 26 | * `refreshable` (optional, default is false) Boolean indicating whether or not this list can be refreshed with the pull-to-refresh gesture. 27 | * `refreshColors` (optional) The colors (at least one) that will be used to draw the refresh indicator. 28 | * `onInitialPullDone` (optional) Called once when the PullFlatList has completed its first burst of pulls of data. Emits the number of items in the data array. 29 | * `onPullingComplete` (optional) Called once when the PullFlatList has completed pulling all data from the source. 30 | * (other props) all other props from FlatList are supported, except `data` and `extraData`, because this module's purpose is to manage that for you 31 | 32 | ## Methods 33 | 34 | * `forceRefresh(retainable?: boolean)` This method will force a refresh to occur, 35 | causing a pull of the scroll stream to start over. However, this method will **not** cause the callback `onInitialPullDone` to be triggered. The argument `retainable` signals (when `false`) whether you want the FlatList's rendering to be "cleaned" or (when `true`) if you want the FlatList to retain the rendering of the previous views *until* the first pull returns. By default, `retainable = false`. 36 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FlatList, 3 | ViewStyle, 4 | ViewToken, 5 | StyleProp, 6 | ListRenderItem, 7 | VirtualizedListProps, 8 | RefreshControl, 9 | Animated, 10 | } from 'react-native'; 11 | import {Component, createElement} from 'react'; 12 | 13 | export type Callback = (endOrErr: boolean | any, data?: T) => void; 14 | export type Readable = (endOrErr: boolean | any, cb?: Callback) => void; 15 | export type GetReadable = (opts?: any) => Readable; 16 | 17 | export interface PullFlatListProps extends VirtualizedListProps { 18 | /** 19 | * Factory function which returns a pull stream to be used when scrolling 20 | * the FlatList, to pull more items and append them to the list. 21 | */ 22 | getScrollStream: GetReadable | null; 23 | 24 | /** 25 | * Factory function which returns a pull stream to be used to prepend items 26 | * to the FlatList, regardless of scrolling. 27 | */ 28 | getPrefixStream?: GetReadable | null; 29 | 30 | /** 31 | * How many items to pull from the pull stream when the scroll position 32 | * reaches the end. 33 | */ 34 | pullAmount?: number; 35 | 36 | /** 37 | * Whether or not this list can be refreshed with the pull-to-refresh gesture. 38 | * By default, this is false. 39 | */ 40 | refreshable?: boolean; 41 | 42 | /** 43 | * Called once when the PullFlatList has completed its first burst of pulls 44 | * of data. Emits the number of items in the data array. 45 | */ 46 | onInitialPullDone?: (amountItems: number) => void; 47 | 48 | /** 49 | * Called once when the PullFlatList has completed pulling all data from 50 | * the source. 51 | */ 52 | onPullingComplete?: () => void; 53 | 54 | /** 55 | * Rendered in between each item, but not at the top or bottom 56 | */ 57 | ItemSeparatorComponent?: 58 | | React.ComponentType 59 | | (() => React.ReactElement) 60 | | null; 61 | 62 | /** 63 | * Rendered when the list is empty. 64 | */ 65 | ListEmptyComponent?: 66 | | React.ComponentClass 67 | | React.ReactElement 68 | | (() => React.ReactElement) 69 | | null; 70 | 71 | /** 72 | * Rendered at the very end of the list. 73 | */ 74 | ListFooterComponent?: 75 | | React.ComponentClass 76 | | React.ReactElement 77 | | (() => React.ReactElement) 78 | | null; 79 | 80 | /** 81 | * Rendered at the very beginning of the list. 82 | */ 83 | ListHeaderComponent?: 84 | | React.ComponentClass 85 | | React.ReactElement 86 | | (() => React.ReactElement) 87 | | null; 88 | 89 | /** 90 | * The colors (at least one) that will be used to draw the refresh indicator. 91 | */ 92 | refreshColors?: Array; 93 | 94 | /** 95 | * Optional custom style for multi-item rows generated when numColumns > 1 96 | */ 97 | columnWrapperStyle?: StyleProp; 98 | 99 | /** 100 | * When false tapping outside of the focused text input when the keyboard 101 | * is up dismisses the keyboard. When true the scroll view will not catch 102 | * taps and the keyboard will not dismiss automatically. The default value 103 | * is false. 104 | */ 105 | keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled'; 106 | 107 | /** 108 | * `getItemLayout` is an optional optimization that lets us skip measurement of dynamic 109 | * content if you know the height of items a priori. getItemLayout is the most efficient, 110 | * and is easy to use if you have fixed height items, for example: 111 | * ``` 112 | * getItemLayout={(data, index) => ( 113 | * {length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index} 114 | * )} 115 | * ``` 116 | * Remember to include separator length (height or width) in your offset calculation if you specify 117 | * `ItemSeparatorComponent`. 118 | */ 119 | getItemLayout?: ( 120 | data: Array | null, 121 | index: number, 122 | ) => {length: number; offset: number; index: number}; 123 | 124 | /** 125 | * If true, renders items next to each other horizontally instead of stacked vertically. 126 | */ 127 | horizontal?: boolean; 128 | 129 | /** 130 | * How many items to render in the initial batch 131 | */ 132 | initialNumToRender?: number; 133 | 134 | /** 135 | * Instead of starting at the top with the first item, start at initialScrollIndex 136 | */ 137 | initialScrollIndex?: number; 138 | 139 | /** 140 | * Used to extract a unique key for a given item at the specified index. Key is used for caching 141 | * and as the react key to track item re-ordering. The default extractor checks `item.key`, then 142 | * falls back to using the index, like React does. 143 | */ 144 | keyExtractor?: (item: ItemT, index: number) => string; 145 | 146 | legacyImplementation?: boolean; 147 | 148 | /** 149 | * Multiple columns can only be rendered with `horizontal={false}` and will zig-zag like a `flexWrap` layout. 150 | * Items should all be the same height - masonry layouts are not supported. 151 | */ 152 | numColumns?: number; 153 | 154 | /** 155 | * Called once when the scroll position gets within onEndReachedThreshold of the rendered content. 156 | */ 157 | onEndReached?: ((info: {distanceFromEnd: number}) => void) | null; 158 | 159 | /** 160 | * How far from the end (in units of visible length of the list) the bottom edge of the 161 | * list must be from the end of the content to trigger the `onEndReached` callback. 162 | * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is 163 | * within half the visible length of the list. 164 | */ 165 | onEndReachedThreshold?: number | null; 166 | 167 | /** 168 | * Called when the viewability of rows changes, as defined by the `viewablePercentThreshold` prop. 169 | */ 170 | onViewableItemsChanged?: 171 | | ((info: { 172 | viewableItems: Array; 173 | changed: Array; 174 | }) => void) 175 | | null; 176 | 177 | /** 178 | * Takes an item from data and renders it into the list. Typical usage: 179 | * ``` 180 | * _renderItem = ({item}) => ( 181 | * this._onPress(item)}> 182 | * {item.title}} 183 | * 184 | * ); 185 | * ... 186 | * 187 | * ``` 188 | * Provides additional metadata like `index` if you need it. 189 | */ 190 | renderItem: ListRenderItem; 191 | 192 | /** 193 | * See `ViewabilityHelper` for flow type and further documentation. 194 | */ 195 | viewabilityConfig?: any; 196 | 197 | /** 198 | * Note: may have bugs (missing content) in some circumstances - use at your own risk. 199 | * 200 | * This may improve scroll performance for large lists. 201 | */ 202 | removeClippedSubviews?: boolean; 203 | } 204 | 205 | /** 206 | * This depends on the internals of Animated.createAnimatedComponent 207 | */ 208 | interface AnimatedFlatListRef { 209 | _component?: FlatList; 210 | } 211 | 212 | export interface State { 213 | data: Array; 214 | isExpectingMore: boolean; 215 | updateInt: number; 216 | refreshing: boolean; 217 | } 218 | 219 | const DEFAULT_INITIAL_PULL_AMOUNT = 4; 220 | const DEFAULT_PULL_AMOUNT = 30; 221 | const DEFAULT_END_THRESHOLD = 4; 222 | 223 | export class PullFlatList extends Component, State> { 224 | private scrollReadable?: Readable; 225 | private prefixReadable?: Readable; 226 | private isPulling: boolean; 227 | private retainableRefresh: boolean; 228 | private morePullQueue: number; 229 | private iteration: number; 230 | private initialDone: boolean; 231 | private unmounting: boolean; 232 | private flatListRef: AnimatedFlatListRef | null; 233 | private _onEndReached: (info: {distanceFromEnd: number}) => void; 234 | private _onRefresh?: () => void; 235 | 236 | constructor(props: PullFlatListProps) { 237 | super(props); 238 | this.state = { 239 | data: [], 240 | isExpectingMore: true, 241 | updateInt: 0, 242 | refreshing: false, 243 | }; 244 | this.isPulling = false; 245 | this.retainableRefresh = false; 246 | this.morePullQueue = 0; 247 | this.iteration = 0; 248 | this.initialDone = false; 249 | this.unmounting = false; 250 | this._onEndReached = this.onEndReached.bind(this); 251 | this._onRefresh = props.refreshable ? this.onRefresh.bind(this) : undefined; 252 | this.flatListRef = null; 253 | } 254 | 255 | public componentDidMount() { 256 | this.unmounting = false; 257 | if (this.props.getScrollStream) { 258 | this.startScrollListener(this.props.getScrollStream()); 259 | } 260 | if (this.props.getPrefixStream) { 261 | this.startPrefixListener(this.props.getPrefixStream()); 262 | } 263 | } 264 | 265 | public componentWillUnmount() { 266 | this.unmounting = true; 267 | this.flatListRef = null; 268 | this.stopScrollListener(); 269 | this.stopPrefixListener(); 270 | } 271 | 272 | public componentDidUpdate(prevProps: PullFlatListProps) { 273 | const nextProps = this.props; 274 | const { 275 | getScrollStream: nextGetScrollReadable, 276 | getPrefixStream: nextGetPrefixReadable, 277 | } = nextProps; 278 | const { 279 | getScrollStream: prevGetScrollReadable, 280 | getPrefixStream: prevGetPrefixReadable, 281 | } = prevProps; 282 | 283 | if (nextGetScrollReadable !== prevGetScrollReadable) { 284 | this.stopScrollListener(() => { 285 | if (!this.unmounting) { 286 | this.isPulling = false; 287 | this.morePullQueue = 0; 288 | this.iteration = 0; 289 | this.startScrollListener(nextGetScrollReadable?.()); 290 | } 291 | }); 292 | } 293 | 294 | if (nextGetPrefixReadable !== prevGetPrefixReadable) { 295 | this.stopPrefixListener(() => { 296 | if (!this.unmounting) { 297 | this.startPrefixListener(nextGetPrefixReadable?.()); 298 | } 299 | }); 300 | } 301 | } 302 | 303 | public startScrollListener(readable?: Readable | null) { 304 | if (this.unmounting) return; 305 | if (readable) { 306 | this.scrollReadable = readable; 307 | this.scrollToOffset({offset: 0, animated: false}); 308 | } 309 | if (this.state.isExpectingMore) { 310 | this._pullWhenScrolling( 311 | this.props.initialNumToRender ?? DEFAULT_INITIAL_PULL_AMOUNT, 312 | ); 313 | } 314 | } 315 | 316 | public startPrefixListener(readable?: Readable | null) { 317 | if (this.unmounting) return; 318 | if (!readable) return; 319 | this.prefixReadable = readable; 320 | const that = this; 321 | readable(null, function read(end, item) { 322 | if (end) return; 323 | else if (that.unmounting) { 324 | readable(true, () => {}); 325 | return; 326 | } else if (item) { 327 | that.setState((prev: State) => ({ 328 | data: [item].concat(prev.data as any), 329 | isExpectingMore: prev.isExpectingMore, 330 | updateInt: 1 - prev.updateInt, 331 | refreshing: prev.refreshing, 332 | })); 333 | readable(null, read); 334 | } 335 | }); 336 | } 337 | 338 | public stopScrollListener(cb?: () => void) { 339 | if (this.scrollReadable) { 340 | this.scrollReadable(true, () => {}); 341 | } 342 | 343 | if (this.unmounting) { 344 | cb?.(); 345 | } else { 346 | this.setState( 347 | (prev: State) => ({ 348 | data: [], 349 | isExpectingMore: true, 350 | updateInt: 1 - prev.updateInt, 351 | refreshing: prev.refreshing, 352 | }), 353 | cb, 354 | ); 355 | } 356 | } 357 | 358 | public stopPrefixListener(cb?: () => void) { 359 | if (this.prefixReadable) { 360 | this.prefixReadable(true, () => {}); 361 | } 362 | 363 | if (this.unmounting) { 364 | cb?.(); 365 | } 366 | } 367 | 368 | private onEndReached(info: {distanceFromEnd: number}): void { 369 | if (this.state.isExpectingMore) { 370 | this._pullWhenScrolling(this.props.pullAmount ?? DEFAULT_PULL_AMOUNT); 371 | } 372 | } 373 | 374 | private onRefresh(): void { 375 | if (this.scrollReadable) { 376 | this.scrollReadable(true, () => {}); 377 | } 378 | if (this.unmounting) return; 379 | this.setState((prev: State) => ({ 380 | data: this.retainableRefresh ? prev.data : [], 381 | isExpectingMore: true, 382 | updateInt: 1 - prev.updateInt, 383 | refreshing: true, 384 | })); 385 | this.iteration += 1; 386 | this.isPulling = false; 387 | this.morePullQueue = 0; 388 | if (this.props.getScrollStream) { 389 | this.scrollReadable = this.props.getScrollStream(); 390 | this._pullWhenScrolling( 391 | this.props.initialNumToRender ?? DEFAULT_INITIAL_PULL_AMOUNT, 392 | ); 393 | } 394 | if (this.props.onRefresh) { 395 | this.props.onRefresh(); 396 | } 397 | } 398 | 399 | private _pullWhenScrolling(amount: number): void { 400 | if (this.unmounting) return; 401 | const readable = this.scrollReadable; 402 | if (!readable) return; 403 | if (this.isPulling) { 404 | this.morePullQueue = amount; 405 | return; 406 | } 407 | this.isPulling = true; 408 | const key: (item: T, idx?: number) => any = this.props.keyExtractor as any; 409 | const myIteration = this.iteration; 410 | const that = this; 411 | const buffer: Array = []; 412 | readable(null, function read(end, item) { 413 | if (that.iteration !== myIteration) return; 414 | if (end === true) { 415 | that.props.onPullingComplete?.(); 416 | that._onEndPullingScroll(buffer, false); 417 | } else if (item) { 418 | if (that.unmounting) { 419 | readable(true, () => {}); 420 | return; 421 | } 422 | const idxStored = that.state.data.findIndex( 423 | (x) => key(x) === key(item), 424 | ); 425 | const idxInBuffer = buffer.findIndex((x) => key(x) === key(item)); 426 | 427 | // Consume message 428 | if (!that.retainableRefresh && idxStored >= 0) { 429 | const newData = that.state.data; 430 | newData[idxStored] = item; 431 | that.setState((prev: State) => ({ 432 | data: newData, 433 | isExpectingMore: prev.isExpectingMore, 434 | updateInt: 1 - prev.updateInt, 435 | refreshing: prev.refreshing, 436 | })); 437 | } else if (idxInBuffer >= 0) { 438 | buffer[idxInBuffer] = item; 439 | } else { 440 | buffer.push(item); 441 | } 442 | 443 | // Continue 444 | if (buffer.length >= amount) { 445 | that._onEndPullingScroll(buffer, that.state.isExpectingMore); 446 | } else if (that.state.isExpectingMore) { 447 | readable(null, read); 448 | } 449 | } 450 | }); 451 | } 452 | 453 | private _onEndPullingScroll(buffer: Array, isExpectingMore: boolean) { 454 | if (this.unmounting) return; 455 | this.isPulling = false; 456 | if (!this.initialDone && this.props.onInitialPullDone) { 457 | this.initialDone = true; 458 | this.props.onInitialPullDone(this.state.data.length + buffer.length); 459 | } 460 | this.setState((prev: State) => ({ 461 | data: this.retainableRefresh ? buffer : prev.data.concat(buffer), 462 | isExpectingMore, 463 | updateInt: 1 - prev.updateInt, 464 | refreshing: false, 465 | })); 466 | this.retainableRefresh = false; 467 | const remaining = this.morePullQueue; 468 | if (remaining > 0) { 469 | this.morePullQueue = 0; 470 | this._pullWhenScrolling(remaining); 471 | } 472 | } 473 | 474 | public scrollToOffset(opts: any) { 475 | if (!this.flatListRef) return; 476 | 477 | if (typeof (this.flatListRef as FlatList).scrollToOffset === 'function') { 478 | (this.flatListRef as FlatList).scrollToOffset(opts); 479 | } else if ( 480 | this.flatListRef._component && 481 | typeof this.flatListRef._component.scrollToOffset === 'function' 482 | ) { 483 | this.flatListRef._component.scrollToOffset(opts); 484 | } 485 | } 486 | 487 | public forceRefresh(retain: boolean | undefined) { 488 | this.retainableRefresh = !!retain; 489 | this.onRefresh(); 490 | } 491 | 492 | public render() { 493 | const props = this.props; 494 | const state = this.state; 495 | const isEmpty = 496 | state.data.length === 0 && 497 | !state.isExpectingMore && 498 | !state.refreshing && 499 | !this.isPulling; 500 | const ListFooterComponent = 501 | props.ListFooterComponent && state.isExpectingMore 502 | ? props.ListFooterComponent 503 | : isEmpty 504 | ? props.ListEmptyComponent 505 | : null; 506 | const isLoadingInitial = state.isExpectingMore && state.data.length === 0; 507 | 508 | return createElement(Animated.FlatList, { 509 | onEndReachedThreshold: DEFAULT_END_THRESHOLD, 510 | ...(props as any), 511 | onRefresh: undefined, 512 | ListEmptyComponent: undefined, 513 | ['ref' as any]: (r: any) => { 514 | this.flatListRef = r; 515 | }, 516 | refreshControl: props.refreshable 517 | ? createElement(RefreshControl, { 518 | colors: props.refreshColors ?? ['#000000'], 519 | onRefresh: this._onRefresh, 520 | progressViewOffset: props.progressViewOffset, 521 | refreshing: state.refreshing ?? isLoadingInitial, 522 | }) 523 | : undefined, 524 | data: state.data, 525 | extraData: state.updateInt, 526 | onEndReached: this._onEndReached, 527 | ListFooterComponent, 528 | }); 529 | } 530 | } 531 | 532 | export default PullFlatList; 533 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull-flat-list", 3 | "version": "2.20.0", 4 | "description": "FlatList React Native component capable of scrolling through pull-streams", 5 | "main": "index.js", 6 | "typings": "index.d.ts", 7 | "homepage": "https://github.com/staltz/pull-flat-list", 8 | "scripts": { 9 | "compile": "tsc" 10 | }, 11 | "author": "staltz.com", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@types/react": ">=16.9.0", 15 | "@types/react-native": ">=0.63.0", 16 | "pull-stream": ">=3", 17 | "react": ">=16.9.0", 18 | "react-native": ">=0.63.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "~16.9.18", 22 | "@types/react-native": "~0.63.30", 23 | "typescript": "4.3.x" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "skipLibCheck": true, 8 | "outDir": "./", 9 | "lib": ["es2015", "es2016", "es2017"], 10 | "strict": true 11 | }, 12 | "include": ["index.ts"], 13 | "exclude": ["node_modules"] 14 | } --------------------------------------------------------------------------------