diff --git a/examples/animate.less b/examples/animate.less new file mode 100644 index 00000000..2d827385 --- /dev/null +++ b/examples/animate.less @@ -0,0 +1,27 @@ +.motion { + transition: all .3s; +} + +.item { + display: inline-block; + box-sizing: border-box; + margin: 0; + padding: 0 16px; + overflow: hidden; + 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 new file mode 100644 index 00000000..6558ef25 --- /dev/null +++ b/examples/animate.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; +// @ts-ignore +import CSSMotion from 'rc-animate/lib/CSSMotion'; +import classNames from 'classnames'; +import List, { ScrollInfo } from '../src/List'; +import './animate.less'; + +let uuid = 0; +function genItem() { + const item = { + id: `key_${uuid}`, + uuid, + }; + uuid += 1; + return item; +} + +const originDataSource: Item[] = []; +for (let i = 0; i < 100000; i += 1) { + originDataSource.push(genItem()); +} + +interface Item { + id: string; + uuid: number; +} + +interface MyItemProps extends Item { + visible: boolean; + motionAppear: boolean; + onClose: (id: string) => void; + onLeave: (id: string) => void; + onAppear: (...args: any[]) => void; + onInsertBefore: (id: string) => void; + onInsertAfter: (id: string) => void; +} + +const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight }); +const getMaxHeight = (node: HTMLElement) => { + return { height: node.scrollHeight }; +}; +const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); + +const MyItem: React.FC = ( + { id, uuid, visible, onClose, onLeave, onAppear, onInsertBefore, onInsertAfter, motionAppear }, + ref, +) => { + return ( + { + onLeave(id); + }} + > + {({ className, style }, motionRef) => { + // if (uuid >= 100) { + // console.log('=>', id, className, style); + // } + return ( +
+
+ + + + {id} +
+
+ ); + }} +
+ ); +}; + +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({ + ...closeMap, + [id]: true, + }); + }; + + const onLeave = (id: string) => { + const newDataSource = dataSource.filter(item => item.id !== id); + 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 ( + +
+

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', + }} + > + {(item, index) => ( + + )} + +
+
+ ); +}; + +export default Demo; diff --git a/examples/basic.tsx b/examples/basic.tsx index dcb951f6..35f809a2 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -25,7 +25,7 @@ const MyItem: React.FC = ({ id }, ref) => { const ForwardMyItem = React.forwardRef(MyItem); -class TestItem extends React.Component { +class TestItem extends React.Component<{ id: number }> { render() { return
{this.props.id}
; } @@ -68,6 +68,7 @@ const Demo = () => { dataSource={dataSource} height={200} itemHeight={30} + itemKey="id" style={{ border: '1px solid red', boxSizing: 'border-box', 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/Filler.tsx b/src/Filler.tsx index 55e3cfa8..8502db14 100644 --- a/src/Filler.tsx +++ b/src/Filler.tsx @@ -4,7 +4,7 @@ interface FillerProps { /** Virtual filler height. Should be `count * itemMinHeight` */ height: number; /** Set offset of visible items. Should be the top of start item position */ - offset: number; + offset?: number; children: React.ReactNode; } @@ -12,22 +12,32 @@ interface FillerProps { /** * Fill component to provided the scroll content real height. */ -const Filler: React.FC = ({ height, offset, children }): React.ReactElement => ( -
-
- {children} +const Filler: React.FC = ({ height, offset, children }): React.ReactElement => { + let outerStyle: React.CSSProperties = {}; + + let innerStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + }; + + if (offset !== undefined) { + outerStyle = { height, position: 'relative', overflow: 'hidden' }; + + innerStyle = { + ...innerStyle, + marginTop: offset, + position: 'absolute', + left: 0, + right: 0, + top: 0, + }; + } + + return ( +
+
{children}
-
-); + ); +}; export default Filler; diff --git a/src/List.tsx b/src/List.tsx index ba760f10..2bb22bb6 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,26 +1,59 @@ import * as React from 'react'; import Filler from './Filler'; -import { getLocationItem, getScrollPercentage, getNodeHeight } from './util'; +import { + getElementScrollPercentage, + getScrollPercentage, + getNodeHeight, + getRangeIndex, + getItemAbsoluteTop, + GHOST_ITEM_KEY, + getItemRelativeTop, + getCompareItemRelativeTop, + alignScrollTop, +} 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[]; height?: number; 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; - scrollPtg: number; + /** Located item index */ itemIndex: number; + /** Located item bind its height percentage with the `scrollTop` */ 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; } /** @@ -32,21 +65,32 @@ interface ListState { * 3. [Render] Render visible items * 4. Get all the visible items height * 5. [Render] Update top item `margin-top` to fit the position + * + * Algorithm: + * We split scroll bar into equal slice. An item with whatever height occupy the same range slice. + * When `scrollTop` change, + * it will calculate the item percentage position and move item to the position. + * Then calculate other item position base on the located item. + * + * Concept: + * + * # 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, - scrollPtg: 0, itemIndex: 0, itemOffsetPtg: 0, startIndex: 0, endIndex: 0, + startItemTop: 0, }; listRef = React.createRef(); @@ -55,6 +99,17 @@ 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 + */ + lockScroll: boolean = false; + /** * Phase 1: Initial should sync with default scroll top */ @@ -68,65 +123,291 @@ class List extends React.Component, ListState> { * Phase 5: Trigger re-render to use correct position */ public componentDidUpdate() { - const { status, startIndex, endIndex } = this.state; + const { status } = this.state; + const { dataSource, height, itemHeight, disabled } = this.props; + const prevDataSource: T[] = this.cachedProps.dataSource || []; + + if (disabled) { + return; + } + if (status === 'MEASURE_START') { + 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) { - this.itemElementHeights[index] = getNodeHeight(this.itemElements[index]); + this.collectItemHeights(); + + // Calculate top visible item top offset + const locatedItemTop = getItemAbsoluteTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollTop, + scrollPtg: getElementScrollPercentage(this.listRef.current), + clientHeight: this.listRef.current.clientHeight, + getItemKey: this.getIndexKey, + }); + + let startItemTop = locatedItemTop; + for (let index = itemIndex - 1; index >= startIndex; index -= 1) { + startItemTop -= this.itemElementHeights[this.getIndexKey(index)] || 0; } - this.setState({ status: 'MEASURE_DONE' }); + this.setState({ + status: 'MEASURE_DONE', + startItemTop, + }); } - } - public getItemHeight = (index: number) => this.itemElementHeights[index] || 0; + /** + * Re-calculate the item position since `dataSource` length changed. + * [IMPORTANT] We use relative position calculate here. + */ + if (prevDataSource.length !== dataSource.length && height) { + const { + itemIndex: originItemIndex, + itemOffsetPtg: originItemOffsetPtg, + startIndex: originStartIndex, + endIndex: originEndIndex, + scrollTop: originScrollTop, + } = this.state; + + // 1. Refresh item heights + this.collectItemHeights(); + + // 1. Get origin located item top + const originLocatedItemRelativeTop = getItemRelativeTop({ + itemIndex: originItemIndex, + itemOffsetPtg: originItemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollPtg: getScrollPercentage({ + scrollTop: originScrollTop, + scrollHeight: prevDataSource.length * itemHeight, + clientHeight: this.listRef.current.clientHeight, + }), + clientHeight: this.listRef.current.clientHeight, + getItemKey: (index: number) => this.getIndexKey(index, this.cachedProps), + }); + + // 2. Find the compare item + const changedItemIndex: number = findListDiffIndex( + prevDataSource, + dataSource, + this.getItemKey, + ); + let originCompareItemIndex = changedItemIndex - 1; + // Use next one since there are not more item before removed + if (originCompareItemIndex < 0) { + originCompareItemIndex = 0; + } + + // 3. Find the compare item top + const originCompareItemTop = getCompareItemRelativeTop({ + locatedItemRelativeTop: originLocatedItemRelativeTop, + locatedItemIndex: originItemIndex, + compareItemIndex: originCompareItemIndex, + startIndex: originStartIndex, + endIndex: originEndIndex, + getItemKey: (index: number) => this.getIndexKey(index, this.cachedProps), + itemElementHeights: this.itemElementHeights, + }); + + 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 } = this.props; + 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) { + if (scrollTop === this.state.scrollTop || this.lockScroll || disabled) { return; } - const scrollPtg = getScrollPercentage(this.listRef.current); - - const { index, offsetPtg } = getLocationItem(scrollPtg, dataSource.length); + const scrollPtg = getElementScrollPercentage(this.listRef.current); const visibleCount = Math.ceil(height / itemHeight); - const beforeCount = Math.ceil(scrollPtg * visibleCount); - const afterCount = Math.ceil((1 - scrollPtg) * visibleCount); + const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( + scrollPtg, + dataSource.length, + visibleCount, + ); this.setState({ status: 'MEASURE_START', scrollTop, - scrollPtg, - itemIndex: index, - itemOffsetPtg: offsetPtg, - startIndex: Math.max(0, index - beforeCount), - endIndex: Math.min(dataSource.length - 1, index + afterCount), + 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; + let bestItemOffsetPtg: number = null; + let bestStartIndex: number = null; + let bestEndIndex: number = null; + + let missSimilarity = 0; + + const scrollHeight = dataSource.length * itemHeight; + const { clientHeight } = this.listRef.current; + const maxScrollTop = scrollHeight - clientHeight; + + for (let i = 0; i < maxScrollTop; i += 1) { + const scrollTop = getIndexByStartLoc(0, maxScrollTop, originScrollTop, i); + + const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight }); + const visibleCount = Math.ceil(height / itemHeight); + + const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( + scrollPtg, + dataSource.length, + visibleCount, + ); + + // No need to check if compare item out of the index to save performance + if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) { + // 1.1 Get measure located item relative top + const locatedItemRelativeTop = getItemRelativeTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollPtg, + clientHeight, + getItemKey: this.getIndexKey, + }); + + const compareItemTop = getCompareItemRelativeTop({ + locatedItemRelativeTop, + locatedItemIndex: itemIndex, + compareItemIndex, // Same as origin index + startIndex, + endIndex, + getItemKey: this.getIndexKey, + itemElementHeights: this.itemElementHeights, + }); + + // 1.2 Find best match compare item top + const similarity = Math.abs(compareItemTop - compareItemRelativeTop); + if (similarity < bestSimilarity) { + bestSimilarity = similarity; + bestScrollTop = scrollTop; + bestItemIndex = itemIndex; + bestItemOffsetPtg = itemOffsetPtg; + bestStartIndex = startIndex; + bestEndIndex = endIndex; + + missSimilarity = 0; + } else { + missSimilarity += 1; + } + } + + // If keeping 10 times not match similarity, + // check more scrollTop is meaningless. + // Here boundary is set to 10. + if (missSimilarity > 10) { + break; + } + } + + // 2. Re-scroll if has best scroll match + if (bestScrollTop !== null) { + this.lockScroll = true; + this.listRef.current.scrollTop = bestScrollTop; + + this.setState({ + status: 'MEASURE_START', + scrollTop: bestScrollTop, + itemIndex: bestItemIndex, + itemOffsetPtg: bestItemOffsetPtg, + startIndex: bestStartIndex, + endIndex: bestEndIndex, + }); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.lockScroll = false; + }); + }); + } + } + } + /** * 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 return React.cloneElement(node, { - key: eleIndex, + key: eleKey, ref: (ele: HTMLElement) => { - this.itemElements[eleIndex] = ele; + this.itemElements[eleKey] = ele; }, }); }); @@ -139,49 +420,31 @@ class List extends React.Component, ListState> { itemHeight, dataSource, children, + itemKey, ...restProps } = this.props; - // Render pure list if not set height - if (height === undefined) { + const mergedStyle = { + ...style, + height, + overflowY: 'auto', + overflowAnchor: 'none', + }; + + // Render pure list if not set height or height is enough for all items + if (height === undefined || dataSource.length * itemHeight <= height) { return ( - - {this.renderChildren(dataSource, 0, children)} + + {this.renderChildren(dataSource, 0, children)} ); } - const { status, startIndex, endIndex, itemIndex, itemOffsetPtg, scrollPtg } = 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); - } - } + const { status, startIndex, endIndex, startItemTop } = this.state; + const contentHeight = dataSource.length * itemHeight * ITEM_SCALE_RATE; return ( - + {this.renderChildren(dataSource.slice(startIndex, endIndex + 1), startIndex, children)} diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 17d1d204..00000000 --- a/src/util.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { findDOMNode } from 'react-dom'; - -interface LocationItemResult { - /** Located item index */ - index: number; - /** Current item display baseline related with current container baseline */ - offsetPtg: number; -} - -/** - * Get location item and its align percentage with the scroll percentage. - * We should measure current scroll position to decide which item is the location item. - * And then fill the top count and bottom count with the base of location item. - * - * `total` should be the real count instead of `total - 1` in calculation. - */ -export function getLocationItem(scrollPtg: number, total: number): LocationItemResult { - const itemIndex = Math.floor(scrollPtg * total); - const itemTopPtg = itemIndex / total; - const itemBottomPtg = (itemIndex + 1) / total; - const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg); - - return { - index: itemIndex, - offsetPtg: itemOffsetPtg, - }; -} - -export function getScrollPercentage(element: HTMLElement | null) { - if (!element) { - return 0; - } - - const { scrollTop, scrollHeight, clientHeight } = element; - const scrollTopPtg = scrollTop / (scrollHeight - clientHeight); - return scrollTopPtg; -} - -/** - * Get node `offsetHeight`. We prefer node is a dom element directly. - * But if not provided, downgrade to `findDOMNode` to get the real dom element. - */ -export function getNodeHeight(node: HTMLElement) { - if (!node) { - return 0; - } - - return 'offsetHeight' in node - ? node.offsetHeight - : (findDOMNode(node) as HTMLElement).offsetHeight; -} diff --git a/src/utils/algorithmUtil.ts b/src/utils/algorithmUtil.ts new file mode 100644 index 00000000..53823d57 --- /dev/null +++ b/src/utils/algorithmUtil.ts @@ -0,0 +1,82 @@ +/** + * Get index with specific start index one by one. e.g. + * min: 3, max: 9, start: 6 + * + * Return index is: + * [0]: 6 + * [1]: 7 + * [2]: 5 + * [3]: 8 + * [4]: 4 + * [5]: 9 + * [6]: 3 + */ +export function getIndexByStartLoc(min: number, max: number, start: number, index: number): number { + const beforeCount = start - min; + const afterCount = max - start; + const balanceCount = Math.min(beforeCount, afterCount) * 2; + + // Balance + if (index <= balanceCount) { + const stepIndex = Math.floor(index / 2); + if (index % 2) { + return start + stepIndex + 1; + } + return start - stepIndex; + } + + // One is out of range + if (beforeCount > afterCount) { + return start - (index - afterCount); + } + return start + (index - beforeCount); +} + +/** + * We assume that 2 list has only 1 item diff and others keeping the order. + * So we can use dichotomy algorithm to find changed one. + */ +export function findListDiffIndex( + originList: T[], + targetList: T[], + getKey: (item: T) => string, +): number | null { + if (originList.length === targetList.length) { + return null; + } + + let startIndex = 0; + let endIndex = originList.length - 1; + let midIndex = Math.floor((startIndex + endIndex) / 2); + + const keyCache: Map = new Map(); + + function getCacheKey(item: T) { + if (!keyCache.has(item)) { + keyCache.set(item, item !== undefined ? getKey(item) : { __EMPTY_ITEM__: true }); + } + return keyCache.get(item); + } + + while (startIndex !== midIndex || midIndex !== endIndex) { + const originMidKey = getCacheKey(originList[midIndex]); + const targetMidKey = getCacheKey(targetList[midIndex]); + + if (originMidKey === targetMidKey) { + startIndex = midIndex; + } else { + endIndex = midIndex; + } + + // Check if there only 2 index left + if (startIndex === endIndex - 1) { + return getCacheKey(originList[startIndex]) !== getCacheKey(targetList[startIndex]) + ? startIndex + : endIndex; + } + + midIndex = Math.floor((startIndex + endIndex) / 2); + } + + return midIndex; +} diff --git a/src/utils/itemUtil.ts b/src/utils/itemUtil.ts new file mode 100644 index 00000000..d5630ea0 --- /dev/null +++ b/src/utils/itemUtil.ts @@ -0,0 +1,186 @@ +import findDOMNode from 'rc-util/lib/Dom/findDOMNode'; + +/** + * Our algorithm have additional one ghost item + * whose index as `dataSource.length` to simplify the calculation + */ +export const GHOST_ITEM_KEY = '__rc_ghost_item__'; + +interface LocationItemResult { + /** Located item index */ + index: number; + /** Current item display baseline related with current container baseline */ + offsetPtg: number; +} + +/** + * Get location item and its align percentage with the scroll percentage. + * We should measure current scroll position to decide which item is the location item. + * And then fill the top count and bottom count with the base of location item. + * + * `total` should be the real count instead of `total - 1` in calculation. + */ +function getLocationItem(scrollPtg: number, total: number): LocationItemResult { + const itemIndex = Math.floor(scrollPtg * total); + const itemTopPtg = itemIndex / total; + const itemBottomPtg = (itemIndex + 1) / total; + const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg); + + return { + index: itemIndex, + offsetPtg: itemOffsetPtg, + }; +} + +/** + * 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, + clientHeight, +}: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}): number { + if (scrollHeight <= clientHeight) { + return 0; + } + + const scrollRange = scrollHeight - clientHeight; + const alignedScrollTop = alignScrollTop(scrollTop, scrollRange); + const scrollTopPtg = alignedScrollTop / scrollRange; + return scrollTopPtg; +} + +export function getElementScrollPercentage(element: HTMLElement | null) { + if (!element) { + return 0; + } + + return getScrollPercentage(element); +} + +/** + * Get node `offsetHeight`. We prefer node is a dom element directly. + * But if not provided, downgrade to `findDOMNode` to get the real dom element. + */ +export function getNodeHeight(node: HTMLElement) { + if (!node) { + return 0; + } + + return findDOMNode(node).offsetHeight; +} + +/** + * Get display items start, end, located item index. This is pure math calculation + */ +export function getRangeIndex(scrollPtg: number, itemCount: number, visibleCount: number) { + const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount); + + const beforeCount = Math.ceil(scrollPtg * visibleCount); + const afterCount = Math.ceil((1 - scrollPtg) * visibleCount); + + return { + itemIndex: index, + itemOffsetPtg: offsetPtg, + startIndex: Math.max(0, index - beforeCount), + endIndex: Math.min(itemCount - 1, index + afterCount), + }; +} + +interface ItemTopConfig { + itemIndex: number; + itemElementHeights: { [key: string]: number }; + itemOffsetPtg: number; + + scrollTop: number; + scrollPtg: number; + clientHeight: number; + + getItemKey: (index: number) => string; +} + +/** + * Calculate the located item related top with current window height + */ +export function getItemRelativeTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights, + scrollPtg, + clientHeight, + getItemKey, +}: Omit) { + const locatedItemHeight = itemElementHeights[getItemKey(itemIndex)] || 0; + const locatedItemTop = scrollPtg * clientHeight; + const locatedItemOffset = itemOffsetPtg * locatedItemHeight; + return Math.floor(locatedItemTop - locatedItemOffset); +} + +/** + * Calculate the located item absolute top with whole scroll height + */ +export function getItemAbsoluteTop({ scrollTop, ...rest }: ItemTopConfig) { + return scrollTop + getItemRelativeTop(rest); +} + +interface CompareItemConfig { + locatedItemRelativeTop: number; + locatedItemIndex: number; + compareItemIndex: number; + getItemKey: (index: number) => string; + startIndex: number; + endIndex: number; + itemElementHeights: { [key: string]: number }; +} + +export function getCompareItemRelativeTop({ + locatedItemRelativeTop, + locatedItemIndex, + compareItemIndex, + startIndex, + endIndex, + getItemKey, + itemElementHeights, +}: CompareItemConfig) { + let originCompareItemTop: number = locatedItemRelativeTop; + const compareItemKey = getItemKey(compareItemIndex); + + if (compareItemIndex <= locatedItemIndex) { + for (let index = locatedItemIndex; index >= startIndex; index -= 1) { + const key = getItemKey(index); + if (key === compareItemKey) { + break; + } + + const prevItemKey = getItemKey(index - 1); + originCompareItemTop -= itemElementHeights[prevItemKey] || 0; + } + } else { + for (let index = locatedItemIndex; index <= endIndex; index += 1) { + const key = getItemKey(index); + if (key === compareItemKey) { + break; + } + + originCompareItemTop += itemElementHeights[key] || 0; + } + } + + return originCompareItemTop; +} diff --git a/tests/index.test.js b/tests/index.test.js deleted file mode 100644 index 8c0e8330..00000000 --- a/tests/index.test.js +++ /dev/null @@ -1,5 +0,0 @@ -describe('Basic', () => { - it('nothing', () => { - // TODO: add test case - }); -}); diff --git a/tests/util.test.js b/tests/util.test.js new file mode 100644 index 00000000..f40014ce --- /dev/null +++ b/tests/util.test.js @@ -0,0 +1,42 @@ +import { getIndexByStartLoc, findListDiffIndex } from '../src/utils/algorithmUtil'; + +describe('Util', () => { + describe('getIndexByStartLoc', () => { + function test(name, min, max, start, expectList) { + it(name, () => { + const len = max - min + 1; + const renderList = new Array(len) + .fill(null) + .map((_, index) => getIndexByStartLoc(min, max, start, index)); + + expect(renderList).toEqual(expectList); + }); + } + + // Balance + test('balance - basic', 0, 2, 1, [1, 2, 0]); + test('balance - moving', 3, 13, 8, [8, 9, 7, 10, 6, 11, 5, 12, 4, 13, 3]); + + // After less + test('after less', 3, 9, 7, [7, 8, 6, 9, 5, 4, 3]); + + // Before less + test('before less', 1, 9, 3, [3, 4, 2, 5, 1, 6, 7, 8, 9]); + }); + + describe('findListDiff', () => { + function test(name, length, diff) { + it(name, () => { + const originList = new Array(length).fill(null).map((_, index) => index); + const targetList = originList.slice(); + targetList.splice(diff, 1); + + expect(findListDiffIndex(originList, targetList, num => num)).toEqual(diff); + }); + } + + for (let i = 0; i < 100; i += 1) { + test(`diff index: ${i}`, 100, i); + } + }); +});