0% found this document useful (0 votes)
9K views13 pages

React Scrollable List Component

This document defines the types and functions used to create a list component in React. It includes prop types for the component, state types, and callback function types for measuring item sizes, positions, and scroll offsets. The exported component class renders list items by calling child functions and manages scrolling state.

Uploaded by

mahoraga
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
9K views13 pages

React Scrollable List Component

This document defines the types and functions used to create a list component in React. It includes prop types for the component, state types, and callback function types for measuring item sizes, positions, and scroll offsets. The exported component class renders list items by calling child functions and manages scrolling state.

Uploaded by

mahoraga
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd

// @flow

import memoizeOne from 'memoize-one';


import * as React from 'react';
import { createElement, PureComponent } from 'react';
import { cancelTimeout, requestTimeout } from './timer';
import { 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<T> = {|
data: T,
index: number,
isScrolling?: boolean,
style: Object,
|};
type RenderComponent<T> = React$ComponentType<$Shape<RenderComponentProps<T>>>;

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<HTMLDivElement>;


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<T> = {|


children: RenderComponent<T>,
className?: string,
direction: Direction,
height: number | string,
initialScrollOffset?: number,
innerRef?: any,
innerElementType?: string | [Link]<InnerProps, any>,
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 | [Link]<OuterProps, any>,
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<any>,
index: number,
instanceProps: any
) => number;
type GetItemSize = (
props: Props<any>,
index: number,
instanceProps: any
) => number;
type GetEstimatedTotalSize = (props: Props<any>, instanceProps: any) => number;
type GetOffsetForIndexAndAlignment = (
props: Props<any>,
index: number,
align: ScrollToAlign,
scrollOffset: number,
instanceProps: any
) => number;
type GetStartIndexForOffset = (
props: Props<any>,
offset: number,
instanceProps: any
) => number;
type GetStopIndexForStartIndex = (
props: Props<any>,
startIndex: number,
scrollOffset: number,
instanceProps: any
) => number;
type InitInstanceProps = (props: Props<any>, instance: any) => any;
type ValidateProps = (props: Props<any>) => 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 ([Link].NODE_ENV !== 'production') {
if (typeof window !== 'undefined' && typeof [Link] !== '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,
|}): [Link]<Props<$FlowFixMe>> {
return class List<T> extends PureComponent<Props<T>, State> {
_instanceProps: any = initInstanceProps([Link], this);
_outerRef: ?HTMLDivElement;
_resetIsScrollingTimeoutId: TimeoutID | null = null;

static defaultProps: {
direction: string,
itemData: void,
layout: string,
overscanCount: number,
useIsScrolling: boolean,
} = {
direction: 'ltr',
itemData: undefined,
layout: 'vertical',
overscanCount: 2,
useIsScrolling: false,
};
state: State = {
instance: this,
isScrolling: false,
scrollDirection: 'forward',
scrollOffset:
typeof [Link] === 'number'
? [Link]
: 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<T>) {
super(props);
}

static getDerivedStateFromProps(
nextProps: Props<T>,
prevState: State
): $Shape<State> | null {
validateSharedProps(nextProps, prevState);
validateProps(nextProps);
return null;
}

scrollTo(scrollOffset: number): void {


scrollOffset = [Link](0, scrollOffset);

[Link](prevState => {
if ([Link] === scrollOffset) {
return null;
}
return {
scrollDirection:
[Link] < scrollOffset ? 'forward' : 'backward',
scrollOffset: scrollOffset,
scrollUpdateWasRequested: true,
};
}, this._resetIsScrollingDebounced);
}

scrollToItem(index: number, align: ScrollToAlign = 'auto'): void {


const { itemCount } = [Link];
const { scrollOffset } = [Link];

index = [Link](0, [Link](index, itemCount - 1));

[Link](
getOffsetForIndexAndAlignment(
[Link],
index,
align,
scrollOffset,
this._instanceProps
)
);
}

componentDidMount() {
const { direction, initialScrollOffset, layout } = [Link];

if (typeof initialScrollOffset === 'number' && this._outerRef != null) {


const outerRef = ((this._outerRef: any): HTMLElement);
// TODO Deprecate direction "horizontal"
if (direction === 'horizontal' || layout === 'horizontal') {
[Link] = initialScrollOffset;
} else {
[Link] = initialScrollOffset;
}
}

this._callPropsCallbacks();
}

componentDidUpdate() {
const { direction, layout } = [Link];
const { scrollOffset, scrollUpdateWasRequested } = [Link];

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':
[Link] = -scrollOffset;
break;
case 'positive-ascending':
[Link] = scrollOffset;
break;
default:
const { clientWidth, scrollWidth } = outerRef;
[Link] = scrollWidth - clientWidth - scrollOffset;
break;
}
} else {
[Link] = scrollOffset;
}
} else {
[Link] = scrollOffset;
}
}

this._callPropsCallbacks();
}

componentWillUnmount() {
if (this._resetIsScrollingTimeoutId !== null) {
cancelTimeout(this._resetIsScrollingTimeoutId);
}
}

