From bfff77326eefb0befaac5df80265754ad02cb95b Mon Sep 17 00:00:00 2001 From: Gabriele Cirulli Date: Tue, 24 Oct 2017 15:43:04 +0200 Subject: [PATCH 1/5] Add support for `scrollToTransition` prop 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)` --- README.md | 1 + src/constants.ts | 5 ++++ src/index.tsx | 65 +++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 65 insertions(+), 6 deletions(-) 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..44281af 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 scrollingNode: 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,53 @@ 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.scrollingNode.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) return; + + // The rect of the element after being scrolled lets us calculate the + // distance it has travelled + const postScrollRect = this.scrollingNode.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.scrollingNode.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.scrollingNode.style.transition = this.props.scrollToTransition || null; + this.scrollingNode.style.transitionProperty = "transform"; + + this.scrollingNode.style.transform = null; + }); + + // We listen to the end of the transition in order to perform some cleanup + const reset = () => { + this.scrollingNode.style.transition = null; + this.scrollingNode.style.transitionProperty = null; + + this.scrollingNode.removeEventListener("transitionend", reset); + } + + this.scrollingNode.addEventListener("transitionend", reset); } getOffsetForIndex(index: number, scrollToAlignment = this.props.scrollToAlignment, itemCount: number = this.props.itemCount): number { @@ -278,6 +326,7 @@ export default class VirtualList extends React.PureComponent { onItemsRendered, onScroll, scrollDirection = DIRECTION_VERTICAL, + scrollToTransition, scrollOffset, scrollToIndex, scrollToAlignment, @@ -310,15 +359,19 @@ export default class VirtualList extends React.PureComponent { } return ( -
-
+
+
{items}
); } - private getRef = (node: HTMLDivElement): void => { + private getRootNodeRef = (node: HTMLDivElement): void => { this.rootNode = node; } + + private getScrollingNodeRef = (node: HTMLDivElement): void => { + this.scrollingNode = node; + } } From 4d1a2c6750a9aa82125ba352e3815107492af952 Mon Sep 17 00:00:00 2001 From: Gabriele Cirulli Date: Tue, 24 Oct 2017 16:04:37 +0200 Subject: [PATCH 2/5] Add `will-change` property to inner element styles if a `scrollToTransition` prop is passed --- src/index.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 44281af..91b4f90 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -360,7 +360,16 @@ export default class VirtualList extends React.PureComponent { return (
-
+
{items}
From 7464cda012848a9fd9f2ce941168adbc10d407a9 Mon Sep 17 00:00:00 2001 From: Gabriele Cirulli Date: Tue, 24 Oct 2017 16:05:02 +0200 Subject: [PATCH 3/5] Code quality: use single quotes, rename this.scrollingNode to this.innerNode for consistency --- src/index.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 91b4f90..331bb76 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -230,7 +230,7 @@ export default class VirtualList extends React.PureComponent { // 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.scrollingNode.getBoundingClientRect(); + const preScrollRect = this.innerNode.getBoundingClientRect(); // Scroll to the right position this.rootNode[scrollProp[scrollDirection]] = value; @@ -241,34 +241,34 @@ export default class VirtualList extends React.PureComponent { // The rect of the element after being scrolled lets us calculate the // distance it has travelled - const postScrollRect = this.scrollingNode.getBoundingClientRect(); + 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.scrollingNode.style.transform = `${transformProp[scrollDirection]}(${delta}px)`; + 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.scrollingNode.style.transition = this.props.scrollToTransition || null; - this.scrollingNode.style.transitionProperty = "transform"; + this.innerNode.style.transition = this.props.scrollToTransition || null; + this.innerNode.style.transitionProperty = 'transform'; - this.scrollingNode.style.transform = null; + this.innerNode.style.transform = null; }); // We listen to the end of the transition in order to perform some cleanup const reset = () => { - this.scrollingNode.style.transition = null; - this.scrollingNode.style.transitionProperty = null; + this.innerNode.style.transition = null; + this.innerNode.style.transitionProperty = null; - this.scrollingNode.removeEventListener("transitionend", reset); + this.innerNode.removeEventListener('transitionend', reset); } - this.scrollingNode.addEventListener("transitionend", reset); + this.innerNode.addEventListener('transitionend', reset); } getOffsetForIndex(index: number, scrollToAlignment = this.props.scrollToAlignment, itemCount: number = this.props.itemCount): number { @@ -361,7 +361,7 @@ export default class VirtualList extends React.PureComponent { return (
{ this.rootNode = node; } - private getScrollingNodeRef = (node: HTMLDivElement): void => { - this.scrollingNode = node; + private getInnerNodeRef = (node: HTMLDivElement): void => { + this.innerNode = node; } } From 5f9873d9a7d7bb4b65982db8fd3ef5782b616601 Mon Sep 17 00:00:00 2001 From: Gabriele Cirulli Date: Tue, 24 Oct 2017 16:08:20 +0200 Subject: [PATCH 4/5] Rename private property scrollingNode to innerNode --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 331bb76..3d6cf74 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -123,7 +123,7 @@ export default class VirtualList extends React.PureComponent { }; private rootNode: HTMLElement; - private scrollingNode: HTMLElement; + private innerNode: HTMLElement; private styleCache: StyleCache = {}; From 1f1af5ef290d89e7c3fcdb3e7b3431710e527d6c Mon Sep 17 00:00:00 2001 From: Gabriele Cirulli Date: Tue, 24 Oct 2017 16:28:43 +0200 Subject: [PATCH 5/5] Fix race condition that breaks transitions when performing multiple `scrollToIndex` changes at the same time --- src/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 3d6cf74..a8ef77b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -237,7 +237,13 @@ export default class VirtualList extends React.PureComponent { // Return early and perform no animation if forced, or no transition has // been passed - if (skipTransition || this.props.scrollToTransition === undefined) return; + 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 @@ -254,16 +260,16 @@ export default class VirtualList extends React.PureComponent { // transform as if the element moved from its location pre-scroll to its // final location. requestAnimationFrame(() => { - this.innerNode.style.transition = this.props.scrollToTransition || null; + this.innerNode.style.transition = this.props.scrollToTransition || ''; this.innerNode.style.transitionProperty = 'transform'; - this.innerNode.style.transform = null; + this.innerNode.style.transform = ''; }); // We listen to the end of the transition in order to perform some cleanup const reset = () => { - this.innerNode.style.transition = null; - this.innerNode.style.transitionProperty = null; + this.innerNode.style.transition = ''; + this.innerNode.style.transitionProperty = ''; this.innerNode.removeEventListener('transitionend', reset); }