|
| 1 | +import { isUndefined } from 'lodash'; |
| 2 | +import React, { useCallback, useEffect, useMemo } from 'react'; |
| 3 | +import { useImmer } from 'use-immer'; |
| 4 | + |
| 5 | +import { useDComponentConfig, useElement, useId, useThrottle, useAsync } from '../../hooks'; |
| 6 | + |
| 7 | +export interface DVirtualScrollProps<T> { |
| 8 | + dTag?: string; |
| 9 | + dWidth?: string | number; |
| 10 | + dHeight?: string | number; |
| 11 | + dItemWidth?: number; |
| 12 | + dItemHeight?: number; |
| 13 | + dList: T[]; |
| 14 | + dRenderItem: (item: T, index: number) => React.ReactNode; |
| 15 | + dCustomSize?: (item: T, index: number) => number; |
| 16 | + onScrollEnd?: () => void; |
| 17 | + [index: string]: unknown; |
| 18 | +} |
| 19 | + |
| 20 | +export function DVirtualScroll<T>(props: DVirtualScrollProps<T>) { |
| 21 | + const { |
| 22 | + dTag = 'div', |
| 23 | + dWidth, |
| 24 | + dHeight, |
| 25 | + dItemWidth, |
| 26 | + dItemHeight, |
| 27 | + dList, |
| 28 | + dRenderItem, |
| 29 | + dCustomSize, |
| 30 | + onScrollEnd, |
| 31 | + style, |
| 32 | + onScroll, |
| 33 | + ...restProps |
| 34 | + } = useDComponentConfig('virtual-scroll', props); |
| 35 | + |
| 36 | + const { throttleByAnimationFrame } = useThrottle(); |
| 37 | + const asyncCapture = useAsync(); |
| 38 | + |
| 39 | + //#region States. |
| 40 | + /* |
| 41 | + * @see https://reactjs.org/docs/state-and-lifecycle.html |
| 42 | + * |
| 43 | + * - Vue: data. |
| 44 | + * @see https://v3.vuejs.org/api/options-data.html#data-2 |
| 45 | + * - Angular: property on a class. |
| 46 | + * @example |
| 47 | + * export class HeroChildComponent { |
| 48 | + * public data: 'example'; |
| 49 | + * } |
| 50 | + */ |
| 51 | + const id = useId(); |
| 52 | + |
| 53 | + const [list, setList] = useImmer<React.ReactNode[]>([]); |
| 54 | + const [fillSize, setFillSize] = useImmer<[React.CSSProperties, React.CSSProperties]>([{}, {}]); |
| 55 | + //#endregion |
| 56 | + |
| 57 | + //#region Element |
| 58 | + const el = useElement(`[data-d-virtual-scroll-${id}]`); |
| 59 | + const referenceEl = useElement(`[data-d-virtual-scroll-reference-${id}]`); |
| 60 | + //#endregion |
| 61 | + |
| 62 | + //#region Getters. |
| 63 | + /* |
| 64 | + * When the dependency changes, recalculate the value. |
| 65 | + * In React, usually use `useMemo` to handle this situation. |
| 66 | + * Notice: `useCallback` also as getter that target at function. |
| 67 | + * |
| 68 | + * - Vue: computed. |
| 69 | + * @see https://v3.vuejs.org/guide/computed.html#computed-properties |
| 70 | + * - Angular: get property on a class. |
| 71 | + * @example |
| 72 | + * // ReactConvertService is a service that implement the |
| 73 | + * // methods when need to convert react to angular. |
| 74 | + * export class HeroChildComponent { |
| 75 | + * public get data():string { |
| 76 | + * return this.reactConvert.useMemo(factory, [deps]); |
| 77 | + * } |
| 78 | + * |
| 79 | + * constructor(private reactConvert: ReactConvertService) {} |
| 80 | + * } |
| 81 | + */ |
| 82 | + const itemSizeArr = useMemo(() => (dCustomSize ? dList.map((item, index) => dCustomSize(item, index)) : []), [dCustomSize, dList]); |
| 83 | + |
| 84 | + const updateList = useCallback(() => { |
| 85 | + if (el.current) { |
| 86 | + const scrollSize = isUndefined(dWidth) ? el.current.scrollTop : el.current.scrollLeft; |
| 87 | + const rect = el.current.getBoundingClientRect(); |
| 88 | + const size = isUndefined(dWidth) ? dItemHeight ?? rect.height : dItemWidth ?? rect.width; |
| 89 | + if (isUndefined(dCustomSize)) { |
| 90 | + if (referenceEl.current) { |
| 91 | + const itemSize = isUndefined(dWidth) |
| 92 | + ? referenceEl.current.getBoundingClientRect().height |
| 93 | + : referenceEl.current.getBoundingClientRect().width; |
| 94 | + |
| 95 | + if (size && itemSize) { |
| 96 | + const startIndex = Math.max(~~(scrollSize / itemSize) - 2, 1); |
| 97 | + const endIndex = Math.min(~~(scrollSize / itemSize) + ~~(size / itemSize) + 1 + 2, dList.length); |
| 98 | + setList(dList.slice(startIndex, endIndex).map((item, index) => dRenderItem(item, startIndex + index))); |
| 99 | + |
| 100 | + setFillSize([ |
| 101 | + { [isUndefined(dWidth) ? 'height' : 'width']: Math.max(itemSize * (startIndex - 1), 0) }, |
| 102 | + { [isUndefined(dWidth) ? 'height' : 'width']: Math.max(itemSize * (dList.length - 1 - endIndex), 0) }, |
| 103 | + ]); |
| 104 | + if (scrollSize + size >= dList.length * itemSize) { |
| 105 | + onScrollEnd?.(); |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + } else { |
| 110 | + if (size) { |
| 111 | + let accumulateSize = 0; |
| 112 | + let startInfo: [number, number] | undefined; |
| 113 | + let endInfo: [number, number] | undefined; |
| 114 | + for (const [index, itemSize] of itemSizeArr.entries()) { |
| 115 | + accumulateSize += itemSize; |
| 116 | + if (accumulateSize > scrollSize && isUndefined(startInfo)) { |
| 117 | + startInfo = [index, accumulateSize]; |
| 118 | + } |
| 119 | + if (accumulateSize > scrollSize + size && isUndefined(endInfo)) { |
| 120 | + endInfo = [index, accumulateSize]; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + const startIndex = Math.max((startInfo?.[0] ?? 0) - 2, 0); |
| 125 | + const endIndex = Math.min((endInfo?.[0] ?? dList.length) + 1 + 2, dList.length); |
| 126 | + setList(dList.slice(startIndex, endIndex).map((item, index) => dRenderItem(item, startIndex + index))); |
| 127 | + |
| 128 | + const preFillSize = Math.max( |
| 129 | + startInfo |
| 130 | + ? (startInfo[1] ?? 0) - |
| 131 | + (itemSizeArr[startInfo[0]] ?? 0) - |
| 132 | + (itemSizeArr[startInfo[0] - 1] ?? 0) - |
| 133 | + (itemSizeArr[startInfo[0] - 2] ?? 0) |
| 134 | + : 0, |
| 135 | + 0 |
| 136 | + ); |
| 137 | + const sufFillSize = Math.max( |
| 138 | + endInfo ? accumulateSize - endInfo[1] - (itemSizeArr[endInfo[0] + 1] ?? 0) - (itemSizeArr[endInfo[0] + 2] ?? 0) : 0, |
| 139 | + 0 |
| 140 | + ); |
| 141 | + setFillSize([ |
| 142 | + { [isUndefined(dWidth) ? 'height' : 'width']: preFillSize }, |
| 143 | + { [isUndefined(dWidth) ? 'height' : 'width']: sufFillSize }, |
| 144 | + ]); |
| 145 | + if (scrollSize + size >= accumulateSize) { |
| 146 | + onScrollEnd?.(); |
| 147 | + } |
| 148 | + } |
| 149 | + } |
| 150 | + } |
| 151 | + }, [el, dWidth, dItemHeight, dItemWidth, dCustomSize, referenceEl, dList, setList, setFillSize, onScrollEnd, dRenderItem, itemSizeArr]); |
| 152 | + |
| 153 | + const reference = useMemo(() => { |
| 154 | + if (dList[0] && isUndefined(dCustomSize)) { |
| 155 | + const _reference = dRenderItem(dList[0], 0) as React.ReactElement; |
| 156 | + const [asyncGroup] = asyncCapture.createGroup('reference'); |
| 157 | + asyncGroup.setTimeout(() => { |
| 158 | + if (referenceEl.current) { |
| 159 | + asyncGroup.onResize(referenceEl.current, () => throttleByAnimationFrame(updateList)); |
| 160 | + } |
| 161 | + }, 20); |
| 162 | + return React.cloneElement(_reference, { |
| 163 | + ..._reference.props, |
| 164 | + [`data-d-virtual-scroll-reference-${id}`]: 'true', |
| 165 | + }); |
| 166 | + } |
| 167 | + }, [asyncCapture, dCustomSize, dList, dRenderItem, id, referenceEl, throttleByAnimationFrame, updateList]); |
| 168 | + |
| 169 | + const handleScroll = useCallback( |
| 170 | + (e) => { |
| 171 | + (onScroll as React.UIEventHandler<HTMLElement>)?.(e); |
| 172 | + throttleByAnimationFrame(updateList); |
| 173 | + }, |
| 174 | + [onScroll, throttleByAnimationFrame, updateList] |
| 175 | + ); |
| 176 | + //#endregion |
| 177 | + |
| 178 | + //#region DidUpdate. |
| 179 | + /* |
| 180 | + * We need a service(ReactConvertService) that implement useEffect. |
| 181 | + * @see https://reactjs.org/docs/hooks-effect.html |
| 182 | + * |
| 183 | + * - Vue: onUpdated. |
| 184 | + * @see https://v3.vuejs.org/api/composition-api.html#lifecycle-hooks |
| 185 | + * - Angular: ngDoCheck. |
| 186 | + * @see https://angular.io/api/core/DoCheck |
| 187 | + */ |
| 188 | + useEffect(() => { |
| 189 | + throttleByAnimationFrame(updateList); |
| 190 | + }, [throttleByAnimationFrame, updateList]); |
| 191 | + //#endregion |
| 192 | + |
| 193 | + return React.createElement( |
| 194 | + dTag, |
| 195 | + { |
| 196 | + ...restProps, |
| 197 | + style: { |
| 198 | + ...(style as React.CSSProperties), |
| 199 | + width: dWidth, |
| 200 | + height: dHeight, |
| 201 | + whiteSpace: isUndefined(dWidth) ? undefined : 'nowrap', |
| 202 | + overflowX: isUndefined(dWidth) ? undefined : 'auto', |
| 203 | + overflowY: isUndefined(dHeight) ? undefined : 'auto', |
| 204 | + }, |
| 205 | + [`data-d-virtual-scroll-${id}`]: 'true', |
| 206 | + onScroll: handleScroll, |
| 207 | + }, |
| 208 | + [ |
| 209 | + reference, |
| 210 | + <div |
| 211 | + key={`d-virtual-scroll-pre-fill-${id}`} |
| 212 | + style={{ |
| 213 | + ...fillSize[0], |
| 214 | + display: isUndefined(dWidth) ? undefined : 'inline-block', |
| 215 | + }} |
| 216 | + ></div>, |
| 217 | + ...list, |
| 218 | + <div |
| 219 | + key={`d-virtual-scroll-sub-fill-${id}`} |
| 220 | + style={{ |
| 221 | + ...fillSize[1], |
| 222 | + display: isUndefined(dWidth) ? undefined : 'inline-block', |
| 223 | + }} |
| 224 | + ></div>, |
| 225 | + ] |
| 226 | + ); |
| 227 | +} |
0 commit comments