render(): any {
const {
children,
className,
direction,
height,
innerRef,
innerElementType,
innerTagName,
itemCount,
itemData,
itemKey = defaultItemKey,
layout,
outerElementType,
outerTagName,
style,
useIsScrolling,
width,
} = [Link];
const { isScrolling } = [Link];

// 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++) {
[Link](
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(
[Link],
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) = memoizeOne(
(
overscanStartIndex: number,
overscanStopIndex: number,
visibleStartIndex: number,
visibleStopIndex: number
) =>
(([Link]: any): onItemsRenderedCallback)({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
})
);

_callOnScroll: ((
scrollDirection: ScrollDirection,
scrollOffset: number,
scrollUpdateWasRequested: boolean
) => void) = memoizeOne(
(
scrollDirection: ScrollDirection,
scrollOffset: number,
scrollUpdateWasRequested: boolean
) =>
(([Link]: any): onScrollCallback)({
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
})
);

_callPropsCallbacks() {
if (typeof [Link] === 'function') {
const { itemCount } = [Link];
if (itemCount > 0) {
const [
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
] = this._getRangeToRender();
this._callOnItemsRendered(
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
);
}
}

if (typeof [Link] === 'function') {


const {
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
} = [Link];
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 => {
const { direction, itemSize, layout } = [Link];

const itemStyleCache = this._getItemStyleCache(


shouldResetStyleCacheOnItemSizeChange && itemSize,
shouldResetStyleCacheOnItemSizeChange && layout,
shouldResetStyleCacheOnItemSizeChange && direction
);

let style;
if ([Link](index)) {
style = itemStyleCache[index];
} else {
const offset = getItemOffset([Link], index, this._instanceProps);
const size = getItemSize([Link], index, this._instanceProps);

// TODO Deprecate direction "horizontal"


const isHorizontal =
direction === 'horizontal' || layout === 'horizontal';

itemStyleCache[index] = style = {
position: 'absolute',
// $FlowFixMe computed properties are unsupported
[direction === 'rtl' ? 'right' : 'left']: isHorizontal ? offset : 0,
top: !isHorizontal ? offset : 0,
height: !isHorizontal ? size : '100%',
width: isHorizontal ? size : '100%',
};
}

return style;
};

_getItemStyleCache: ((_: any, __: any, ___: any) => ItemStyleCache) =


memoizeOne((_: any, __: any, ___: any) => ({}));

_getRangeToRender(): [number, number, number, number] {


const { itemCount, overscanCount } = [Link];
const { isScrolling, scrollDirection, scrollOffset } = [Link];

if (itemCount === 0) {
return [0, 0, 0, 0];
}

const startIndex = getStartIndexForOffset(


[Link],
scrollOffset,
this._instanceProps
);
const stopIndex = getStopIndexForStartIndex(
[Link],
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'
? [Link](1, overscanCount)
: 1;
const overscanForward =
!isScrolling || scrollDirection === 'forward'
? [Link](1, overscanCount)
: 1;

return [
[Link](0, startIndex - overscanBackward),
[Link](0, [Link](itemCount - 1, stopIndex + overscanForward)),
startIndex,
stopIndex,
];
}

_onScrollHorizontal = (event: ScrollEvent): void => {


const { clientWidth, scrollLeft, scrollWidth } = [Link];
[Link](prevState => {
if ([Link] === 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 [Link].
return null;
}

const { direction } = [Link];

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 = [Link](
0,
[Link](scrollOffset, scrollWidth - clientWidth)
);

return {
isScrolling: true,
scrollDirection:
[Link] < scrollLeft ? 'forward' : 'backward',
scrollOffset,
scrollUpdateWasRequested: false,
};
}, this._resetIsScrollingDebounced);
};

_onScrollVertical = (event: ScrollEvent): void => {


const { clientHeight, scrollHeight, scrollTop } = [Link];
[Link](prevState => {
if ([Link] === 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 [Link].
return null;
}

// Prevent Safari's elastic scrolling from causing visual shaking when


scrolling past bounds.
const scrollOffset = [Link](
0,
[Link](scrollTop, scrollHeight - clientHeight)
);

return {
isScrolling: true,
scrollDirection:
[Link] < scrollOffset ? 'forward' : 'backward',
scrollOffset,
scrollUpdateWasRequested: false,
};
}, this._resetIsScrollingDebounced);
};

_outerRefSetter = (ref: any): void => {


const { outerRef } = [Link];

this._outerRef = ((ref: any): HTMLDivElement);

if (typeof outerRef === 'function') {


outerRef(ref);
} else if (
outerRef != null &&
typeof outerRef === 'object' &&
[Link]('current')
) {
[Link] = ref;
}
};

_resetIsScrollingDebounced = () => {
if (this._resetIsScrollingTimeoutId !== null) {
cancelTimeout(this._resetIsScrollingTimeoutId);
}

this._resetIsScrollingTimeoutId = requestTimeout(
this._resetIsScrolling,
IS_SCROLLING_DEBOUNCE_INTERVAL
);
};

_resetIsScrolling = () => {
this._resetIsScrollingTimeoutId = null;

[Link]({ 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<any>,
{ instance }: State
): void => {
if ([Link].NODE_ENV !== 'production') {
if (innerTagName != null || outerTagName != null) {
if (devWarningsTagName && ![Link](instance)) {
[Link](instance);
[Link](
'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 && ![Link](instance)) {
[Link](instance);
[Link](
'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.`
);
}
}
};

You might also like