VirtualizedSectionList.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. /**
  2. * Copyright (c) Facebook, Inc. and its affiliates.
  3. *
  4. * This source code is licensed under the MIT license found in the
  5. * LICENSE file in the root directory of this source tree.
  6. *
  7. * @flow
  8. * @format
  9. */
  10. 'use strict';
  11. const React = require('react');
  12. const View = require('../Components/View/View');
  13. const VirtualizedList = require('./VirtualizedList');
  14. const invariant = require('invariant');
  15. import type {ViewToken} from './ViewabilityHelper';
  16. type Item = any;
  17. export type SectionBase<SectionItemT> = {
  18. /**
  19. * The data for rendering items in this section.
  20. */
  21. data: $ReadOnlyArray<SectionItemT>,
  22. /**
  23. * Optional key to keep track of section re-ordering. If you don't plan on re-ordering sections,
  24. * the array index will be used by default.
  25. */
  26. key?: string,
  27. // Optional props will override list-wide props just for this section.
  28. renderItem?: ?(info: {
  29. item: SectionItemT,
  30. index: number,
  31. section: SectionBase<SectionItemT>,
  32. separators: {
  33. highlight: () => void,
  34. unhighlight: () => void,
  35. updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
  36. ...
  37. },
  38. ...
  39. }) => null | React.Element<any>,
  40. ItemSeparatorComponent?: ?React.ComponentType<any>,
  41. keyExtractor?: (item: SectionItemT, index?: ?number) => string,
  42. ...
  43. };
  44. type RequiredProps<SectionT: SectionBase<any>> = {|
  45. sections: $ReadOnlyArray<SectionT>,
  46. |};
  47. type OptionalProps<SectionT: SectionBase<any>> = {|
  48. /**
  49. * Default renderer for every item in every section.
  50. */
  51. renderItem?: (info: {
  52. item: Item,
  53. index: number,
  54. section: SectionT,
  55. separators: {
  56. highlight: () => void,
  57. unhighlight: () => void,
  58. updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
  59. ...
  60. },
  61. ...
  62. }) => null | React.Element<any>,
  63. /**
  64. * Rendered at the top of each section. These stick to the top of the `ScrollView` by default on
  65. * iOS. See `stickySectionHeadersEnabled`.
  66. */
  67. renderSectionHeader?: ?(info: {
  68. section: SectionT,
  69. ...
  70. }) => null | React.Element<any>,
  71. /**
  72. * Rendered at the bottom of each section.
  73. */
  74. renderSectionFooter?: ?(info: {
  75. section: SectionT,
  76. ...
  77. }) => null | React.Element<any>,
  78. /**
  79. * Rendered at the top and bottom of each section (note this is different from
  80. * `ItemSeparatorComponent` which is only rendered between items). These are intended to separate
  81. * sections from the headers above and below and typically have the same highlight response as
  82. * `ItemSeparatorComponent`. Also receives `highlighted`, `[leading/trailing][Item/Separator]`,
  83. * and any custom props from `separators.updateProps`.
  84. */
  85. SectionSeparatorComponent?: ?React.ComponentType<any>,
  86. /**
  87. * Makes section headers stick to the top of the screen until the next one pushes it off. Only
  88. * enabled by default on iOS because that is the platform standard there.
  89. */
  90. stickySectionHeadersEnabled?: boolean,
  91. onEndReached?: ?({distanceFromEnd: number, ...}) => void,
  92. |};
  93. type VirtualizedListProps = React.ElementProps<typeof VirtualizedList>;
  94. export type Props<SectionT> = {|
  95. ...RequiredProps<SectionT>,
  96. ...OptionalProps<SectionT>,
  97. ...$Diff<
  98. VirtualizedListProps,
  99. {renderItem: $PropertyType<VirtualizedListProps, 'renderItem'>, ...},
  100. >,
  101. |};
  102. export type ScrollToLocationParamsType = {|
  103. animated?: ?boolean,
  104. itemIndex: number,
  105. sectionIndex: number,
  106. viewOffset?: number,
  107. viewPosition?: number,
  108. |};
  109. type DefaultProps = {|
  110. ...typeof VirtualizedList.defaultProps,
  111. data: $ReadOnlyArray<Item>,
  112. |};
  113. type State = {childProps: VirtualizedListProps, ...};
  114. /**
  115. * Right now this just flattens everything into one list and uses VirtualizedList under the
  116. * hood. The only operation that might not scale well is concatting the data arrays of all the
  117. * sections when new props are received, which should be plenty fast for up to ~10,000 items.
  118. */
  119. class VirtualizedSectionList<
  120. SectionT: SectionBase<any>,
  121. > extends React.PureComponent<Props<SectionT>, State> {
  122. static defaultProps: DefaultProps = {
  123. ...VirtualizedList.defaultProps,
  124. data: [],
  125. };
  126. scrollToLocation(params: ScrollToLocationParamsType) {
  127. let index = params.itemIndex;
  128. for (let i = 0; i < params.sectionIndex; i++) {
  129. index += this.props.getItemCount(this.props.sections[i].data) + 2;
  130. }
  131. let viewOffset = params.viewOffset || 0;
  132. if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) {
  133. // $FlowFixMe Cannot access private property
  134. const frame = this._listRef._getFrameMetricsApprox(
  135. index - params.itemIndex,
  136. );
  137. viewOffset += frame.length;
  138. }
  139. const toIndexParams = {
  140. ...params,
  141. viewOffset,
  142. index,
  143. };
  144. this._listRef.scrollToIndex(toIndexParams);
  145. }
  146. getListRef(): VirtualizedList {
  147. return this._listRef;
  148. }
  149. constructor(props: Props<SectionT>, context: Object) {
  150. super(props, context);
  151. this.state = this._computeState(props);
  152. }
  153. UNSAFE_componentWillReceiveProps(nextProps: Props<SectionT>) {
  154. this.setState(this._computeState(nextProps));
  155. }
  156. _computeState(props: Props<SectionT>): State {
  157. const offset = props.ListHeaderComponent ? 1 : 0;
  158. const stickyHeaderIndices = [];
  159. const itemCount = props.sections
  160. ? props.sections.reduce((v, section) => {
  161. stickyHeaderIndices.push(v + offset);
  162. return v + props.getItemCount(section.data) + 2; // Add two for the section header and footer.
  163. }, 0)
  164. : 0;
  165. const {
  166. SectionSeparatorComponent,
  167. renderItem,
  168. renderSectionFooter,
  169. renderSectionHeader,
  170. sections: _sections,
  171. stickySectionHeadersEnabled,
  172. ...restProps
  173. } = props;
  174. return {
  175. childProps: {
  176. ...restProps,
  177. renderItem: this._renderItem,
  178. ItemSeparatorComponent: undefined, // Rendered with renderItem
  179. data: props.sections,
  180. getItemCount: () => itemCount,
  181. // $FlowFixMe
  182. getItem: (sections, index) => this._getItem(props, sections, index),
  183. keyExtractor: this._keyExtractor,
  184. onViewableItemsChanged: props.onViewableItemsChanged
  185. ? this._onViewableItemsChanged
  186. : undefined,
  187. stickyHeaderIndices: props.stickySectionHeadersEnabled
  188. ? stickyHeaderIndices
  189. : undefined,
  190. },
  191. };
  192. }
  193. render(): React.Node {
  194. return (
  195. <VirtualizedList {...this.state.childProps} ref={this._captureRef} />
  196. );
  197. }
  198. _getItem = (
  199. props: Props<SectionT>,
  200. sections: ?$ReadOnlyArray<Item>,
  201. index: number,
  202. ): ?Item => {
  203. if (!sections) {
  204. return null;
  205. }
  206. let itemIdx = index - 1;
  207. for (let i = 0; i < sections.length; i++) {
  208. const section = sections[i];
  209. const sectionData = section.data;
  210. const itemCount = props.getItemCount(sectionData);
  211. if (itemIdx === -1 || itemIdx === itemCount) {
  212. // We intend for there to be overflow by one on both ends of the list.
  213. // This will be for headers and footers. When returning a header or footer
  214. // item the section itself is the item.
  215. return section;
  216. } else if (itemIdx < itemCount) {
  217. // If we are in the bounds of the list's data then return the item.
  218. return props.getItem(sectionData, itemIdx);
  219. } else {
  220. itemIdx -= itemCount + 2; // Add two for the header and footer
  221. }
  222. }
  223. return null;
  224. };
  225. _keyExtractor = (item: Item, index: number) => {
  226. const info = this._subExtractor(index);
  227. return (info && info.key) || String(index);
  228. };
  229. _subExtractor(
  230. index: number,
  231. ): ?{
  232. section: SectionT,
  233. // Key of the section or combined key for section + item
  234. key: string,
  235. // Relative index within the section
  236. index: ?number,
  237. // True if this is the section header
  238. header?: ?boolean,
  239. leadingItem?: ?Item,
  240. leadingSection?: ?SectionT,
  241. trailingItem?: ?Item,
  242. trailingSection?: ?SectionT,
  243. ...
  244. } {
  245. let itemIndex = index;
  246. const {getItem, getItemCount, keyExtractor, sections} = this.props;
  247. for (let i = 0; i < sections.length; i++) {
  248. const section = sections[i];
  249. const sectionData = section.data;
  250. const key = section.key || String(i);
  251. itemIndex -= 1; // The section adds an item for the header
  252. if (itemIndex >= getItemCount(sectionData) + 1) {
  253. itemIndex -= getItemCount(sectionData) + 1; // The section adds an item for the footer.
  254. } else if (itemIndex === -1) {
  255. return {
  256. section,
  257. key: key + ':header',
  258. index: null,
  259. header: true,
  260. trailingSection: sections[i + 1],
  261. };
  262. } else if (itemIndex === getItemCount(sectionData)) {
  263. return {
  264. section,
  265. key: key + ':footer',
  266. index: null,
  267. header: false,
  268. trailingSection: sections[i + 1],
  269. };
  270. } else {
  271. const extractor = section.keyExtractor || keyExtractor;
  272. return {
  273. section,
  274. key:
  275. key + ':' + extractor(getItem(sectionData, itemIndex), itemIndex),
  276. index: itemIndex,
  277. leadingItem: getItem(sectionData, itemIndex - 1),
  278. leadingSection: sections[i - 1],
  279. trailingItem: getItem(sectionData, itemIndex + 1),
  280. trailingSection: sections[i + 1],
  281. };
  282. }
  283. }
  284. }
  285. _convertViewable = (viewable: ViewToken): ?ViewToken => {
  286. invariant(viewable.index != null, 'Received a broken ViewToken');
  287. const info = this._subExtractor(viewable.index);
  288. if (!info) {
  289. return null;
  290. }
  291. const keyExtractor = info.section.keyExtractor || this.props.keyExtractor;
  292. return {
  293. ...viewable,
  294. index: info.index,
  295. /* $FlowFixMe(>=0.63.0 site=react_native_fb) This comment suppresses an
  296. * error found when Flow v0.63 was deployed. To see the error delete this
  297. * comment and run Flow. */
  298. key: keyExtractor(viewable.item, info.index),
  299. section: info.section,
  300. };
  301. };
  302. _onViewableItemsChanged = ({
  303. viewableItems,
  304. changed,
  305. }: {
  306. viewableItems: Array<ViewToken>,
  307. changed: Array<ViewToken>,
  308. ...
  309. }) => {
  310. const onViewableItemsChanged = this.props.onViewableItemsChanged;
  311. if (onViewableItemsChanged != null) {
  312. onViewableItemsChanged({
  313. viewableItems: viewableItems
  314. .map(this._convertViewable, this)
  315. .filter(Boolean),
  316. changed: changed.map(this._convertViewable, this).filter(Boolean),
  317. });
  318. }
  319. };
  320. _renderItem = ({item, index}: {item: Item, index: number, ...}) => {
  321. const info = this._subExtractor(index);
  322. if (!info) {
  323. return null;
  324. }
  325. const infoIndex = info.index;
  326. if (infoIndex == null) {
  327. const {section} = info;
  328. if (info.header === true) {
  329. const {renderSectionHeader} = this.props;
  330. return renderSectionHeader ? renderSectionHeader({section}) : null;
  331. } else {
  332. const {renderSectionFooter} = this.props;
  333. return renderSectionFooter ? renderSectionFooter({section}) : null;
  334. }
  335. } else {
  336. const renderItem = info.section.renderItem || this.props.renderItem;
  337. const SeparatorComponent = this._getSeparatorComponent(index, info);
  338. invariant(renderItem, 'no renderItem!');
  339. return (
  340. <ItemWithSeparator
  341. SeparatorComponent={SeparatorComponent}
  342. LeadingSeparatorComponent={
  343. infoIndex === 0 ? this.props.SectionSeparatorComponent : undefined
  344. }
  345. cellKey={info.key}
  346. index={infoIndex}
  347. item={item}
  348. leadingItem={info.leadingItem}
  349. leadingSection={info.leadingSection}
  350. onUpdateSeparator={this._onUpdateSeparator}
  351. prevCellKey={(this._subExtractor(index - 1) || {}).key}
  352. ref={ref => {
  353. this._cellRefs[info.key] = ref;
  354. }}
  355. renderItem={renderItem}
  356. section={info.section}
  357. trailingItem={info.trailingItem}
  358. trailingSection={info.trailingSection}
  359. inverted={!!this.props.inverted}
  360. />
  361. );
  362. }
  363. };
  364. _onUpdateSeparator = (key: string, newProps: Object) => {
  365. const ref = this._cellRefs[key];
  366. ref && ref.updateSeparatorProps(newProps);
  367. };
  368. _getSeparatorComponent(
  369. index: number,
  370. info?: ?Object,
  371. ): ?React.ComponentType<any> {
  372. info = info || this._subExtractor(index);
  373. if (!info) {
  374. return null;
  375. }
  376. const ItemSeparatorComponent =
  377. info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent;
  378. const {SectionSeparatorComponent} = this.props;
  379. const isLastItemInList = index === this.state.childProps.getItemCount() - 1;
  380. const isLastItemInSection =
  381. info.index === this.props.getItemCount(info.section.data) - 1;
  382. if (SectionSeparatorComponent && isLastItemInSection) {
  383. return SectionSeparatorComponent;
  384. }
  385. if (ItemSeparatorComponent && !isLastItemInSection && !isLastItemInList) {
  386. return ItemSeparatorComponent;
  387. }
  388. return null;
  389. }
  390. _cellRefs = {};
  391. _listRef: VirtualizedList;
  392. _captureRef = ref => {
  393. /* $FlowFixMe(>=0.53.0 site=react_native_fb,react_native_oss) This comment
  394. * suppresses an error when upgrading Flow's support for React. To see the
  395. * error delete this comment and run Flow. */
  396. this._listRef = ref;
  397. };
  398. }
  399. type ItemWithSeparatorCommonProps = $ReadOnly<{|
  400. leadingItem: ?Item,
  401. leadingSection: ?Object,
  402. section: Object,
  403. trailingItem: ?Item,
  404. trailingSection: ?Object,
  405. |}>;
  406. type ItemWithSeparatorProps = $ReadOnly<{|
  407. ...ItemWithSeparatorCommonProps,
  408. LeadingSeparatorComponent: ?React.ComponentType<any>,
  409. SeparatorComponent: ?React.ComponentType<any>,
  410. cellKey: string,
  411. index: number,
  412. item: Item,
  413. onUpdateSeparator: (cellKey: string, newProps: Object) => void,
  414. prevCellKey?: ?string,
  415. renderItem: Function,
  416. inverted: boolean,
  417. |}>;
  418. type ItemWithSeparatorState = {
  419. separatorProps: $ReadOnly<{|
  420. highlighted: false,
  421. ...ItemWithSeparatorCommonProps,
  422. |}>,
  423. leadingSeparatorProps: $ReadOnly<{|
  424. highlighted: false,
  425. ...ItemWithSeparatorCommonProps,
  426. |}>,
  427. ...
  428. };
  429. class ItemWithSeparator extends React.Component<
  430. ItemWithSeparatorProps,
  431. ItemWithSeparatorState,
  432. > {
  433. state = {
  434. separatorProps: {
  435. highlighted: false,
  436. leadingItem: this.props.item,
  437. leadingSection: this.props.leadingSection,
  438. section: this.props.section,
  439. trailingItem: this.props.trailingItem,
  440. trailingSection: this.props.trailingSection,
  441. },
  442. leadingSeparatorProps: {
  443. highlighted: false,
  444. leadingItem: this.props.leadingItem,
  445. leadingSection: this.props.leadingSection,
  446. section: this.props.section,
  447. trailingItem: this.props.item,
  448. trailingSection: this.props.trailingSection,
  449. },
  450. };
  451. _separators = {
  452. highlight: () => {
  453. ['leading', 'trailing'].forEach(s =>
  454. this._separators.updateProps(s, {highlighted: true}),
  455. );
  456. },
  457. unhighlight: () => {
  458. ['leading', 'trailing'].forEach(s =>
  459. this._separators.updateProps(s, {highlighted: false}),
  460. );
  461. },
  462. updateProps: (select: 'leading' | 'trailing', newProps: Object) => {
  463. const {LeadingSeparatorComponent, cellKey, prevCellKey} = this.props;
  464. if (select === 'leading' && LeadingSeparatorComponent != null) {
  465. this.setState(state => ({
  466. leadingSeparatorProps: {...state.leadingSeparatorProps, ...newProps},
  467. }));
  468. } else {
  469. this.props.onUpdateSeparator(
  470. (select === 'leading' && prevCellKey) || cellKey,
  471. newProps,
  472. );
  473. }
  474. },
  475. };
  476. static getDerivedStateFromProps(
  477. props: ItemWithSeparatorProps,
  478. prevState: ItemWithSeparatorState,
  479. ): ?ItemWithSeparatorState {
  480. return {
  481. separatorProps: {
  482. ...prevState.separatorProps,
  483. leadingItem: props.item,
  484. leadingSection: props.leadingSection,
  485. section: props.section,
  486. trailingItem: props.trailingItem,
  487. trailingSection: props.trailingSection,
  488. },
  489. leadingSeparatorProps: {
  490. ...prevState.leadingSeparatorProps,
  491. leadingItem: props.leadingItem,
  492. leadingSection: props.leadingSection,
  493. section: props.section,
  494. trailingItem: props.item,
  495. trailingSection: props.trailingSection,
  496. },
  497. };
  498. }
  499. updateSeparatorProps(newProps: Object) {
  500. this.setState(state => ({
  501. separatorProps: {...state.separatorProps, ...newProps},
  502. }));
  503. }
  504. render() {
  505. const {
  506. LeadingSeparatorComponent,
  507. SeparatorComponent,
  508. item,
  509. index,
  510. section,
  511. inverted,
  512. } = this.props;
  513. const element = this.props.renderItem({
  514. item,
  515. index,
  516. section,
  517. separators: this._separators,
  518. });
  519. const leadingSeparator = LeadingSeparatorComponent && (
  520. <LeadingSeparatorComponent {...this.state.leadingSeparatorProps} />
  521. );
  522. const separator = SeparatorComponent && (
  523. <SeparatorComponent {...this.state.separatorProps} />
  524. );
  525. return leadingSeparator || separator ? (
  526. /* $FlowFixMe(>=0.89.0 site=react_native_fb) This comment suppresses an
  527. * error found when Flow v0.89 was deployed. To see the error, delete
  528. * this comment and run Flow. */
  529. <View>
  530. {!inverted ? leadingSeparator : separator}
  531. {element}
  532. {!inverted ? separator : leadingSeparator}
  533. </View>
  534. ) : (
  535. element
  536. );
  537. }
  538. }
  539. module.exports = VirtualizedSectionList;