fork 一份 react-tiny-virtual-list 到内部支持宽度自动撑开

This commit is contained in:
wuduoyi 2020-08-12 13:58:57 +08:00
parent cbd56a798e
commit 82e6ea11ce
5 changed files with 804 additions and 3 deletions

View File

@ -66,7 +66,6 @@
"react-progress-2": "^4.4.2",
"react-select": "1.2.1",
"react-textarea-autosize": "5.1.0",
"react-tiny-virtual-list": "^2.2.0",
"react-transition-group": "2.9.0",
"react-visibility-sensor": "3.11.0",
"redux": "^3.7.2",

View File

@ -7,7 +7,7 @@
import {uncontrollable} from 'uncontrollable';
import React from 'react';
import VirtualList from 'react-tiny-virtual-list';
import VirtualList from '../utils/virtual-list';
import Overlay from './Overlay';
import PopOver from './PopOver';
import Downshift, {ControllerStateAndHelpers} from 'downshift';
@ -786,7 +786,7 @@ export class Select extends React.Component<SelectProps, SelectState> {
{filtedOptions.length ? (
<VirtualList
height={280}
height={filtedOptions.length > 8 ? 280 : filtedOptions.length * 35}
itemCount={filtedOptions.length}
itemSize={35}
renderItem={({index, style}) => {

View File

@ -0,0 +1,305 @@
/* Forked from react-virtualized 💖 */
import {ALIGNMENT} from './constants';
export type ItemSizeGetter = (index: number) => number;
export type ItemSize = number | number[] | ItemSizeGetter;
export interface SizeAndPosition {
size: number;
offset: number;
}
interface SizeAndPositionData {
[id: number]: SizeAndPosition;
}
export interface Options {
itemCount: number;
itemSizeGetter: ItemSizeGetter;
estimatedItemSize: number;
}
export default class SizeAndPositionManager {
private itemSizeGetter: ItemSizeGetter;
private itemCount: number;
private estimatedItemSize: number;
private lastMeasuredIndex: number;
private itemSizeAndPositionData: SizeAndPositionData;
constructor({itemCount, itemSizeGetter, estimatedItemSize}: Options) {
this.itemSizeGetter = itemSizeGetter;
this.itemCount = itemCount;
this.estimatedItemSize = estimatedItemSize;
// Cache of size and position data for items, mapped by item index.
this.itemSizeAndPositionData = {};
// Measurements for items up to this index can be trusted; items afterward should be estimated.
this.lastMeasuredIndex = -1;
}
updateConfig({
itemCount,
itemSizeGetter,
estimatedItemSize,
}: Partial<Options>) {
if (itemCount != null) {
this.itemCount = itemCount;
}
if (estimatedItemSize != null) {
this.estimatedItemSize = estimatedItemSize;
}
if (itemSizeGetter != null) {
this.itemSizeGetter = itemSizeGetter;
}
}
getLastMeasuredIndex() {
return this.lastMeasuredIndex;
}
/**
* This method returns the size and position for the item at the specified index.
* It just-in-time calculates (or used cached values) for items leading up to the index.
*/
getSizeAndPositionForIndex(index: number) {
if (index < 0 || index >= this.itemCount) {
throw Error(
`Requested index ${index} is outside of range 0..${this.itemCount}`,
);
}
if (index > this.lastMeasuredIndex) {
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
let offset =
lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;
for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
const size = this.itemSizeGetter(i);
if (size == null || isNaN(size)) {
throw Error(`Invalid size returned for index ${i} of value ${size}`);
}
this.itemSizeAndPositionData[i] = {
offset,
size,
};
offset += size;
}
this.lastMeasuredIndex = index;
}
return this.itemSizeAndPositionData[index];
}
getSizeAndPositionOfLastMeasuredItem() {
return this.lastMeasuredIndex >= 0
? this.itemSizeAndPositionData[this.lastMeasuredIndex]
: {offset: 0, size: 0};
}
/**
* Total size of all items being measured.
* This value will be completedly estimated initially.
* As items as measured the estimate will be updated.
*/
getTotalSize(): number {
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
return (
lastMeasuredSizeAndPosition.offset +
lastMeasuredSizeAndPosition.size +
(this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize
);
}
/**
* Determines a new offset that ensures a certain item is visible, given the alignment.
*
* @param align Desired alignment within container; one of "start" (default), "center", or "end"
* @param containerSize Size (width or height) of the container viewport
* @return Offset to use to ensure the specified item is visible
*/
getUpdatedOffsetForIndex({
align = ALIGNMENT.START,
containerSize,
currentOffset,
targetIndex,
}: {
align: ALIGNMENT | undefined;
containerSize: number;
currentOffset: number;
targetIndex: number;
}): number {
if (containerSize <= 0) {
return 0;
}
const datum = this.getSizeAndPositionForIndex(targetIndex);
const maxOffset = datum.offset;
const minOffset = maxOffset - containerSize + datum.size;
let idealOffset;
switch (align) {
case ALIGNMENT.END:
idealOffset = minOffset;
break;
case ALIGNMENT.CENTER:
idealOffset = maxOffset - (containerSize - datum.size) / 2;
break;
case ALIGNMENT.START:
idealOffset = maxOffset;
break;
default:
idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset));
}
const totalSize = this.getTotalSize();
return Math.max(0, Math.min(totalSize - containerSize, idealOffset));
}
getVisibleRange({
containerSize,
offset,
overscanCount,
}: {
containerSize: number;
offset: number;
overscanCount: number;
}): {start?: number; stop?: number} {
const totalSize = this.getTotalSize();
if (totalSize === 0) {
return {};
}
const maxOffset = offset + containerSize;
let start = this.findNearestItem(offset);
if (typeof start === 'undefined') {
throw Error(`Invalid offset ${offset} specified`);
}
const datum = this.getSizeAndPositionForIndex(start);
offset = datum.offset + datum.size;
let stop = start;
while (offset < maxOffset && stop < this.itemCount - 1) {
stop++;
offset += this.getSizeAndPositionForIndex(stop).size;
}
if (overscanCount) {
start = Math.max(0, start - overscanCount);
stop = Math.min(stop + overscanCount, this.itemCount - 1);
}
return {
start,
stop,
};
}
/**
* Clear all cached values for items after the specified index.
* This method should be called for any item that has changed its size.
* It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called.
*/
resetItem(index: number) {
this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1);
}
/**
* Searches for the item (index) nearest the specified offset.
*
* If no exact match is found the next lowest item index will be returned.
* This allows partially visible items (with offsets just before/above the fold) to be visible.
*/
findNearestItem(offset: number) {
if (isNaN(offset)) {
throw Error(`Invalid offset ${offset} specified`);
}
// Our search algorithms find the nearest match at or below the specified offset.
// So make sure the offset is at least 0 or no match will be found.
offset = Math.max(0, offset);
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex);
if (lastMeasuredSizeAndPosition.offset >= offset) {
// If we've already measured items within this range just use a binary search as it's faster.
return this.binarySearch({
high: lastMeasuredIndex,
low: 0,
offset,
});
} else {
// If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
// The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
// The overall complexity for this approach is O(log n).
return this.exponentialSearch({
index: lastMeasuredIndex,
offset,
});
}
}
private binarySearch({
low,
high,
offset,
}: {
low: number;
high: number;
offset: number;
}) {
let middle = 0;
let currentOffset = 0;
while (low <= high) {
middle = low + Math.floor((high - low) / 2);
currentOffset = this.getSizeAndPositionForIndex(middle).offset;
if (currentOffset === offset) {
return middle;
} else if (currentOffset < offset) {
low = middle + 1;
} else if (currentOffset > offset) {
high = middle - 1;
}
}
if (low > 0) {
return low - 1;
}
return 0;
}
private exponentialSearch({index, offset}: {index: number; offset: number}) {
let interval = 1;
while (
index < this.itemCount &&
this.getSizeAndPositionForIndex(index).offset < offset
) {
index += interval;
interval *= 2;
}
return this.binarySearch({
high: Math.min(index, this.itemCount - 1),
low: Math.floor(index / 2),
offset,
});
}
}

View File

@ -0,0 +1,41 @@
export enum ALIGNMENT {
AUTO = 'auto',
START = 'start',
CENTER = 'center',
END = 'end',
}
export enum DIRECTION {
HORIZONTAL = 'horizontal',
VERTICAL = 'vertical',
}
export enum SCROLL_CHANGE_REASON {
OBSERVED = 'observed',
REQUESTED = 'requested',
}
export const scrollProp = {
[DIRECTION.VERTICAL]: 'scrollTop',
[DIRECTION.HORIZONTAL]: 'scrollLeft',
};
export const sizeProp = {
[DIRECTION.VERTICAL]: 'height',
[DIRECTION.HORIZONTAL]: 'width',
};
export const positionProp = {
[DIRECTION.VERTICAL]: 'top',
[DIRECTION.HORIZONTAL]: 'left',
};
export const marginProp = {
[DIRECTION.VERTICAL]: 'marginTop',
[DIRECTION.HORIZONTAL]: 'marginLeft',
};
export const oppositeMarginProp = {
[DIRECTION.VERTICAL]: 'marginBottom',
[DIRECTION.HORIZONTAL]: 'marginRight',
};

View File

@ -0,0 +1,456 @@
/**
* https://github.com/clauderic/react-tiny-virtual-list 改造,主要是加了宽度自适应
*/
import * as React from 'react';
import {findDOMNode} from 'react-dom';
import * as PropTypes from 'prop-types';
import SizeAndPositionManager, {ItemSize} from './SizeAndPositionManager';
import {
ALIGNMENT,
DIRECTION,
SCROLL_CHANGE_REASON,
marginProp,
oppositeMarginProp,
positionProp,
scrollProp,
sizeProp
} from './constants';
export {DIRECTION as ScrollDirection} from './constants';
export type ItemPosition = 'absolute' | 'sticky';
export interface ItemStyle {
position: ItemPosition;
top?: number;
left: number;
width: string | number;
height?: number;
marginTop?: number;
marginLeft?: number;
marginRight?: number;
marginBottom?: number;
zIndex?: number;
}
interface StyleCache {
[id: number]: ItemStyle;
}
export interface ItemInfo {
index: number;
style: ItemStyle;
}
export interface RenderedRows {
startIndex: number;
stopIndex: number;
}
export interface Props {
className?: string;
estimatedItemSize?: number;
height: number | string;
itemCount: number;
itemSize: ItemSize;
overscanCount?: number;
scrollOffset?: number;
scrollToIndex?: number;
scrollToAlignment?: ALIGNMENT;
scrollDirection?: DIRECTION;
stickyIndices?: number[];
style?: React.CSSProperties;
width?: number | string;
onItemsRendered?({startIndex, stopIndex}: RenderedRows): void;
onScroll?(offset: number, event: UIEvent): void;
renderItem(itemInfo: ItemInfo): React.ReactNode;
}
export interface State {
offset: number;
scrollChangeReason: SCROLL_CHANGE_REASON;
}
const STYLE_WRAPPER: React.CSSProperties = {
overflow: 'auto',
willChange: 'transform',
WebkitOverflowScrolling: 'touch'
};
const STYLE_INNER: React.CSSProperties = {
position: 'relative',
width: 'auto',
whiteSpace: 'nowrap',
minHeight: '100%'
};
const STYLE_ITEM: {
position: ItemStyle['position'];
top: ItemStyle['top'];
left: ItemStyle['left'];
width: ItemStyle['width'];
} = {
position: 'absolute' as ItemPosition,
top: 0,
left: 0,
width: '100%'
};
const STYLE_STICKY_ITEM = {
...STYLE_ITEM,
position: 'sticky' as ItemPosition
};
export default class VirtualList extends React.PureComponent<Props, State> {
static defaultProps = {
overscanCount: 3,
scrollDirection: DIRECTION.VERTICAL,
width: '100%'
};
static propTypes = {
estimatedItemSize: PropTypes.number,
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
.isRequired,
itemCount: PropTypes.number.isRequired,
itemSize: PropTypes.oneOfType([
PropTypes.number,
PropTypes.array,
PropTypes.func
]).isRequired,
onScroll: PropTypes.func,
onItemsRendered: PropTypes.func,
overscanCount: PropTypes.number,
renderItem: PropTypes.func.isRequired,
scrollOffset: PropTypes.number,
scrollToIndex: PropTypes.number,
scrollToAlignment: PropTypes.oneOf([
ALIGNMENT.AUTO,
ALIGNMENT.START,
ALIGNMENT.CENTER,
ALIGNMENT.END
]),
scrollDirection: PropTypes.oneOf([
DIRECTION.HORIZONTAL,
DIRECTION.VERTICAL
]),
stickyIndices: PropTypes.arrayOf(PropTypes.number),
style: PropTypes.object,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
};
itemSizeGetter = (itemSize: Props['itemSize']) => {
return index => this.getSize(index, itemSize);
};
sizeAndPositionManager = new SizeAndPositionManager({
itemCount: this.props.itemCount,
itemSizeGetter: this.itemSizeGetter(this.props.itemSize),
estimatedItemSize: this.getEstimatedItemSize()
});
readonly state: State = {
offset:
this.props.scrollOffset ||
(this.props.scrollToIndex != null &&
this.getOffsetForIndex(this.props.scrollToIndex)) ||
0,
scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED
};
private rootNode: HTMLElement;
private styleCache: StyleCache = {};
componentDidMount() {
const {scrollOffset, scrollToIndex} = this.props;
this.rootNode.addEventListener('scroll', this.handleScroll, {
passive: true
});
this.updateRootWidth();
if (scrollOffset != null) {
this.scrollTo(scrollOffset);
} else if (scrollToIndex != null) {
this.scrollTo(this.getOffsetForIndex(scrollToIndex));
}
}
// 自适应宽度
updateRootWidth(isDidUpdate: boolean = false) {
let scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth || 15;
if (isDidUpdate) {
scrollbarWidth = 0;
}
const itemsDom = this.rootNode.children[0].children;
const containerWidth = this.rootNode.parentElement!.getBoundingClientRect()
.width;
let maxItemWidth = 0;
for (let i = 0; i < itemsDom.length; i++) {
let itemWidth = itemsDom[i].getBoundingClientRect().width;
if (itemWidth > maxItemWidth) {
maxItemWidth = itemWidth;
}
}
if (containerWidth >= maxItemWidth) {
this.rootNode.style.width = containerWidth + 'px';
} else {
this.rootNode.style.width = maxItemWidth + scrollbarWidth + 'px';
}
}
componentWillReceiveProps(nextProps: Props) {
const {
estimatedItemSize,
itemCount,
itemSize,
scrollOffset,
scrollToAlignment,
scrollToIndex
} = this.props;
const scrollPropsHaveChanged =
nextProps.scrollToIndex !== scrollToIndex ||
nextProps.scrollToAlignment !== scrollToAlignment;
const itemPropsHaveChanged =
nextProps.itemCount !== itemCount ||
nextProps.itemSize !== itemSize ||
nextProps.estimatedItemSize !== estimatedItemSize;
if (nextProps.itemSize !== itemSize) {
this.sizeAndPositionManager.updateConfig({
itemSizeGetter: this.itemSizeGetter(nextProps.itemSize)
});
}
if (
nextProps.itemCount !== itemCount ||
nextProps.estimatedItemSize !== estimatedItemSize
) {
this.sizeAndPositionManager.updateConfig({
itemCount: nextProps.itemCount,
estimatedItemSize: this.getEstimatedItemSize(nextProps)
});
}
if (itemPropsHaveChanged) {
this.recomputeSizes();
}
if (nextProps.scrollOffset !== scrollOffset) {
this.setState({
offset: nextProps.scrollOffset || 0,
scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED
});
} else if (
typeof nextProps.scrollToIndex === 'number' &&
(scrollPropsHaveChanged || itemPropsHaveChanged)
) {
this.setState({
offset: this.getOffsetForIndex(
nextProps.scrollToIndex,
nextProps.scrollToAlignment,
nextProps.itemCount
),
scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED
});
}
}
componentDidUpdate(_: Props, prevState: State) {
this.updateRootWidth(true);
const {offset, scrollChangeReason} = this.state;
if (
prevState.offset !== offset &&
scrollChangeReason === SCROLL_CHANGE_REASON.REQUESTED
) {
this.scrollTo(offset);
}
}
componentWillUnmount() {
this.rootNode.removeEventListener('scroll', this.handleScroll);
}
scrollTo(value: number) {
const {scrollDirection = DIRECTION.VERTICAL} = this.props;
this.rootNode[scrollProp[scrollDirection]] = value;
}
getOffsetForIndex(
index: number,
scrollToAlignment = this.props.scrollToAlignment,
itemCount: number = this.props.itemCount
): number {
const {scrollDirection = DIRECTION.VERTICAL} = this.props;
if (index < 0 || index >= itemCount) {
index = 0;
}
return this.sizeAndPositionManager.getUpdatedOffsetForIndex({
align: scrollToAlignment,
containerSize: this.props[sizeProp[scrollDirection]],
currentOffset: (this.state && this.state.offset) || 0,
targetIndex: index
});
}
recomputeSizes(startIndex = 0) {
this.styleCache = {};
this.sizeAndPositionManager.resetItem(startIndex);
}
render() {
const {
estimatedItemSize,
height,
overscanCount = 3,
renderItem,
itemCount,
itemSize,
onItemsRendered,
onScroll,
scrollDirection = DIRECTION.VERTICAL,
scrollOffset,
scrollToIndex,
scrollToAlignment,
stickyIndices,
style,
width,
...props
} = this.props;
const {offset} = this.state;
const {start, stop} = this.sizeAndPositionManager.getVisibleRange({
containerSize: this.props[sizeProp[scrollDirection]] || 0,
offset,
overscanCount
});
const items: React.ReactNode[] = [];
const wrapperStyle = {...STYLE_WRAPPER, ...style, height, width};
const innerStyle = {
...STYLE_INNER,
[sizeProp[scrollDirection]]: this.sizeAndPositionManager.getTotalSize()
};
if (stickyIndices != null && stickyIndices.length !== 0) {
stickyIndices.forEach((index: number) =>
items.push(
renderItem({
index,
style: this.getStyle(index, true)
})
)
);
if (scrollDirection === DIRECTION.HORIZONTAL) {
innerStyle.display = 'flex';
}
}
if (typeof start !== 'undefined' && typeof stop !== 'undefined') {
for (let index = start; index <= stop; index++) {
if (stickyIndices != null && stickyIndices.includes(index)) {
continue;
}
items.push(
renderItem({
index,
style: this.getStyle(index, false)
})
);
}
if (typeof onItemsRendered === 'function') {
onItemsRendered({
startIndex: start,
stopIndex: stop
});
}
}
return (
<div ref={this.getRef} {...props} style={wrapperStyle}>
<div style={innerStyle}>{items}</div>
</div>
);
}
private getRef = (node: HTMLDivElement): void => {
this.rootNode = node;
};
private handleScroll = (event: UIEvent) => {
const {onScroll} = this.props;
const offset = this.getNodeOffset();
if (
offset < 0 ||
this.state.offset === offset ||
event.target !== this.rootNode
) {
return;
}
this.setState({
offset,
scrollChangeReason: SCROLL_CHANGE_REASON.OBSERVED
});
if (typeof onScroll === 'function') {
onScroll(offset, event);
}
};
private getNodeOffset() {
const {scrollDirection = DIRECTION.VERTICAL} = this.props;
return this.rootNode[scrollProp[scrollDirection]];
}
private getEstimatedItemSize(props = this.props) {
return (
props.estimatedItemSize ||
(typeof props.itemSize === 'number' && props.itemSize) ||
50
);
}
private getSize(index: number, itemSize) {
if (typeof itemSize === 'function') {
return itemSize(index);
}
return Array.isArray(itemSize) ? itemSize[index] : itemSize;
}
private getStyle(index: number, sticky: boolean) {
const style = this.styleCache[index];
if (style) {
return style;
}
const {scrollDirection = DIRECTION.VERTICAL} = this.props;
const {
size,
offset
} = this.sizeAndPositionManager.getSizeAndPositionForIndex(index);
return (this.styleCache[index] = sticky
? {
...STYLE_STICKY_ITEM,
[sizeProp[scrollDirection]]: size,
[marginProp[scrollDirection]]: offset,
[oppositeMarginProp[scrollDirection]]: -(offset + size),
zIndex: 1
}
: {
...STYLE_ITEM,
[sizeProp[scrollDirection]]: size,
[positionProp[scrollDirection]]: offset
});
}
}