Skip to content

Instantly share code, notes, and snippets.

@sahrens
Last active May 22, 2020 08:46
Show Gist options
  • Save sahrens/902d49c6c154cd09fafc52a79503728f to your computer and use it in GitHub Desktop.
Save sahrens/902d49c6c154cd09fafc52a79503728f to your computer and use it in GitHub Desktop.

A Better ListView for React Native

I have a proposal for some new APIs, and a first draft on an implementation (first diff: D4412469). Salient points on the new APIs:

  • Two easy-to-use components: FlatList and SectionList. I have found that separating the section API out makes things a lot cleaner for both cases.

  • One shared underlying implementation, VirtualizedList, which has an API similar to react-virtualized and uses getter functions to be very flexible and supports any kind of data structure such as a plain array or an immutable list.

  • Naming adjustments to make it horizontal agnostic, e.g. "Item" instead of "Row".

  • No more DataSource. FlatList just takes an array of data. Easy peasy. keys are determined by an optional keyExtractor function prop that by default looks for a key prop on your data items and falls back to using the index as the key, just like React. SectionList takes an array of section objects, each of which has an items array. No more required rowHasChanged function.

  • Built-in PTR - just set the onRefresh and refreshing props.

  • *Component props instead of render* function props (e.g. FooterComponent vs. renderFooter). This gives more flexibility, encourages reuse of components, and cuts down on boiler plate and possible perf issues in some circumstances. You can still provide a render function like before since they are just functional React components.

  • SectionList is more powerful. In addition to supporting sticky section headers, different sections can have different ItemComponents, so it's much easier to compose sections of heterogeneous components and data.

  • Virtualized by default, so content outside of the render window is unmounted and the memory is reclaimed, allowing scrolling through massive amounts of data without running out of memory and also improves perf in several other ways.

  • Easier to use viewable items API.

  • Reduced implementation complexity because no longer addressing Incremental rendering - assuming React Fiber will fix that problem.

Open Questions:

  • Should we add a missing key warning like React has?
  • Should we pass index to ItemComponents?
  • Should we allow arbitrary recursive nesting of sections? API needs to be nailed down in general.
  • General naming and tweaks are still open for debate, especially Viewable.
  • How smart should we try to make our adaptive/predictive windowing?
  • Can we rely on onLayout or should we try to use RCTScrollViewManager.calculateChildFrames like ListView? I have a perf optimization that should get onLayout useable on both platforms
  • Should we do windowing in terms of component count or pixels or visible lengths?
  • Should we support fixed-height optimizations.
  • What kind of jump-to API's should we support? Should we support them with dynamic heights using black magic (e.g. negative insets)?
  • Should we allow free scrolling through blank content, or prevent scrolling into yet-to-be-rendered areas? Would it be worth making it an option?
  • I'd like to at least support simple grids, including Masonry/Pinterest style layouts - what range of use-cases should we support, and what should that API look like?

Minimal example:

const MyRow = ({item}) => <Text>{item.key}</Text>;

...

<FlatList
  items={[{key: 'a'}, {key: 'b'}]}
  ItemComponent={MyRow}
/>

flow interface:

type Item = any;

type Viewable = {item: Item, key: string, index: ?number, isViewable: boolean, section?: any};


//  #######    FlatList    ######

