// @flow import memoizeOne from 'memoize-one'; import { createElement, PureComponent } from 'react'; import { cancelTimeout, requestTimeout } from './timer'; import { getScrollbarSize, getRTLOffsetType } from './domHelpers'; import type { TimeoutID } from './timer'; export type ScrollToAlign = 'auto' | 'smart' | 'center' | 'start' | 'end'; type itemSize = number | ((index: number) => number); // TODO Deprecate directions "horizontal" and "vertical" type Direction = 'ltr' | 'rtl' | 'horizontal' | 'vertical'; type Layout = 'horizontal' | 'vertical'; type RenderComponentProps = {| data: T, index: number, isScrolling?: boolean, style: Object, |}; type RenderComponent = React$ComponentType<$Shape>>; type ScrollDirection = 'forward' | 'backward'; type onItemsRenderedCallback = ({ overscanStartIndex: number, overscanStopIndex: number, visibleStartIndex: number, visibleStopIndex: number, }) => void; type onScrollCallback = ({ scrollDirection: ScrollDirection, scrollOffset: number, scrollUpdateWasRequested: boolean, }) => void; type ScrollEvent = SyntheticEvent; type ItemStyleCache = { [index: number]: Object }; type OuterProps = {| children: React$Node, className: string | void, onScroll: ScrollEvent => void, style: { [string]: mixed, }, |}; type InnerProps = {| children: React$Node, style: { [string]: mixed, }, |}; export type Props = {| children: RenderComponent, className?: string, direction: Direction, height: number | string, initialScrollOffset?: number, innerRef?: any, innerElementType?: string | React$AbstractComponent, innerTagName?: string, // deprecated itemCount: number, itemData: T, itemKey?: (index: number, data: T) => any, itemSize: itemSize, layout: Layout, onItemsRendered?: onItemsRenderedCallback, onScroll?: onScrollCallback, outerRef?: any, outerElementType?: string | React$AbstractComponent, outerTagName?: string, // deprecated overscanCount: number, style?: Object, useIsScrolling: boolean, width: number | string, |}; type State = {| instance: any, isScrolling: boolean, scrollDirection: ScrollDirection, scrollOffset: number, scrollUpdateWasRequested: boolean, |}; type GetItemOffset = ( props: Props, index: number, instanceProps: any ) => number; type GetItemSize = ( props: Props, index: number, instanceProps: any ) => number; type GetEstimatedTotalSize = (props: Props, instanceProps: any) => number; type GetOffsetForIndexAndAlignment = ( props: Props, index: number, align: ScrollToAlign, scrollOffset: number, instanceProps: any ) => number; type GetStartIndexForOffset = ( props: Props, offset: number, instanceProps: any ) => number; type GetStopIndexForStartIndex = ( props: Props, startIndex: number, scrollOffset: number, instanceProps: any ) => number; type InitInstanceProps = (props: Props, instance: any) => any; type ValidateProps = (props: Props) => void; const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; const defaultItemKey = (index: number, data: any) => index; // In DEV mode, this Set helps us only log a warning once per component instance. // This avoids spamming the console every time a render happens. let devWarningsDirection = null; let devWarningsTagName = null; if (process.env.NODE_ENV !== 'production') { if (typeof window !== 'undefined' && typeof window.WeakSet !== 'undefined') { devWarningsDirection = new WeakSet(); devWarningsTagName = new WeakSet(); } } export default function createListComponent({ getItemOffset, getEstimatedTotalSize, getItemSize, getOffsetForIndexAndAlignment, getStartIndexForOffset, getStopIndexForStartIndex, initInstanceProps, shouldResetStyleCacheOnItemSizeChange, validateProps, }: {| getItemOffset: GetItemOffset, getEstimatedTotalSize: GetEstimatedTotalSize, getItemSize: GetItemSize, getOffsetForIndexAndAlignment: GetOffsetForIndexAndAlignment, getStartIndexForOffset: GetStartIndexForOffset, getStopIndexForStartIndex: GetStopIndexForStartIndex, initInstanceProps: InitInstanceProps, shouldResetStyleCacheOnItemSizeChange: boolean, validateProps: ValidateProps, |}) { return class List extends PureComponent, State> { _instanceProps: any = initInstanceProps(this.props, this); _outerRef: ?HTMLDivElement; _resetIsScrollingTimeoutId: TimeoutID | null = null; static defaultProps = { direction: 'ltr', itemData: undefined, layout: 'vertical', overscanCount: 2, useIsScrolling: false, }; state: State = { instance: this, isScrolling: false, scrollDirection: 'forward', scrollOffset: typeof this.props.initialScrollOffset === 'number' ? this.props.initialScrollOffset : 0, scrollUpdateWasRequested: false, }; // Always use explicit constructor for React components. // It produces less code after transpilation. (#26) // eslint-disable-next-line no-useless-constructor constructor(props: Props) { super(props); } static getDerivedStateFromProps( nextProps: Props, prevState: State ): $Shape | null { validateSharedProps(nextProps, prevState); validateProps(nextProps); return null; } scrollTo(scrollOffset: number): void { scrollOffset = Math.max(0, scrollOffset); this.setState(prevState => { if (prevState.scrollOffset === scrollOffset) { return null; } return { scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward', scrollOffset: scrollOffset, scrollUpdateWasRequested: true, }; }, this._resetIsScrollingDebounced); } scrollToItem(index: number, align: ScrollToAlign = 'auto'): void { const { itemCount, layout } = this.props; const { scrollOffset } = this.state; index = Math.max(0, Math.min(index, itemCount - 1)); // The scrollbar size should be considered when scrolling an item into view, to ensure it's fully visible. // But we only need to account for its size when it's actually visible. // This is an edge case for lists; normally they only scroll in the dominant direction. let scrollbarSize = 0; if (this._outerRef) { const outerRef = ((this._outerRef: any): HTMLElement); if (layout === 'vertical') { scrollbarSize = outerRef.scrollWidth > outerRef.clientWidth ? getScrollbarSize() : 0; } else { scrollbarSize = outerRef.scrollHeight > outerRef.clientHeight ? getScrollbarSize() : 0; } } this.scrollTo( getOffsetForIndexAndAlignment( this.props, index, align, scrollOffset, this._instanceProps, scrollbarSize ) ); } componentDidMount() { const { direction, initialScrollOffset, layout } = this.props; if (typeof initialScrollOffset === 'number' && this._outerRef != null) { const outerRef = ((this._outerRef: any): HTMLElement); // TODO Deprecate direction "horizontal" if (direction === 'horizontal' || layout === 'horizontal') { outerRef.scrollLeft = initialScrollOffset; } else { outerRef.scrollTop = initialScrollOffset; } } this._callPropsCallbacks(); } componentDidUpdate() { const { direction, layout } = this.props; const { scrollOffset, scrollUpdateWasRequested } = this.state; if (scrollUpdateWasRequested && this._outerRef != null) { const outerRef = ((this._outerRef: any): HTMLElement); // TODO Deprecate direction "horizontal" if (direction === 'horizontal' || layout === 'horizontal') { if (direction === 'rtl') { // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. // This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left). // So we need to determine which browser behavior we're dealing with, and mimic it. switch (getRTLOffsetType()) { case 'negative': outerRef.scrollLeft = -scrollOffset; break; case 'positive-ascending': outerRef.scrollLeft = scrollOffset; break; default: const { clientWidth, scrollWidth } = outerRef; outerRef.scrollLeft = scrollWidth - clientWidth - scrollOffset; break; } } else { outerRef.scrollLeft = scrollOffset; } } else { outerRef.scrollTop = scrollOffset; } } this._callPropsCallbacks(); } componentWillUnmount() { if (this._resetIsScrollingTimeoutId !== null) { cancelTimeout(this._resetIsScrollingTimeoutId); } } render() { const { children, className, direction, height, innerRef, innerElementType, innerTagName, itemCount, itemData, itemKey = defaultItemKey, layout, outerElementType, outerTagName, style, useIsScrolling, width, } = this.props; const { isScrolling } = this.state; // TODO Deprecate direction "horizontal" const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; const onScroll = isHorizontal ? this._onScrollHorizontal : this._onScrollVertical; const [startIndex, stopIndex] = this._getRangeToRender(); const items = []; if (itemCount > 0) { for (let index = startIndex; index <= stopIndex; index++) { items.push( createElement(children, { data: itemData, key: itemKey(index, itemData), index, isScrolling: useIsScrolling ? isScrolling : undefined, style: this._getItemStyle(index), }) ); } } // Read this value AFTER items have been created, // So their actual sizes (if variable) are taken into consideration. const estimatedTotalSize = getEstimatedTotalSize( this.props, this._instanceProps ); return createElement( outerElementType || outerTagName || 'div', { className, onScroll, ref: this._outerRefSetter, style: { position: 'relative', height, width, overflow: 'auto', WebkitOverflowScrolling: 'touch', willChange: 'transform', direction, ...style, }, }, createElement(innerElementType || innerTagName || 'div', { children: items, ref: innerRef, style: { height: isHorizontal ? '100%' : estimatedTotalSize, pointerEvents: isScrolling ? 'none' : undefined, width: isHorizontal ? estimatedTotalSize : '100%', }, }) ); } _callOnItemsRendered: ( overscanStartIndex: number, overscanStopIndex: number, visibleStartIndex: number, visibleStopIndex: number ) => void; _callOnItemsRendered = memoizeOne( ( overscanStartIndex: number, overscanStopIndex: number, visibleStartIndex: number, visibleStopIndex: number ) => ((this.props.onItemsRendered: any): onItemsRenderedCallback)({ overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex, }) ); _callOnScroll: ( scrollDirection: ScrollDirection, scrollOffset: number, scrollUpdateWasRequested: boolean ) => void; _callOnScroll = memoizeOne( ( scrollDirection: ScrollDirection, scrollOffset: number, scrollUpdateWasRequested: boolean ) => ((this.props.onScroll: any): onScrollCallback)({ scrollDirection, scrollOffset, scrollUpdateWasRequested, }) ); _callPropsCallbacks() { if (typeof this.props.onItemsRendered === 'function') { const { itemCount } = this.props; if (itemCount > 0) { const [ overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex, ] = this._getRangeToRender(); this._callOnItemsRendered( overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex ); } } if (typeof this.props.onScroll === 'function') { const { scrollDirection, scrollOffset, scrollUpdateWasRequested, } = this.state; this._callOnScroll( scrollDirection, scrollOffset, scrollUpdateWasRequested ); } } // Lazily create and cache item styles while scrolling, // So that pure component sCU will prevent re-renders. // We maintain this cache, and pass a style prop rather than index, // So that List can clear cached styles and force item re-render if necessary. _getItemStyle: (index: number) => Object; _getItemStyle = (index: number): Object => { const { direction, itemSize, layout } = this.props; const itemStyleCache = this._getItemStyleCache( shouldResetStyleCacheOnItemSizeChange && itemSize, shouldResetStyleCacheOnItemSizeChange && layout, shouldResetStyleCacheOnItemSizeChange && direction ); let style; if (itemStyleCache.hasOwnProperty(index)) { style = itemStyleCache[index]; } else { const offset = getItemOffset(this.props, index, this._instanceProps); const size = getItemSize(this.props, index, this._instanceProps); // TODO Deprecate direction "horizontal" const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; const isRtl = direction === 'rtl'; const offsetHorizontal = isHorizontal ? offset : 0; itemStyleCache[index] = style = { position: 'absolute', left: isRtl ? undefined : offsetHorizontal, right: isRtl ? offsetHorizontal : undefined, top: !isHorizontal ? offset : 0, height: !isHorizontal ? size : '100%', width: isHorizontal ? size : '100%', }; } return style; }; _getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache; _getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => ({})); _getRangeToRender(): [number, number, number, number] { const { itemCount, overscanCount } = this.props; const { isScrolling, scrollDirection, scrollOffset } = this.state; if (itemCount === 0) { return [0, 0, 0, 0]; } const startIndex = getStartIndexForOffset( this.props, scrollOffset, this._instanceProps ); const stopIndex = getStopIndexForStartIndex( this.props, startIndex, scrollOffset, this._instanceProps ); // Overscan by one item in each direction so that tab/focus works. // If there isn't at least one extra item, tab loops back around. const overscanBackward = !isScrolling || scrollDirection === 'backward' ? Math.max(1, overscanCount) : 1; const overscanForward = !isScrolling || scrollDirection === 'forward' ? Math.max(1, overscanCount) : 1; return [ Math.max(0, startIndex - overscanBackward), Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)), startIndex, stopIndex, ]; } _onScrollHorizontal = (event: ScrollEvent): void => { const { clientWidth, scrollLeft, scrollWidth } = event.currentTarget; this.setState(prevState => { if (prevState.scrollOffset === scrollLeft) { // Scroll position may have been updated by cDM/cDU, // In which case we don't need to trigger another render, // And we don't want to update state.isScrolling. return null; } const { direction } = this.props; let scrollOffset = scrollLeft; if (direction === 'rtl') { // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. // This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left). // It's also easier for this component if we convert offsets to the same format as they would be in for ltr. // So the simplest solution is to determine which browser behavior we're dealing with, and convert based on it. switch (getRTLOffsetType()) { case 'negative': scrollOffset = -scrollLeft; break; case 'positive-descending': scrollOffset = scrollWidth - clientWidth - scrollLeft; break; } } // Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds. scrollOffset = Math.max( 0, Math.min(scrollOffset, scrollWidth - clientWidth) ); return { isScrolling: true, scrollDirection: prevState.scrollOffset < scrollLeft ? 'forward' : 'backward', scrollOffset, scrollUpdateWasRequested: false, }; }, this._resetIsScrollingDebounced); }; _onScrollVertical = (event: ScrollEvent): void => { const { clientHeight, scrollHeight, scrollTop } = event.currentTarget; this.setState(prevState => { if (prevState.scrollOffset === scrollTop) { // Scroll position may have been updated by cDM/cDU, // In which case we don't need to trigger another render, // And we don't want to update state.isScrolling. return null; } // Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds. const scrollOffset = Math.max( 0, Math.min(scrollTop, scrollHeight - clientHeight) ); return { isScrolling: true, scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward', scrollOffset, scrollUpdateWasRequested: false, }; }, this._resetIsScrollingDebounced); }; _outerRefSetter = (ref: any): void => { const { outerRef } = this.props; this._outerRef = ((ref: any): HTMLDivElement); if (typeof outerRef === 'function') { outerRef(ref); } else if ( outerRef != null && typeof outerRef === 'object' && outerRef.hasOwnProperty('current') ) { outerRef.current = ref; } }; _resetIsScrollingDebounced = () => { if (this._resetIsScrollingTimeoutId !== null) { cancelTimeout(this._resetIsScrollingTimeoutId); } this._resetIsScrollingTimeoutId = requestTimeout( this._resetIsScrolling, IS_SCROLLING_DEBOUNCE_INTERVAL ); }; _resetIsScrolling = () => { this._resetIsScrollingTimeoutId = null; this.setState({ isScrolling: false }, () => { // Clear style cache after state update has been committed. // This way we don't break pure sCU for items that don't use isScrolling param. this._getItemStyleCache(-1, null); }); }; }; } // NOTE: I considered further wrapping individual items with a pure ListItem component. // This would avoid ever calling the render function for the same index more than once, // But it would also add the overhead of a lot of components/fibers. // I assume people already do this (render function returning a class component), // So my doing it would just unnecessarily double the wrappers. const validateSharedProps = ( { children, direction, height, layout, innerTagName, outerTagName, width, }: Props, { instance }: State ): void => { if (process.env.NODE_ENV !== 'production') { if (innerTagName != null || outerTagName != null) { if (devWarningsTagName && !devWarningsTagName.has(instance)) { devWarningsTagName.add(instance); console.warn( 'The innerTagName and outerTagName props have been deprecated. ' + 'Please use the innerElementType and outerElementType props instead.' ); } } // TODO Deprecate direction "horizontal" const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; switch (direction) { case 'horizontal': case 'vertical': if (devWarningsDirection && !devWarningsDirection.has(instance)) { devWarningsDirection.add(instance); console.warn( 'The direction prop should be either "ltr" (default) or "rtl". ' + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' ); } break; case 'ltr': case 'rtl': // Valid values break; default: throw Error( 'An invalid "direction" prop has been specified. ' + 'Value should be either "ltr" or "rtl". ' + `"${direction}" was specified.` ); } switch (layout) { case 'horizontal': case 'vertical': // Valid values break; default: throw Error( 'An invalid "layout" prop has been specified. ' + 'Value should be either "horizontal" or "vertical". ' + `"${layout}" was specified.` ); } if (children == null) { throw Error( 'An invalid "children" prop has been specified. ' + 'Value should be a React component. ' + `"${children === null ? 'null' : typeof children}" was specified.` ); } if (isHorizontal && typeof width !== 'number') { throw Error( 'An invalid "width" prop has been specified. ' + 'Horizontal lists must specify a number for width. ' + `"${width === null ? 'null' : typeof width}" was specified.` ); } else if (!isHorizontal && typeof height !== 'number') { throw Error( 'An invalid "height" prop has been specified. ' + 'Vertical lists must specify a number for height. ' + `"${height === null ? 'null' : typeof height}" was specified.` ); } } };