From a081755798f2d5feabba1109a03f85bdd78e1f92 Mon Sep 17 00:00:00 2001 From: zombiej Date: Tue, 9 Jul 2019 18:00:58 +0800 Subject: [PATCH 01/24] support animation --- examples/animate.less | 13 ++++++ examples/animate.tsx | 103 ++++++++++++++++++++++++++++++++++++++++++ package.json | 8 ++-- src/util.ts | 6 +-- 4 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 examples/animate.less create mode 100644 examples/animate.tsx diff --git a/examples/animate.less b/examples/animate.less new file mode 100644 index 00000000..22343699 --- /dev/null +++ b/examples/animate.less @@ -0,0 +1,13 @@ +.motion { + transition: all .3s; +} + +.item { + display: inline-block; + box-sizing: border-box; + margin: 0; + padding: 0 16px; + overflow: hidden; + line-height: 30px; + border-bottom: 1px solid gray; +} diff --git a/examples/animate.tsx b/examples/animate.tsx new file mode 100644 index 00000000..2c35827e --- /dev/null +++ b/examples/animate.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +// @ts-ignore +import CSSMotion from 'rc-animate/lib/CSSMotion'; +import classNames from 'classnames'; +import List from '../src/List'; +import './animate.less'; + +interface Item { + id: number; +} + +interface MyItemProps extends Item { + visible: boolean; + onClose: (id: number) => void; + onLeave: (id: number) => void; +} + +const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight }); +const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); + +const MyItem: React.FC = ({ id, onClose, onLeave, visible }, ref) => { + return ( + { + onLeave(id); + }} + > + {({ className, style }, motionRef) => ( +
+ + {id} +
+ )} +
+ ); +}; + +const ForwardMyItem = React.forwardRef(MyItem); + +const originDataSource: Item[] = []; +for (let i = 0; i < 100; i += 1) { + originDataSource.push({ + id: i, + }); +} + +const Demo = () => { + const [dataSource, setDataSource] = React.useState(originDataSource); + const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({}); + + const onClose = (id: number) => { + setCloseMap({ + ...closeMap, + [id]: true, + }); + }; + + const onLeave = (id: number) => { + const newDataSource = dataSource.filter(item => item.id !== id); + setDataSource(newDataSource); + }; + + return ( + +
+

Animate

+ + + {item => ( + + )} + +
+
+ ); +}; + +export default Demo; diff --git a/package.json b/package.json index 762b1bb1..35463634 100644 --- a/package.json +++ b/package.json @@ -39,24 +39,22 @@ "react-dom": "*" }, "devDependencies": { - "@types/lodash": "^4.14.135", "@types/react": "^16.8.19", "@types/react-dom": "^16.8.4", "@types/warning": "^3.0.0", + "classnames": "^2.2.6", "cross-env": "^5.2.0", "enzyme": "^3.1.0", "enzyme-adapter-react-16": "^1.0.2", "enzyme-to-json": "^3.1.4", "father": "^2.13.2", "np": "^5.0.3", + "rc-animate": "^2.9.1", "react": "^v16.9.0-alpha.0", "react-dom": "^v16.9.0-alpha.0", "typescript": "^3.5.2" }, "dependencies": { - "async-validator": "^1.11.2", - "lodash": "^4.17.4", - "rc-util": "^4.6.0", - "warning": "^4.0.3" + "rc-util": "^4.8.0" } } diff --git a/src/util.ts b/src/util.ts index 17d1d204..f05a9525 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,4 @@ -import { findDOMNode } from 'react-dom'; +import findDOMNode from 'rc-util/lib/Dom/findDOMNode'; interface LocationItemResult { /** Located item index */ @@ -45,7 +45,5 @@ export function getNodeHeight(node: HTMLElement) { return 0; } - return 'offsetHeight' in node - ? node.offsetHeight - : (findDOMNode(node) as HTMLElement).offsetHeight; + return findDOMNode(node).offsetHeight; } From 9b33d2c29e45fd4333dc25615969b5089c73f45d Mon Sep 17 00:00:00 2001 From: zombiej Date: Tue, 9 Jul 2019 20:20:56 +0800 Subject: [PATCH 02/24] add item key prop --- examples/animate.less | 18 +++++++++-- examples/animate.tsx | 74 +++++++++++++++++++++++++++++++++---------- src/List.tsx | 18 ++++++++--- 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/examples/animate.less b/examples/animate.less index 22343699..2d827385 100644 --- a/examples/animate.less +++ b/examples/animate.less @@ -8,6 +8,20 @@ margin: 0; padding: 0 16px; overflow: hidden; - line-height: 30px; - border-bottom: 1px solid gray; + line-height: 31px; + position: relative; + + &::after { + content: ''; + border-bottom: 1px solid gray; + position: absolute; + bottom: 0; + left: 0; + right: 0; + } + + button { + vertical-align: text-top; + margin-right: 8px; + } } diff --git a/examples/animate.tsx b/examples/animate.tsx index 2c35827e..d418b0b6 100644 --- a/examples/animate.tsx +++ b/examples/animate.tsx @@ -5,6 +5,19 @@ import classNames from 'classnames'; import List from '../src/List'; import './animate.less'; +let uuid = 0; +function genItem() { + uuid += 1; + return { + id: uuid, + }; +} + +const originDataSource: Item[] = []; +for (let i = 0; i < 19; i += 1) { + originDataSource.push(genItem()); +} + interface Item { id: number; } @@ -13,12 +26,17 @@ interface MyItemProps extends Item { visible: boolean; onClose: (id: number) => void; onLeave: (id: number) => void; + onInsertBefore: (id: number) => void; + onInsertAfter: (id: number) => void; } const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight }); const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); -const MyItem: React.FC = ({ id, onClose, onLeave, visible }, ref) => { +const MyItem: React.FC = ( + { id, visible, onClose, onLeave, onInsertBefore, onInsertAfter }, + ref, +) => { return ( = ({ id, onClose, onLeave, visible }, ref) = > {({ className, style }, motionRef) => (
- - {id} +
+ + + + {id} +
)}
@@ -49,13 +82,6 @@ const MyItem: React.FC = ({ id, onClose, onLeave, visible }, ref) = const ForwardMyItem = React.forwardRef(MyItem); -const originDataSource: Item[] = []; -for (let i = 0; i < 100; i += 1) { - originDataSource.push({ - id: i, - }); -} - const Demo = () => { const [dataSource, setDataSource] = React.useState(originDataSource); const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({}); @@ -72,6 +98,17 @@ const Demo = () => { setDataSource(newDataSource); }; + const onInsertBefore = (id: number) => { + const index = dataSource.findIndex(item => item.id === id); + const newDataSource = [...dataSource.slice(0, index), genItem(), ...dataSource.slice(index)]; + setDataSource(newDataSource); + }; + const onInsertAfter = (id: number) => { + const index = dataSource.findIndex(item => item.id === id) + 1; + const newDataSource = [...dataSource.slice(0, index), genItem(), ...dataSource.slice(index)]; + setDataSource(newDataSource); + }; + return (
@@ -81,6 +118,7 @@ const Demo = () => { dataSource={dataSource} height={200} itemHeight={30} + itemKey="id" style={{ border: '1px solid red', boxSizing: 'border-box', @@ -92,6 +130,8 @@ const Demo = () => { visible={!closeMap[item.id]} onClose={onClose} onLeave={onLeave} + onInsertBefore={onInsertBefore} + onInsertAfter={onInsertAfter} /> )} diff --git a/src/List.tsx b/src/List.tsx index ba760f10..f4313ff9 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -9,6 +9,7 @@ export interface ListProps extends React.HTMLAttributes { dataSource: T[]; height?: number; itemHeight?: number; + itemKey?: string; component?: string | React.FC | React.ComponentClass; } @@ -69,10 +70,14 @@ class List extends React.Component, ListState> { */ public componentDidUpdate() { const { status, startIndex, endIndex } = this.state; + const { dataSource, itemKey } = this.props; + if (status === 'MEASURE_START') { // Record here since measure item height will get warning in `render` for (let index = startIndex; index <= endIndex; index += 1) { - this.itemElementHeights[index] = getNodeHeight(this.itemElements[index]); + const item = dataSource[index]; + const eleKey = itemKey ? item[itemKey] : index; + this.itemElementHeights[index] = getNodeHeight(this.itemElements[eleKey]); } this.setState({ status: 'MEASURE_DONE' }); @@ -116,20 +121,23 @@ class List extends React.Component, ListState> { /** * Phase 4: Render item and get all the visible items height */ - public renderChildren = (list: T[], startIndex: number, renderFunc: RenderFunc) => + public renderChildren = (list: T[], startIndex: number, renderFunc: RenderFunc) => { + const { itemKey } = this.props; // We should measure rendered item height - list.map((item, index) => { + return list.map((item, index) => { const node = renderFunc(item) as React.ReactElement; const eleIndex = startIndex + index; + const eleKey = itemKey ? item[itemKey] : eleIndex; // Pass `key` and `ref` for internal measure return React.cloneElement(node, { - key: eleIndex, + key: eleKey, ref: (ele: HTMLElement) => { - this.itemElements[eleIndex] = ele; + this.itemElements[eleKey] = ele; }, }); }); + }; public render() { const { From c690260eafad6890abd4f65173e0a3218aaa99fd Mon Sep 17 00:00:00 2001 From: zombiej Date: Tue, 9 Jul 2019 20:27:04 +0800 Subject: [PATCH 03/24] omit itemKey --- src/List.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/List.tsx b/src/List.tsx index f4313ff9..62da3727 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -68,7 +68,7 @@ class List extends React.Component, ListState> { * Phase 4: Record used item height * Phase 5: Trigger re-render to use correct position */ - public componentDidUpdate() { + public componentDidUpdate(prevProps: ListProps) { const { status, startIndex, endIndex } = this.state; const { dataSource, itemKey } = this.props; @@ -82,6 +82,11 @@ class List extends React.Component, ListState> { this.setState({ status: 'MEASURE_DONE' }); } + + // Re-calculate the scroll position align with the current visible item position + if (prevProps.dataSource.length !== dataSource.length) { + console.log('!!!!!!'); + } } public getItemHeight = (index: number) => this.itemElementHeights[index] || 0; @@ -147,6 +152,7 @@ class List extends React.Component, ListState> { itemHeight, dataSource, children, + itemKey, ...restProps } = this.props; From d3aa2896b064e51cff80153e5cc580d035b8df26 Mon Sep 17 00:00:00 2001 From: zombiej Date: Tue, 9 Jul 2019 20:44:21 +0800 Subject: [PATCH 04/24] refactor --- src/List.tsx | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 62da3727..a9e86fc6 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -22,6 +22,12 @@ interface ListState { itemOffsetPtg: number; startIndex: number; endIndex: number; + /** + * Calculated by `scrollTop`. + * We cache in the state since if `dataSource` length change, + * we need revert back to the located item index. + */ + startItemTop: number; } /** @@ -48,6 +54,7 @@ class List extends React.Component, ListState> { itemOffsetPtg: 0, startIndex: 0, endIndex: 0, + startItemTop: 0, }; listRef = React.createRef(); @@ -69,7 +76,7 @@ class List extends React.Component, ListState> { * Phase 5: Trigger re-render to use correct position */ public componentDidUpdate(prevProps: ListProps) { - const { status, startIndex, endIndex } = this.state; + const { status, scrollPtg, startIndex, endIndex, itemIndex, itemOffsetPtg } = this.state; const { dataSource, itemKey } = this.props; if (status === 'MEASURE_START') { @@ -80,7 +87,19 @@ class List extends React.Component, ListState> { this.itemElementHeights[index] = getNodeHeight(this.itemElements[eleKey]); } - this.setState({ status: 'MEASURE_DONE' }); + // Calculate top visible item top offset + const locatedItemHeight = this.getItemHeight(itemIndex); + const locatedItemTop = scrollPtg * this.listRef.current.clientHeight; + const locatedItemOffset = itemOffsetPtg * locatedItemHeight; + const locatedItemMergedTop = + this.listRef.current.scrollTop + locatedItemTop - locatedItemOffset; + + let startItemTop = locatedItemMergedTop; + for (let index = itemIndex - 1; index >= startIndex; index -= 1) { + startItemTop -= this.getItemHeight(index); + } + + this.setState({ status: 'MEASURE_DONE', startItemTop }); } // Re-calculate the scroll position align with the current visible item position @@ -165,25 +184,9 @@ class List extends React.Component, ListState> { ); } - const { status, startIndex, endIndex, itemIndex, itemOffsetPtg, scrollPtg } = this.state; - + const { status, startIndex, endIndex, startItemTop } = this.state; const contentHeight = dataSource.length * itemHeight; - // TODO: refactor - let startItemTop = 0; - if (status === 'MEASURE_DONE') { - const locatedItemHeight = this.getItemHeight(itemIndex); - const locatedItemTop = scrollPtg * this.listRef.current.clientHeight; - const locatedItemOffset = itemOffsetPtg * locatedItemHeight; - const locatedItemMergedTop = - this.listRef.current.scrollTop + locatedItemTop - locatedItemOffset; - - startItemTop = locatedItemMergedTop; - for (let index = itemIndex - 1; index >= startIndex; index -= 1) { - startItemTop -= this.getItemHeight(index); - } - } - return ( Date: Tue, 9 Jul 2019 21:02:45 +0800 Subject: [PATCH 05/24] fix id mapping --- examples/animate.tsx | 26 ++++++++++++++------------ src/List.tsx | 28 +++++++++++++++------------- src/util.ts | 2 ++ 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/examples/animate.tsx b/examples/animate.tsx index d418b0b6..a8aba32d 100644 --- a/examples/animate.tsx +++ b/examples/animate.tsx @@ -9,7 +9,8 @@ let uuid = 0; function genItem() { uuid += 1; return { - id: uuid, + id: `key_${uuid}`, + uuid, }; } @@ -19,22 +20,23 @@ for (let i = 0; i < 19; i += 1) { } interface Item { - id: number; + id: string; + uuid: number; } interface MyItemProps extends Item { visible: boolean; - onClose: (id: number) => void; - onLeave: (id: number) => void; - onInsertBefore: (id: number) => void; - onInsertAfter: (id: number) => void; + onClose: (id: string) => void; + onLeave: (id: string) => void; + onInsertBefore: (id: string) => void; + onInsertAfter: (id: string) => void; } const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight }); const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); const MyItem: React.FC = ( - { id, visible, onClose, onLeave, onInsertBefore, onInsertAfter }, + { id, uuid, visible, onClose, onLeave, onInsertBefore, onInsertAfter }, ref, ) => { return ( @@ -50,7 +52,7 @@ const MyItem: React.FC = ( > {({ className, style }, motionRef) => (
-
+
- - - {id} + {({ className, style }, motionRef) => { + // if (uuid >= 100) { + // console.log('=>', id, className, style); + // } + return ( +
+
+ + + + {id} +
-
- )} + ); + }} ); }; @@ -88,6 +102,10 @@ const ForwardMyItem = React.forwardRef(MyItem); const Demo = () => { const [dataSource, setDataSource] = React.useState(originDataSource); const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({}); + const [animating, setAnimating] = React.useState(false); + const [insertIndex, setInsertIndex] = React.useState(); + + const listRef = React.useRef>(); const onClose = (id: string) => { setCloseMap({ @@ -101,15 +119,27 @@ const Demo = () => { setDataSource(newDataSource); }; + const onAppear = (...args: any[]) => { + setAnimating(false); + }; + + function lockForAnimation() { + setAnimating(true); + } + const onInsertBefore = (id: string) => { const index = dataSource.findIndex(item => item.id === id); const newDataSource = [...dataSource.slice(0, index), genItem(), ...dataSource.slice(index)]; + setInsertIndex(index); setDataSource(newDataSource); + lockForAnimation(); }; const onInsertAfter = (id: string) => { const index = dataSource.findIndex(item => item.id === id) + 1; const newDataSource = [...dataSource.slice(0, index), genItem(), ...dataSource.slice(index)]; + setInsertIndex(index); setDataSource(newDataSource); + lockForAnimation(); }; return ( @@ -118,24 +148,27 @@ const Demo = () => {

Animate

Current: {dataSource.length} records

- dataSource={dataSource} data-id="list" height={200} itemHeight={30} itemKey="id" + disabled={animating} + ref={listRef} style={{ - // border: '1px solid red', - // boxSizing: 'border-box', - boxShadow: '0 0 2px red', + border: '1px solid red', + boxSizing: 'border-box', }} > - {item => ( + {(item, index) => ( diff --git a/src/List.tsx b/src/List.tsx index 2bddd4b9..5c1189b7 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -12,10 +12,21 @@ import { } from './utils/itemUtil'; import { getIndexByStartLoc, findListDiffIndex } from './utils/algorithmUtil'; -type RenderFunc = (item: T) => React.ReactNode; +type RenderFunc = (item: T, index: number) => React.ReactNode; const ITEM_SCALE_RATE = 1; +export interface RelativeScroll { + itemIndex: number; + relativeTop: number; +} + +export interface ScrollInfo { + scrollTop: number; + startItemTop: number; + startIndex: number; +} + export interface ListProps extends React.HTMLAttributes { children: RenderFunc; dataSource: T[]; @@ -23,9 +34,10 @@ export interface ListProps extends React.HTMLAttributes { itemHeight?: number; itemKey: string; component?: string | React.FC | React.ComponentClass; + disabled?: boolean; } -interface ListState { +interface ListState { status: 'NONE' | 'MEASURE_START' | 'MEASURE_DONE'; scrollTop: number | null; @@ -64,13 +76,13 @@ interface ListState { * # located item * The base position item which other items position calculate base on. */ -class List extends React.Component, ListState> { +class List extends React.Component, ListState> { static defaultProps = { itemHeight: 15, dataSource: [], }; - state: ListState = { + state: ListState = { status: 'NONE', scrollTop: null, itemIndex: 0, @@ -86,6 +98,11 @@ class List extends React.Component, ListState> { itemElementHeights: { [index: number]: number } = {}; + /** + * Always point to the latest props if `disabled` is `false` + */ + cachedProps: Partial> = {}; + /** * Lock scroll process with `onScroll` event. * This is used for `dataSource` length change and `scrollTop` restore @@ -104,25 +121,28 @@ class List extends React.Component, ListState> { * Phase 4: Record used item height * Phase 5: Trigger re-render to use correct position */ - public componentDidUpdate(prevProps: ListProps) { + public componentDidUpdate() { const { status } = this.state; - const { dataSource, height, itemHeight } = this.props; + const { dataSource, height, itemHeight, disabled } = this.props; + const prevDataSource: T[] = this.cachedProps.dataSource || []; + + if (disabled) { + return; + } if (status === 'MEASURE_START') { - const { startIndex, endIndex, itemIndex, itemOffsetPtg } = this.state; + const { startIndex, itemIndex, itemOffsetPtg } = this.state; + const { scrollTop } = this.listRef.current; // Record here since measure item height will get warning in `render` - for (let index = startIndex; index <= endIndex; index += 1) { - const eleKey = this.getIndexKey(index); - this.itemElementHeights[eleKey] = getNodeHeight(this.itemElements[eleKey]); - } + this.collectItemHeights(); // Calculate top visible item top offset const locatedItemTop = getItemAbsoluteTop({ itemIndex, itemOffsetPtg, itemElementHeights: this.itemElementHeights, - scrollTop: this.listRef.current.scrollTop, + scrollTop, scrollPtg: getElementScrollPercentage(this.listRef.current), clientHeight: this.listRef.current.clientHeight, getItemKey: this.getIndexKey, @@ -133,14 +153,17 @@ class List extends React.Component, ListState> { startItemTop -= this.itemElementHeights[this.getIndexKey(index)] || 0; } - this.setState({ status: 'MEASURE_DONE', startItemTop }); + this.setState({ + status: 'MEASURE_DONE', + startItemTop, + }); } /** * Re-calculate the item position since `dataSource` length changed. * [IMPORTANT] We use relative position calculate here. */ - if (prevProps.dataSource.length !== dataSource.length && height) { + if (prevDataSource.length !== dataSource.length && height) { const { itemIndex: originItemIndex, itemOffsetPtg: originItemOffsetPtg, @@ -149,6 +172,9 @@ class List extends React.Component, ListState> { scrollTop: originScrollTop, } = this.state; + // 1. Refresh item heights + this.collectItemHeights(); + // 1. Get origin located item top const originLocatedItemRelativeTop = getItemRelativeTop({ itemIndex: originItemIndex, @@ -156,20 +182,20 @@ class List extends React.Component, ListState> { itemElementHeights: this.itemElementHeights, scrollPtg: getScrollPercentage({ scrollTop: originScrollTop, - scrollHeight: prevProps.dataSource.length * itemHeight, + scrollHeight: prevDataSource.length * itemHeight, clientHeight: this.listRef.current.clientHeight, }), clientHeight: this.listRef.current.clientHeight, - getItemKey: (index: number) => this.getIndexKey(index, prevProps), + getItemKey: (index: number) => this.getIndexKey(index, this.cachedProps), }); // 2. Find the compare item - const removedItemIndex: number = findListDiffIndex( - prevProps.dataSource, + const changedItemIndex: number = findListDiffIndex( + prevDataSource, dataSource, this.getItemKey, ); - let originCompareItemIndex = removedItemIndex - 1; + let originCompareItemIndex = changedItemIndex - 1; // Use next one since there are not more item before removed if (originCompareItemIndex < 0) { originCompareItemIndex = 0; @@ -182,11 +208,95 @@ class List extends React.Component, ListState> { compareItemIndex: originCompareItemIndex, startIndex: originStartIndex, endIndex: originEndIndex, - getItemKey: (index: number) => this.getIndexKey(index, prevProps), + getItemKey: (index: number) => this.getIndexKey(index, this.cachedProps), itemElementHeights: this.itemElementHeights, }); - // 4. Find the best match compare item top + this.scrollTo({ + itemIndex: originCompareItemIndex, + relativeTop: originCompareItemTop, + }); + } + + this.cachedProps = this.props; + } + + /** + * Phase 2: Trigger render since we should re-calculate current position. + */ + public onScroll = () => { + const { dataSource, height, itemHeight, disabled } = this.props; + + const { scrollTop } = this.listRef.current; + + // Skip if `scrollTop` not change to avoid shake + if (scrollTop === this.state.scrollTop || this.lockScroll || disabled) { + return; + } + + const scrollPtg = getElementScrollPercentage(this.listRef.current); + const visibleCount = Math.ceil(height / itemHeight); + + const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( + scrollPtg, + dataSource.length, + visibleCount, + ); + + this.setState({ + status: 'MEASURE_START', + scrollTop, + itemIndex, + itemOffsetPtg, + startIndex, + endIndex, + }); + }; + + public getIndexKey = (index: number, props?: Partial>) => { + const mergedProps = props || this.props; + const { dataSource = [] } = mergedProps; + + // Return ghost key as latest index item + if (index === dataSource.length) { + return GHOST_ITEM_KEY; + } + + const item = dataSource[index]; + if (!item) { + console.error('Not find index item. Please report this since it is a bug.'); + } + + return this.getItemKey(item, mergedProps); + }; + + public getItemKey = (item: T, props?: Partial>) => { + const { itemKey } = props || this.props; + return item ? item[itemKey] : null; + }; + + /** + * Collect current rendered dom element item heights + */ + public collectItemHeights = () => { + const { startIndex, endIndex } = this.state; + + // Record here since measure item height will get warning in `render` + for (let index = startIndex; index <= endIndex; index += 1) { + const eleKey = this.getIndexKey(index); + this.itemElementHeights[eleKey] = getNodeHeight(this.itemElements[eleKey]); + } + }; + + public scrollTo(arg: number | RelativeScroll): void { + if (typeof arg === 'number') { + this.listRef.current.scrollTop = arg; + } else if (typeof arg === 'object') { + const { itemIndex: compareItemIndex, relativeTop: compareItemRelativeTop } = arg; + const { scrollTop: originScrollTop } = this.state; + const { dataSource, itemHeight, height } = this.props; + + // 1. Find the best match compare item top let bestSimilarity = Number.MAX_VALUE; let bestScrollTop: number = null; let bestItemIndex: number = null; @@ -213,8 +323,8 @@ class List extends React.Component, ListState> { ); // No need to check if compare item out of the index to save performance - if (startIndex <= originCompareItemIndex && originCompareItemIndex <= endIndex) { - // 4.1 Get measure located item relative top + if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) { + // 1.1 Get measure located item relative top const locatedItemRelativeTop = getItemRelativeTop({ itemIndex, itemOffsetPtg, @@ -227,15 +337,15 @@ class List extends React.Component, ListState> { const compareItemTop = getCompareItemRelativeTop({ locatedItemRelativeTop, locatedItemIndex: itemIndex, - compareItemIndex: originCompareItemIndex, // Same as origin index + compareItemIndex, // Same as origin index startIndex, endIndex, getItemKey: this.getIndexKey, itemElementHeights: this.itemElementHeights, }); - // 4.2 Find best match compare item top - const similarity = Math.abs(compareItemTop - originCompareItemTop); + // 1.2 Find best match compare item top + const similarity = Math.abs(compareItemTop - compareItemRelativeTop); if (similarity < bestSimilarity) { bestSimilarity = similarity; bestScrollTop = scrollTop; @@ -258,7 +368,7 @@ class List extends React.Component, ListState> { } } - // 5. Re-scroll if has best scroll match + // 2. Re-scroll if has best scroll match if (bestScrollTop !== null) { this.lockScroll = true; this.listRef.current.scrollTop = bestScrollTop; @@ -281,68 +391,14 @@ class List extends React.Component, ListState> { } } - /** - * Phase 2: Trigger render since we should re-calculate current position. - */ - public onScroll = () => { - const { dataSource, height, itemHeight } = this.props; - - const { scrollTop } = this.listRef.current; - - // Skip if `scrollTop` not change to avoid shake - if (scrollTop === this.state.scrollTop || this.lockScroll) { - return; - } - - const scrollPtg = getElementScrollPercentage(this.listRef.current); - const visibleCount = Math.ceil(height / itemHeight); - - const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( - scrollPtg, - dataSource.length, - visibleCount, - ); - - this.setState({ - status: 'MEASURE_START', - scrollTop, - itemIndex, - itemOffsetPtg, - startIndex, - endIndex, - }); - }; - - public getIndexKey = (index: number, props?: ListProps) => { - const mergedProps = props || this.props; - const { dataSource } = mergedProps; - - // Return ghost key as latest index item - if (index === dataSource.length) { - return GHOST_ITEM_KEY; - } - - const item = dataSource[index]; - if (!item) { - console.error('Not find index item. Please report this since it is a bug.'); - } - - return this.getItemKey(item, mergedProps); - }; - - public getItemKey = (item: T, props?: ListProps) => { - const { itemKey } = props || this.props; - return item ? item[itemKey] : null; - }; - /** * Phase 4: Render item and get all the visible items height */ public renderChildren = (list: T[], startIndex: number, renderFunc: RenderFunc) => // We should measure rendered item height list.map((item, index) => { - const node = renderFunc(item) as React.ReactElement; const eleIndex = startIndex + index; + const node = renderFunc(item, eleIndex) as React.ReactElement; const eleKey = this.getIndexKey(eleIndex); // Pass `key` and `ref` for internal measure diff --git a/src/utils/itemUtil.ts b/src/utils/itemUtil.ts index 01041841..5d1ee116 100644 --- a/src/utils/itemUtil.ts +++ b/src/utils/itemUtil.ts @@ -112,7 +112,7 @@ export function getItemRelativeTop({ const locatedItemHeight = itemElementHeights[getItemKey(itemIndex)] || 0; const locatedItemTop = scrollPtg * clientHeight; const locatedItemOffset = itemOffsetPtg * locatedItemHeight; - return locatedItemTop - locatedItemOffset; + return Math.floor(locatedItemTop - locatedItemOffset); } /** From 61574d75b658044f28a2abfbce4fe38b40b1fa61 Mon Sep 17 00:00:00 2001 From: zombiej Date: Fri, 12 Jul 2019 11:46:01 +0800 Subject: [PATCH 24/24] handle Safari elasticity effect --- src/List.tsx | 4 +++- src/utils/itemUtil.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 5c1189b7..2bb22bb6 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -9,6 +9,7 @@ import { GHOST_ITEM_KEY, getItemRelativeTop, getCompareItemRelativeTop, + alignScrollTop, } from './utils/itemUtil'; import { getIndexByStartLoc, findListDiffIndex } from './utils/algorithmUtil'; @@ -227,7 +228,8 @@ class List extends React.Component, ListState> { public onScroll = () => { const { dataSource, height, itemHeight, disabled } = this.props; - const { scrollTop } = this.listRef.current; + const { scrollTop: originScrollTop, clientHeight, scrollHeight } = this.listRef.current; + const scrollTop = alignScrollTop(originScrollTop, scrollHeight - clientHeight); // Skip if `scrollTop` not change to avoid shake if (scrollTop === this.state.scrollTop || this.lockScroll || disabled) { diff --git a/src/utils/itemUtil.ts b/src/utils/itemUtil.ts index 5d1ee116..d5630ea0 100644 --- a/src/utils/itemUtil.ts +++ b/src/utils/itemUtil.ts @@ -32,6 +32,21 @@ function getLocationItem(scrollPtg: number, total: number): LocationItemResult { }; } +/** + * Safari has the elasticity effect which provides negative `scrollTop` value. + * We should ignore it since will make scroll animation shake. + */ +export function alignScrollTop(scrollTop: number, scrollRange: number) { + if (scrollTop < 0) { + return 0; + } + if (scrollTop >= scrollRange) { + return scrollRange; + } + + return scrollTop; +} + export function getScrollPercentage({ scrollTop, scrollHeight, @@ -45,7 +60,9 @@ export function getScrollPercentage({ return 0; } - const scrollTopPtg = scrollTop / (scrollHeight - clientHeight); + const scrollRange = scrollHeight - clientHeight; + const alignedScrollTop = alignScrollTop(scrollTop, scrollRange); + const scrollTopPtg = alignedScrollTop / scrollRange; return scrollTopPtg; }