type RequiredProps = {
  ItemComponent: ReactClass<{item: Item, index: number}>,
  items: ?Array<Item>,
};
type OptionalProps = {
  FooterComponent?: ReactClass<*>,
  SeparatorComponent?: ReactClass<*>,
  /**
   * getItemLength and getItemLayout are optional optimizations that let us skip measurement of
   * dynamic content if you know the height of items a priori. getItemLayout is the most efficient,
   * and is easy to use if you have fixed height items, for example:
   *
   *   getItemLayout={(items, index) => ({length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index})}
   *
   * Remember to include separator height in your offset calculation if you specify
   * `SeparatorComponent`.
   */
  getItemLayout?: (items: any, index: number) => {length: number, offset: number},
  getItemLength?: (items: any, index: number) => number,
  horizontal?: ?boolean,
  /**
   * Used to extract a unique key for a given item at the specified index. Key is used for caching
   * and as the react key to track item re-ordering. The default extractor checks item.key, then
   * falls back to using the index, like react does.
   */
  keyExtractor?: (item: Item, index: number) => string,
  /**
   * The `msg` prop is used in place of ref functions like `scrollTo`. If the `msg` object changes,
   * it will be processed once. A typically pattern is to store it in state, then `setState` with a
   * new `msg` when you want to perform a one-time action, like scroll to a specific offset.
   */
  msg?: ?(
    // scrollToItem may be janky without `getItemLayout` prop. Requires linear scan through items -
    // use scrollToIndex instead if possible.
    {action: 'scrollToItem', animated?: ?boolean, item: Item, viewPosition?: number} |
    // scrollToIndex may be janky without `getItemLayout` prop
    {action: 'scrollToIndex', animated?: ?boolean, index: number, viewPosition?: number} |
    {action: 'scrollToOffset', animated?: ?boolean, offset: number} |
    // scrollToEnd may be janky without `getItemLayout` prop
    {action: 'scrollToEnd', animated?: ?boolean}
  ),
  /**
   * Called once when the scroll position gets within onEndReachedThreshold of the rendered content.
   */
  onEndReached?: ?({distanceFromEnd: number}) => void,
  onEndReachedThreshold?: ?number,
  /**
   * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make
   * sure to also set the `refreshing` prop correctly.
   */
  onRefresh?: ?Function,
  /**
   * Called when the viewability of rows changes, as defined by the
   * `viewablePercentThreshold` prop.
   */
  onViewableItemsChanged?: ?({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
  /**
   * Set this true while waiting for new data from a refresh.
   */
  refreshing?: ?boolean,
  /**
   * Optional optimization to minimize re-rendering items.
   */
  shouldItemUpdate?: ?(
    props: {item: Item, index: number},
    nextProps: {item: Item, index: number}
  ) => boolean,
};
type Props = RequiredProps & OptionalProps; // plus props from the underlying implementation




// #######  SectionList #######


type Section = {
  // Must be provided directly on each section.
  items: ?Array<Item>,
  key: string,

  // Optional props will override list-wide props just for this section.
  FooterComponent?: ?ReactClass<*>,
  HeaderComponent?: ?ReactClass<*>,
  ItemComponent?: ?ReactClass<{item: Item, index: number}>,
  keyExtractor?: (item: Item) => string,
  /**
   * Called when the viewability of rows changes, as defined by the
   * `viewablePercentThreshold` prop.
   */
  onViewableItemsChanged?: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
}

type RequiredProps = {
  sections: Array<Section>,
};
type OptionalProps = {
  ItemComponent?: ?ReactClass<{item: Item, index: number}>,
  SeparatorComponent?: ?ReactClass<*>,
  /**
   * Warning: Virtualization can drastically improve memory consumption for long lists, but trashes
   * the state of items when they scroll out of the render window, so make sure all relavent data is
   * stored outside of the recursive `ItemComponent` instance tree.
   */
  enableVirtualization?: ?boolean,
  keyExtractor?: (item: Item) => string,
  onEndReached?: ({distanceFromEnd: number}) => void,
  /**
   * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make
   * sure to also set the `refreshing` prop correctly.
   */
  onRefresh?: ?Function,
  /**
   * Called when the viewability of rows changes, as defined by the
   * `viewablePercentThreshold` prop.
   */
  onViewableItemsChanged?: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
  /**
   * Set this true while waiting for new data from a refresh.
   */
  refreshing?: boolean,
};
type Props = RequiredProps & OptionalProps;



// ####### VirtualizedList #######


type Item = any;
type ItemComponentType = ReactClass<{item: Item, index: number}>;

type RequiredProps = {
  ItemComponent: ItemComponentType,
  /**
   * The default accessor functions assume this is an Array<{key: string}> but you can override
   * getItem, getItemCount, and keyExtractor to handle any type of index-based data.
   */
  data: any,
}
type OptionalProps = {
  FooterComponent?: ?ReactClass<*>,
  SeparatorComponent?: ?ReactClass<*>,
  /**
   * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully
   * unmounts react instances that are outside of the render window. You should only need to disable
   * this for debugging purposes.
   */
  disableVirtualization: boolean,
  getItem: (items: any, index: number) => ?Item,
  getItemCount: (items: any) => number,
  horizontal: boolean,
  initialNumToRender: number,
  keyExtractor: (item: Item, index: number) => string,
  maxToRenderPerBatch: number,
  onEndReached: ({distanceFromEnd: number}) => void,
  onEndReachedThreshold: number, // units of visible length
  onLayout?: ?Function,
  /**
   * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make
   * sure to also set the `refreshing` prop correctly.
   */
  onRefresh?: ?Function,
  /**
   * Called when the viewability of rows changes, as defined by the
   * `viewablePercentThreshold` prop.
   */
  onViewableItemsChanged?: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
  /**
   * Set this true while waiting for new data from a refresh.
   */
  refreshing?: boolean,
  renderScrollComponent: (props: Object) => React.Element<*>,
  shouldItemUpdate: (
    props: {item: Item, index: number},
    nextProps: {item: Item, index: number}
  ) => boolean,
  updateCellsBatchingPeriod: number,
  /**
   * Percent of viewport that must be covered for a partially occluded item to count as
   * "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means
   * that a single pixel in the viewport makes the item viewable, and a value of 100 means that
   * an item must be either entirely visible or cover the entire viewport to count as viewable.
   */
  viewablePercentThreshold: number,
  windowSize: number, // units of visible length
};
type Props = RequiredProps & OptionalProps;
@brentvatne
Copy link

Hey @sahrens!

This is super exciting! I hope that "Reduced implementation complexity because no longer addressing Incremental rendering - assuming React Fiber will fix that problem" means we're at least somewhat close to integrating Fiber into React Native :) Everything in your proposal sounds nice to me, I responded to your open questions below, hopefully it's kind of helpful.

Open questions

Should we add a missing key warning like React has?

I think this is valuable -- it can really trip people up when you use the array index for keys because if you remove an item then everything after it now has a new key.

Should we pass index to ItemComponents?

I believe so, yes. Often useful eg: to know if first/last row, to zebra stripe, etc.

Should we allow arbitrary recursive nesting of sections? API needs to be nailed down in general.

Do you have an example in mind where this would be useful? I can't think of any off of the top of my head.

General naming and tweaks are still open for debate, especially Viewable.

Everything seems fine to me at first glance except Viewable which I'm a bit confused about.

How smart should we try to make our adaptive/predictive windowing?

A couple of thoughts:

  • It should factor in the scroll direction so it renders more ahead of the current scroll direction than behind
  • It should pass in a prop to indicate whether it is on screen or being 'predictevly' (for lack of a better word) rendered
    • Is there some way to make Fiber aware of the list view's priorities wrt. to visibility of items?

Can we rely on onLayout or should we try to use RCTScrollViewManager.calculateChildFrames like ListView? I have a perf optimization that should get onLayout useable on both platforms

imo onLayout seems ideal because it's how you'd write other components with React Native, but I don't feel strongly about this.

Should we do windowing in terms of component count or pixels or visible lengths?

No strong opinion about this either way, as long as the window size is customizable.

A third option might even be useful: how many screens tall should the window be. Awful name but let's say it's windowSize, so windowSize={{ahead: 2, behind: 1}} would render two screens of content ahead of the direction you're scrolling and 1 behind, or possibly windowSize={4} and have the listview determine how to distribute that. With pixels you probably will just multiply it by the device height anyhow.

Should we support fixed-height optimizations.

I think this is valuable, perhaps as third and forth (FlatListFixedHeight and SectionListFixedHeight) types of listviews to keep the other implementations clean.

What kind of jump-to API's should we support? Should we support them with dynamic heights using black magic (e.g. negative insets)?

Not familiar with the black magic required for this but I think as long as it's doable, jump-to is common enough that this would valuable.

Should we allow free scrolling through blank content, or prevent scrolling into yet-to-be-rendered areas? Would it be worth making it an option?

If we pass a prop into rows to let them know that they are being predictively rendered, ideally we won't run into blank content because you can render a minimal placeholder item to ensure smooth scrolling. I think this viewable or is that something else?

How would we prevent scrolling? If we keep momentum but freeze the main thread (:P), that would feel most like native. It's always weird when you hit the bottom of a list view in React Native and scrolling stops, because you don't know that you can scroll more.

I'd like to at least support simple grids, including Masonry/Pinterest style
layouts - what range of use-cases should we support, and what should that API
look like?

Simple grid would be useful!

For pinterest style layout we'd need to aboslute position which seems pretty hard to deal with some edge cases eg: what happens when the height of one row grows? Also, do we support variable widths? It may be best to leave this to a third party lib, I suspect we'd run into a bunch of edge cases.

Minimal example / flow interface:

  • Small typo I think, missing } to close key: 'a'
  • Unclear to me what isViewable is -- does this just mean that it's renderable?

@ide
Copy link

ide commented Jan 28, 2017

Agree that fixed-height lists would be useful. Often the fixed heights are small (think contacts in an address book) compared to those of variable-height stories in a news feed, so optimizing for many compact rows on the screen at once is helpful.

@ide
Copy link

ide commented Jan 28, 2017

About nested sections -- would this be rendered differently than simply flattening the sections before passing them into the SectionList? If it's not necessary or especially helpful for SectionList to support this I'd be in favor of delegating section-flattening to user space or a separate npm package.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment