diff --git a/Examples/UIExplorer/js/SectionListExample.js b/Examples/UIExplorer/js/SectionListExample.js index 6a2a36f91a1aff..441aa19cb0dccd 100644 --- a/Examples/UIExplorer/js/SectionListExample.js +++ b/Examples/UIExplorer/js/SectionListExample.js @@ -79,6 +79,7 @@ class SectionListExample extends React.PureComponent { state = { data: genItemData(1000), + debug: false, filterText: '', logViewable: false, virtualized: true, @@ -96,6 +97,16 @@ class SectionListExample extends React.PureComponent { filterRegex.test(item.text) || filterRegex.test(item.title) ); const filteredData = this.state.data.filter(filter); + const filteredSectionData = []; + let startIndex = 0; + const endIndex = filteredData.length - 1; + for (let ii = 10; ii <= endIndex + 10; ii += 10) { + filteredSectionData.push({ + key: `${filteredData[startIndex].key} - ${filteredData[Math.min(ii - 1, endIndex)].key}`, + data: filteredData.slice(startIndex, ii), + }); + startIndex = ii; + } return ( {renderSmallSwitchOption(this, 'virtualized')} {renderSmallSwitchOption(this, 'logViewable')} + {renderSmallSwitchOption(this, 'debug')} @@ -124,6 +136,7 @@ class SectionListExample extends React.PureComponent { ItemSeparatorComponent={() => } + debug={this.state.debug} enableVirtualization={this.state.virtualized} onRefresh={() => alert('onRefresh: nothing to refresh :P')} onScroll={this._scrollSinkY} @@ -139,7 +152,7 @@ class SectionListExample extends React.PureComponent { {noImage: true, title: '1st item', text: 'Section s2', key: '0'}, {noImage: true, title: '2nd item', text: 'Section s2', key: '1'}, ]}, - {key: 'Filtered Items', data: filteredData}, + ...filteredSectionData, ]} viewabilityConfig={VIEWABILITY_CONFIG} /> diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 0f167228b70d73..bdd50b3dc06c1a 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -383,7 +383,7 @@ const ScrollView = React.createClass({ _scrollAnimatedValue: (new Animated.Value(0): Animated.Value), _scrollAnimatedValueAttachment: (null: ?{detach: () => void}), _stickyHeaderRefs: (new Map(): Map), - + _headerLayoutYs: (new Map(): Map), getInitialState: function() { return this.scrollResponderMixinGetInitialState(); }, @@ -391,6 +391,7 @@ const ScrollView = React.createClass({ componentWillMount: function() { this._scrollAnimatedValue = new Animated.Value(0); this._stickyHeaderRefs = new Map(); + this._headerLayoutYs = new Map(); }, componentDidMount: function() { @@ -482,6 +483,11 @@ const ScrollView = React.createClass({ this.scrollTo({x, y, animated: false}); }, + _getKeyForIndex: function(index, childArray) { + const child = childArray[index]; + return child && child.key; + }, + _updateAnimatedNodeAttachment: function() { if (this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0) { if (!this._scrollAnimatedValueAttachment) { @@ -498,21 +504,34 @@ const ScrollView = React.createClass({ } }, - _setStickyHeaderRef: function(index, ref) { - this._stickyHeaderRefs.set(index, ref); + _setStickyHeaderRef: function(key, ref) { + if (ref) { + this._stickyHeaderRefs.set(key, ref); + } else { + this._stickyHeaderRefs.delete(key); + } }, - _onStickyHeaderLayout: function(index, event) { + _onStickyHeaderLayout: function(index, event, key) { if (!this.props.stickyHeaderIndices) { return; } + const childArray = React.Children.toArray(this.props.children); + if (key !== this._getKeyForIndex(index, childArray)) { + // ignore stale layout update + return; + } - const previousHeaderIndex = this.props.stickyHeaderIndices[ - this.props.stickyHeaderIndices.indexOf(index) - 1 - ]; + const layoutY = event.nativeEvent.layout.y; + this._headerLayoutYs.set(key, layoutY); + + const indexOfIndex = this.props.stickyHeaderIndices.indexOf(index); + const previousHeaderIndex = this.props.stickyHeaderIndices[indexOfIndex - 1]; if (previousHeaderIndex != null) { - const previousHeader = this._stickyHeaderRefs.get(previousHeaderIndex); - previousHeader && previousHeader.setNextHeaderY(event.nativeEvent.layout.y); + const previousHeader = this._stickyHeaderRefs.get( + this._getKeyForIndex(previousHeaderIndex, childArray) + ); + previousHeader && previousHeader.setNextHeaderY(layoutY); } }, @@ -599,27 +618,33 @@ const ScrollView = React.createClass({ }; } - const {stickyHeaderIndices} = this.props; - const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0; - const children = stickyHeaderIndices && hasStickyHeaders ? - React.Children.toArray(this.props.children).map((child, index) => { - const stickyHeaderIndex = stickyHeaderIndices.indexOf(index); - if (child && stickyHeaderIndex >= 0) { - return ( - this._setStickyHeaderRef(index, ref)} - onLayout={(event) => this._onStickyHeaderLayout(index, event)} - scrollAnimatedValue={this._scrollAnimatedValue}> - {child} - - ); - } else { - return child; - } - }) : - this.props.children; - const contentContainer = + const {stickyHeaderIndices} = this.props; + const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0; + const childArray = hasStickyHeaders && React.Children.toArray(this.props.children); + const children = hasStickyHeaders ? + childArray.map((child, index) => { + const indexOfIndex = child ? stickyHeaderIndices.indexOf(index) : -1; + if (indexOfIndex > -1) { + const key = child.key; + const nextIndex = stickyHeaderIndices[indexOfIndex + 1]; + return ( + this._setStickyHeaderRef(key, ref)} + nextHeaderLayoutY={ + this._headerLayoutYs.get(this._getKeyForIndex(nextIndex, childArray)) + } + onLayout={(event) => this._onStickyHeaderLayout(index, event, key)} + scrollAnimatedValue={this._scrollAnimatedValue}> + {child} + + ); + } else { + return child; + } + }) : + this.props.children; + const contentContainer = , - scrollAnimatedValue: Animated.Value, + nextHeaderLayoutY: ?number, onLayout: (event: Object) => void, + scrollAnimatedValue: Animated.Value, }; class ScrollViewStickyHeader extends React.Component { props: Props; - state = { - measured: false, - layoutY: 0, - layoutHeight: 0, - nextHeaderLayoutY: (null: ?number), + state: { + measured: boolean, + layoutY: number, + layoutHeight: number, + nextHeaderLayoutY: ?number, }; + constructor(props: Props, context: Object) { + super(props, context); + this.state = { + measured: false, + layoutY: 0, + layoutHeight: 0, + nextHeaderLayoutY: props.nextHeaderLayoutY, + }; + } + setNextHeaderY(y: number) { this.setState({ nextHeaderLayoutY: y }); } @@ -65,8 +76,10 @@ class ScrollViewStickyHeader extends React.Component { // scroll indefinetly. const inputRange = [-1, 0, layoutY]; const outputRange: Array = [0, 0, 0]; - if (nextHeaderLayoutY != null) { - const collisionPoint = nextHeaderLayoutY - layoutHeight; + // Sometimes headers jump around so we make sure we don't violate the monotonic inputRange + // condition. + const collisionPoint = (nextHeaderLayoutY || 0) - layoutHeight; + if (collisionPoint >= layoutY) { inputRange.push(collisionPoint, collisionPoint + 1); outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY); } else { diff --git a/Libraries/CustomComponents/Lists/SectionList.js b/Libraries/CustomComponents/Lists/SectionList.js index 2bc66a65affe54..71a9dcbca3ed73 100644 --- a/Libraries/CustomComponents/Lists/SectionList.js +++ b/Libraries/CustomComponents/Lists/SectionList.js @@ -33,6 +33,7 @@ 'use strict'; const MetroListView = require('MetroListView'); +const Platform = require('Platform'); const React = require('React'); const VirtualizedSectionList = require('VirtualizedSectionList'); @@ -52,9 +53,7 @@ type SectionBase = { keyExtractor?: (item: SectionItemT) => string, // TODO: support more optional/override props - // FooterComponent?: ?ReactClass<*>, - // HeaderComponent?: ?ReactClass<*>, - // onViewableItemsChanged?: ({viewableItems: Array, changed: Array}) => void, + // onViewableItemsChanged?: ... }; type RequiredProps> = { @@ -102,7 +101,10 @@ type OptionalProps> = { * Called when the viewability of rows changes, as defined by the * `viewabilityConfig` prop. */ - onViewableItemsChanged?: ?(info: {viewableItems: Array, changed: Array}) => void, + onViewableItemsChanged?: ?(info: { + viewableItems: Array, + changed: Array, + }) => void, /** * Set this true while waiting for new data from a refresh. */ @@ -114,13 +116,23 @@ type OptionalProps> = { prevProps: {item: Item, index: number}, nextProps: {item: Item, index: number} ) => boolean, + /** + * Makes section headers stick to the top of the screen until the next one pushes it off. Only + * enabled by default on iOS because that is the platform standard there. + */ + stickySectionHeadersEnabled?: boolean, }; type Props = RequiredProps & OptionalProps & VirtualizedSectionListProps; -type DefaultProps = typeof VirtualizedSectionList.defaultProps; +const defaultProps = { + ...VirtualizedSectionList.defaultProps, + stickySectionHeadersEnabled: Platform.OS === 'ios', +}; + +type DefaultProps = typeof defaultProps; /** * A performant interface for rendering sectioned lists, supporting the most handy features: @@ -136,7 +148,8 @@ type DefaultProps = typeof VirtualizedSectionList.defaultProps; * - Pull to Refresh. * - Scroll loading. * - * If you don't need section support and want a simpler interface, use [``](/react-native/docs/flatlist.html). + * If you don't need section support and want a simpler interface, use + * [``](/react-native/docs/flatlist.html). * * If you need _sticky_ section header support, use `ListView` for now. * @@ -180,7 +193,7 @@ class SectionList> extends React.PureComponent, void> { props: Props; - static defaultProps: DefaultProps = VirtualizedSectionList.defaultProps; + static defaultProps: DefaultProps = defaultProps; render() { const List = this.props.legacyImplementation ? MetroListView : VirtualizedSectionList; diff --git a/Libraries/CustomComponents/Lists/VirtualizedList.js b/Libraries/CustomComponents/Lists/VirtualizedList.js index 62faf5ffbb2101..ffdc7e3b82ed6a 100644 --- a/Libraries/CustomComponents/Lists/VirtualizedList.js +++ b/Libraries/CustomComponents/Lists/VirtualizedList.js @@ -105,7 +105,10 @@ type OptionalProps = { * Called when the viewability of rows changes, as defined by the * `viewabilityConfig` prop. */ - onViewableItemsChanged?: ?(info: {viewableItems: Array, changed: Array}) => void, + onViewableItemsChanged?: ?(info: { + viewableItems: Array, + changed: Array, + }) => void, /** * Set this true while waiting for new data from a refresh. */ @@ -336,21 +339,31 @@ class VirtualizedList extends React.PureComponent { this._updateCellsToRenderBatcher.schedule(); } - _pushCells(cells, first, last) { + _pushCells( + cells: Array, + stickyHeaderIndices: Array, + stickyIndicesFromProps: Set, + first: number, + last: number, + ) { const {ItemSeparatorComponent, data, getItem, getItemCount, keyExtractor} = this.props; + const stickyOffset = this.props.ListHeaderComponent ? 1 : 0; const end = getItemCount(data) - 1; last = Math.min(end, last); for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); invariant(item, 'No item for index ' + ii); const key = keyExtractor(item, ii); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { + stickyHeaderIndices.push(cells.length); + } cells.push( this._onCellLayout(e, key, ii)} onUnmount={this._onCellUnmount} parentProps={this.props} /> @@ -364,6 +377,8 @@ class VirtualizedList extends React.PureComponent { const {ListFooterComponent, ListHeaderComponent} = this.props; const {data, disableVirtualization, horizontal} = this.props; const cells = []; + const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); + const stickyHeaderIndices = []; if (ListHeaderComponent) { cells.push( @@ -374,18 +389,45 @@ class VirtualizedList extends React.PureComponent { const itemCount = this.props.getItemCount(data); if (itemCount > 0) { _usedIndexForKey = false; + const spacerKey = !horizontal ? 'height' : 'width'; const lastInitialIndex = this.props.initialNumToRender - 1; const {first, last} = this.state; - this._pushCells(cells, 0, lastInitialIndex); - if (!disableVirtualization && first > lastInitialIndex) { - const initBlock = this._getFrameMetricsApprox(lastInitialIndex); - const firstSpace = this._getFrameMetricsApprox(first).offset - - (initBlock.offset + initBlock.length); - cells.push( - - ); + this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, 0, lastInitialIndex); + const firstAfterInitial = Math.max(lastInitialIndex + 1, first); + if (!disableVirtualization && first > lastInitialIndex + 1) { + let insertedStickySpacer = false; + if (stickyIndicesFromProps.size > 0) { + const stickyOffset = ListHeaderComponent ? 1 : 0; + // See if there are any sticky headers in the virtualized space that we need to render. + for (let ii = firstAfterInitial - 1; ii > lastInitialIndex; ii--) { + if (stickyIndicesFromProps.has(ii + stickyOffset)) { + const initBlock = this._getFrameMetricsApprox(lastInitialIndex); + const stickyBlock = this._getFrameMetricsApprox(ii); + const leadSpace = stickyBlock.offset - (initBlock.offset + initBlock.length); + cells.push( + + ); + this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, ii, ii); + const trailSpace = this._getFrameMetricsApprox(first).offset - + (stickyBlock.offset + stickyBlock.length); + cells.push( + + ); + insertedStickySpacer = true; + break; + } + } + } + if (!insertedStickySpacer) { + const initBlock = this._getFrameMetricsApprox(lastInitialIndex); + const firstSpace = this._getFrameMetricsApprox(first).offset - + (initBlock.offset + initBlock.length); + cells.push( + + ); + } } - this._pushCells(cells, Math.max(lastInitialIndex + 1, first), last); + this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, firstAfterInitial, last); if (!this._hasWarned.keys && _usedIndexForKey) { console.warn( 'VirtualizedList: missing keys for items, make sure to specify a key property on each ' + @@ -406,7 +448,7 @@ class VirtualizedList extends React.PureComponent { (endFrame.offset + endFrame.length) - (lastFrame.offset + lastFrame.length); cells.push( - + ); } } @@ -426,6 +468,7 @@ class VirtualizedList extends React.PureComponent { onScrollBeginDrag: this._onScrollBeginDrag, ref: this._captureScrollRef, scrollEventThrottle: 50, // TODO: Android support + stickyHeaderIndices, }, cells, ); @@ -460,7 +503,7 @@ class VirtualizedList extends React.PureComponent { this._scrollRef = ref; }; - _onCellLayout = (e, cellKey, index) => { + _onCellLayout(e, cellKey, index) { const layout = e.nativeEvent.layout; const next = { offset: this._selectOffset(layout), @@ -480,8 +523,10 @@ class VirtualizedList extends React.PureComponent { this._frames[cellKey] = next; this._highestMeasuredFrameIndex = Math.max(this._highestMeasuredFrameIndex, index); this._updateCellsToRenderBatcher.schedule(); + } else { + this._frames[cellKey].inLayout = true; } - }; + } _onCellUnmount = (cellKey: string) => { const curr = this._frames[cellKey]; @@ -710,7 +755,7 @@ class CellRenderer extends React.Component { cellKey: string, index: number, item: Item, - onCellLayout: (event: Object, cellKey: string, index: number) => void, + onLayout: (event: Object) => void, // This is extracted by ScrollViewStickyHeader onUnmount: (cellKey: string) => void, parentProps: { renderItem: renderItemType, @@ -721,9 +766,6 @@ class CellRenderer extends React.Component { ) => boolean, }, }; - _onLayout = (e) => { - this.props.onCellLayout(e, this.props.cellKey, this.props.index); - } componentWillUnmount() { this.props.onUnmount(this.props.cellKey); } @@ -740,8 +782,10 @@ class CellRenderer extends React.Component { if (getItemLayout && !parentProps.debug) { return element; } + // NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and + // called explicitly by `ScrollViewStickyHeader`. return ( - + {element} ); diff --git a/Libraries/CustomComponents/Lists/VirtualizedSectionList.js b/Libraries/CustomComponents/Lists/VirtualizedSectionList.js index cd4312e96f4a11..291e4c96db20bc 100644 --- a/Libraries/CustomComponents/Lists/VirtualizedSectionList.js +++ b/Libraries/CustomComponents/Lists/VirtualizedSectionList.js @@ -79,7 +79,7 @@ type OptionalProps = { */ renderItem: ({item: Item, index: number}) => ?React.Element<*>, /** - * Rendered at the top of each section. In the future, a sticky option will be added. + * Rendered at the top of each section. */ renderSectionHeader?: ?({section: SectionT}) => ?React.Element<*>, /** @@ -210,11 +210,6 @@ class VirtualizedSectionList } } - _isItemSticky = (item, index) => { - const info = this._subExtractor(index); - return info && info.index == null; - }; - _renderItem = ({item, index}: {item: Item, index: number}) => { const info = this._subExtractor(index); if (!info) { @@ -263,7 +258,15 @@ class VirtualizedSectionList } _computeState(props: Props): State { - const itemCount = props.sections.reduce((v, section) => v + section.data.length + 1, 0); + const offset = props.ListHeaderComponent ? 1 : 0; + const stickyHeaderIndices = []; + const itemCount = props.sections.reduce( + (v, section) => { + stickyHeaderIndices.push(v + offset); + return v + section.data.length + 1; + }, + 0 + ); return { childProps: { ...props, @@ -272,21 +275,17 @@ class VirtualizedSectionList data: props.sections, getItemCount: () => itemCount, getItem, - isItemSticky: this._isItemSticky, keyExtractor: this._keyExtractor, onViewableItemsChanged: props.onViewableItemsChanged ? this._onViewableItemsChanged : undefined, shouldItemUpdate: this._shouldItemUpdate, + stickyHeaderIndices: props.stickySectionHeadersEnabled ? stickyHeaderIndices : undefined, }, }; } constructor(props: Props, context: Object) { super(props, context); - warning( - !props.stickySectionHeadersEnabled, - 'VirtualizedSectionList: Sticky headers only supported with legacyImplementation for now.' - ); this.state = this._computeState(props); } diff --git a/Libraries/CustomComponents/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/Libraries/CustomComponents/Lists/__tests__/__snapshots__/FlatList-test.js.snap index cafff52235495f..216ae57ab6b3c0 100644 --- a/Libraries/CustomComponents/Lists/__tests__/__snapshots__/FlatList-test.js.snap +++ b/Libraries/CustomComponents/Lists/__tests__/__snapshots__/FlatList-test.js.snap @@ -53,6 +53,7 @@ exports[`FlatList renders all the bells and whistles 1`] = ` renderScrollComponent={[Function]} scrollEventThrottle={50} shouldItemUpdate={[Function]} + stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} windowSize={21} > @@ -145,6 +146,7 @@ exports[`FlatList renders empty list 1`] = ` renderScrollComponent={[Function]} scrollEventThrottle={50} shouldItemUpdate={[Function]} + stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} windowSize={21} > @@ -175,6 +177,7 @@ exports[`FlatList renders null list 1`] = ` renderScrollComponent={[Function]} scrollEventThrottle={50} shouldItemUpdate={[Function]} + stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} windowSize={21} > @@ -217,6 +220,7 @@ exports[`FlatList renders simple list 1`] = ` renderScrollComponent={[Function]} scrollEventThrottle={50} shouldItemUpdate={[Function]} + stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} windowSize={21} > diff --git a/Libraries/CustomComponents/Lists/__tests__/__snapshots__/SectionList-test.js.snap b/Libraries/CustomComponents/Lists/__tests__/__snapshots__/SectionList-test.js.snap index 5b4f27b3dd2f00..e91124aaeb8bd4 100644 --- a/Libraries/CustomComponents/Lists/__tests__/__snapshots__/SectionList-test.js.snap +++ b/Libraries/CustomComponents/Lists/__tests__/__snapshots__/SectionList-test.js.snap @@ -23,7 +23,6 @@ exports[`SectionList rendering empty section headers is fine 1`] = ` getItemCount={[Function]} horizontal={false} initialNumToRender={10} - isItemSticky={[Function]} keyExtractor={[Function]} maxToRenderPerBatch={10} onContentSizeChange={[Function]} @@ -54,6 +53,12 @@ exports[`SectionList rendering empty section headers is fine 1`] = ` ] } shouldItemUpdate={[Function]} + stickyHeaderIndices={ + Array [ + 0, + ] + } + stickySectionHeadersEnabled={true} updateCellsBatchingPeriod={50} windowSize={21} > @@ -123,7 +128,6 @@ exports[`SectionList renders all the bells and whistles 1`] = ` getItemCount={[Function]} horizontal={false} initialNumToRender={10} - isItemSticky={[Function]} keyExtractor={[Function]} maxToRenderPerBatch={10} onContentSizeChange={[Function]} @@ -176,6 +180,13 @@ exports[`SectionList renders all the bells and whistles 1`] = ` ] } shouldItemUpdate={[Function]} + stickyHeaderIndices={ + Array [ + 1, + 4, + ] + } + stickySectionHeadersEnabled={true} updateCellsBatchingPeriod={50} windowSize={21} > @@ -257,7 +268,6 @@ exports[`SectionList renders empty list 1`] = ` getItemCount={[Function]} horizontal={false} initialNumToRender={10} - isItemSticky={[Function]} keyExtractor={[Function]} maxToRenderPerBatch={10} onContentSizeChange={[Function]} @@ -273,6 +283,8 @@ exports[`SectionList renders empty list 1`] = ` scrollEventThrottle={50} sections={Array []} shouldItemUpdate={[Function]} + stickyHeaderIndices={Array []} + stickySectionHeadersEnabled={true} updateCellsBatchingPeriod={50} windowSize={21} >