diff --git a/README.md b/README.md index 25948ba..aa28a8a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ render( | scrollOffset | Number | | Can be used to control the scroll offset; Also useful for setting an initial scroll offset | | scrollToIndex | Number | | Item index to scroll to (by forcefully scrolling if necessary) | | scrollToAlignment | String | | Used in combination with `scrollToIndex`, this prop controls the alignment of the scrolled to item. One of: `'start'`, `'center'`, `'end'` or `'auto'`. Use `'start'` to always align items to the top of the container and `'end'` to align them bottom. Use `'center`' to align them in the middle of the container. `'auto'` scrolls the least amount possible to ensure that the specified `scrollToIndex` item is fully visible. | +| scrollToTransition | String | | Used in combination with `scrollToIndex`, if passed this prop adds a transition when `scrollToIndex` changes. Use the same syntax as the CSS `transition` property. Do not specify a property. Example transitions: `200ms ease`, `1s cubic-bezier(0.4, 0.0, 0.2, 1)` | | overscanCount | Number | | Number of extra buffer items to render above/below the visible items. Tweaking this can help reduce scroll flickering on certain browsers/devices. | | estimatedItemSize | Number | | Used to estimate the total size of the list before all of its items have actually been measured. The estimated total height is progressively adjusted as items are rendered. | | onItemsRendered | Function | | Callback invoked with information about the slice of rows/columns that were just rendered. It has the following signature: `({startIndex: number, stopIndex: number})`. | diff --git a/src/constants.ts b/src/constants.ts index b4822cd..ce4be01 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,3 +26,8 @@ export const positionProp = { [DIRECTION_VERTICAL]: 'top', [DIRECTION_HORIZONTAL]: 'left', }; + +export const transformProp = { + [DIRECTION_VERTICAL]: 'translateY', + [DIRECTION_HORIZONTAL]: 'translateX', +}; diff --git a/src/index.tsx b/src/index.tsx index 8368dd5..a8ef77b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,7 @@ import { positionProp, scrollProp, sizeProp, + transformProp } from './constants'; const STYLE_WRAPPER: React.CSSProperties = { @@ -70,6 +71,7 @@ export interface Props { scrollToIndex?: number, scrollToAlignment?: ALIGNMENT, scrollDirection?: DIRECTION, + scrollToTransition?: string, style?: any, width?: number | string, onItemsRendered?({startIndex, stopIndex}: RenderedRows): void, @@ -100,6 +102,7 @@ export default class VirtualList extends React.PureComponent { scrollOffset: PropTypes.number, scrollToIndex: PropTypes.number, scrollToAlignment: PropTypes.oneOf([ALIGN_AUTO, ALIGN_START, ALIGN_CENTER, ALIGN_END]), + scrollToTransition: PropTypes.string, scrollDirection: PropTypes.oneOf([DIRECTION_HORIZONTAL, DIRECTION_VERTICAL]).isRequired, width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }; @@ -120,6 +123,7 @@ export default class VirtualList extends React.PureComponent { }; private rootNode: HTMLElement; + private innerNode: HTMLElement; private styleCache: StyleCache = {}; @@ -127,9 +131,9 @@ export default class VirtualList extends React.PureComponent { const {scrollOffset, scrollToIndex} = this.props; if (scrollOffset != null) { - this.scrollTo(scrollOffset); + this.scrollTo(scrollOffset, true); } else if (scrollToIndex != null) { - this.scrollTo(this.getOffsetForIndex(scrollToIndex)); + this.scrollTo(this.getOffsetForIndex(scrollToIndex), true); } } @@ -218,9 +222,59 @@ export default class VirtualList extends React.PureComponent { return this.rootNode[scrollProp[scrollDirection]]; } - scrollTo(value: number) { + scrollTo(value: number, skipTransition: boolean = false) { const {scrollDirection = DIRECTION_VERTICAL} = this.props; + + // We use the FLIP technique to animate the scroll change. + // See https://aerotwist.com/blog/flip-your-animations/ for more info. + + // Get the element's rect which will be used to determine how far the list + // has scrolled once the scroll position has been set + const preScrollRect = this.innerNode.getBoundingClientRect(); + + // Scroll to the right position this.rootNode[scrollProp[scrollDirection]] = value; + + // Return early and perform no animation if forced, or no transition has + // been passed + if ( + skipTransition || + this.props.scrollToTransition === undefined || + this.innerNode.style.transition !== '' + ) { + return; + } + + // The rect of the element after being scrolled lets us calculate the + // distance it has travelled + const postScrollRect = this.innerNode.getBoundingClientRect(); + + const delta = preScrollRect[positionProp[scrollDirection]] - postScrollRect[positionProp[scrollDirection]]; + + // Set `translateX` or `translateY` (depending on the scroll direction) in + // order to move the element back to the original position before scrolling + this.innerNode.style.transform = `${transformProp[scrollDirection]}(${delta}px)`; + + // Wait for the next frame, then add a transition to the element and move it + // back to its current position. This makes the browser animate the + // transform as if the element moved from its location pre-scroll to its + // final location. + requestAnimationFrame(() => { + this.innerNode.style.transition = this.props.scrollToTransition || ''; + this.innerNode.style.transitionProperty = 'transform'; + + this.innerNode.style.transform = ''; + }); + + // We listen to the end of the transition in order to perform some cleanup + const reset = () => { + this.innerNode.style.transition = ''; + this.innerNode.style.transitionProperty = ''; + + this.innerNode.removeEventListener('transitionend', reset); + } + + this.innerNode.addEventListener('transitionend', reset); } getOffsetForIndex(index: number, scrollToAlignment = this.props.scrollToAlignment, itemCount: number = this.props.itemCount): number { @@ -278,6 +332,7 @@ export default class VirtualList extends React.PureComponent { onItemsRendered, onScroll, scrollDirection = DIRECTION_VERTICAL, + scrollToTransition, scrollOffset, scrollToIndex, scrollToAlignment, @@ -310,15 +365,28 @@ export default class VirtualList extends React.PureComponent { } return ( -
-
+
+
{items}
); } - private getRef = (node: HTMLDivElement): void => { + private getRootNodeRef = (node: HTMLDivElement): void => { this.rootNode = node; } + + private getInnerNodeRef = (node: HTMLDivElement): void => { + this.innerNode = node; + } }