ant-design-vue/components/vc-virtual-list/List.tsx
2020-10-10 18:16:28 +08:00

405 lines
11 KiB
TypeScript

import {
ref,
defineComponent,
PropType,
watchEffect,
Component,
computed,
nextTick,
onBeforeUnmount,
reactive,
CSSProperties,
} from 'vue';
import { Key } from '../_util/type';
import Filler from './Filler';
import Item from './Item';
import ScrollBar from './ScrollBar';
import useHeights from './hooks/useHeights';
import useScrollTo from './hooks/useScrollTo';
import useFrameWheel from './hooks/useFrameWheel';
import useMobileTouchMove from './hooks/useMobileTouchMove';
import useOriginScroll from './hooks/useOriginScroll';
import PropTypes from '../_util/vue-types';
import classNames from '../_util/classNames';
import { RenderFunc, SharedConfig } from './interface';
const EMPTY_DATA = [];
const ScrollStyle: CSSProperties = {
overflowY: 'auto',
overflowAnchor: 'none',
};
function renderChildren<T>(
list: T[],
startIndex: number,
endIndex: number,
setNodeRef: (item: T, element: HTMLElement) => void,
renderFunc: RenderFunc<T>,
{ getKey }: SharedConfig<T>,
) {
return list.slice(startIndex, endIndex + 1).map((item, index) => {
const eleIndex = startIndex + index;
const node = renderFunc(item, eleIndex, {
// style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
});
const key = getKey(item);
return (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
}
export interface ListState<T = object> {
scrollTop: number;
scrollMoving: boolean;
mergedData: T[];
}
const List = defineComponent({
inheritAttrs: false,
name: 'List',
props: {
prefixCls: PropTypes.string,
data: PropTypes.array,
height: PropTypes.number,
itemHeight: PropTypes.number,
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight: PropTypes.looseBool,
itemKey: {
type: [String, Number, Function] as PropType<Key | ((item: object) => Key)>,
required: true,
},
component: {
type: [String, Object] as PropType<string | Component>,
},
/** Set `false` will always use real scroll instead of virtual one */
virtual: PropTypes.looseBool,
children: PropTypes.func,
onScroll: PropTypes.func,
onMousedown: PropTypes.func,
onMouseenter: PropTypes.func,
},
setup(props) {
// ================================= MISC =================================
const inVirtual = computed(() => {
const { height, itemHeight, data, virtual } = props;
return !!(
virtual !== false &&
height &&
itemHeight &&
data &&
itemHeight * data.length > height
);
});
const state = reactive<ListState>({
scrollTop: 0,
scrollMoving: false,
mergedData: computed(() => props.data || EMPTY_DATA) as any,
});
const componentRef = ref<Element>();
// =============================== Item Key ===============================
const getKey = (item: Record<string, any>) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item);
}
return item[props.itemKey];
};
const sharedConfig = {
getKey,
};
// ================================ Scroll ================================
function syncScrollTop(newTop: number | ((prev: number) => number)) {
let value: number;
if (typeof newTop === 'function') {
value = newTop(state.scrollTop);
} else {
value = newTop;
}
const alignedTop = keepInRange(value);
if (componentRef.value) {
componentRef.value.scrollTop = alignedTop;
}
state.scrollTop = alignedTop;
}
// ================================ Height ================================
const [setInstance, collectHeight, heights] = useHeights(getKey, null, null);
// ========================== Visible Calculation =========================
const calRes = computed(() => {
if (!inVirtual.value) {
return {
scrollHeight: undefined,
start: 0,
end: state.mergedData.length - 1,
offset: undefined,
};
}
let itemTop = 0;
let startIndex: number | undefined;
let startOffset: number | undefined;
let endIndex: number | undefined;
const dataLen = state.mergedData.length;
for (let i = 0; i < dataLen; i += 1) {
const item = state.mergedData[i];
const key = getKey(item);
const cacheHeight = heights[key];
const currentItemBottom =
itemTop + (cacheHeight === undefined ? props.itemHeight! : cacheHeight);
if (currentItemBottom >= state.scrollTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
}
// Check item bottom in the range. We will render additional one item for motion usage
if (currentItemBottom > state.scrollTop + props.height! && endIndex === undefined) {
endIndex = i;
}
itemTop = currentItemBottom;
}
// Fallback to normal if not match. This code should never reach
/* istanbul ignore next */
if (startIndex === undefined) {
startIndex = 0;
startOffset = 0;
}
if (endIndex === undefined) {
endIndex = state.mergedData.length - 1;
}
// Give cache to improve scroll experience
endIndex = Math.min(endIndex + 1, state.mergedData.length);
return {
scrollHeight: itemTop,
start: startIndex,
end: endIndex,
offset: startOffset,
};
});
// =============================== In Range ===============================
const maxScrollHeight = computed(() => calRes.value.scrollHeight! - props.height!);
function keepInRange(newScrollTop: number) {
let newTop = Math.max(newScrollTop, 0);
if (!Number.isNaN(maxScrollHeight.value)) {
newTop = Math.min(newTop, maxScrollHeight.value);
}
return newTop;
}
const isScrollAtTop = computed(() => state.scrollTop <= 0);
const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value);
const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom);
// ================================ Scroll ================================
function onScrollBar(newScrollTop: number) {
const newTop = newScrollTop;
syncScrollTop(newTop);
}
// This code may only trigger in test case.
// But we still need a sync if some special escape
function onFallbackScroll(e: UIEvent) {
const { scrollTop: newScrollTop } = e.currentTarget as Element;
if (newScrollTop !== state.scrollTop) {
syncScrollTop(newScrollTop);
}
// Trigger origin onScroll
props.onScroll?.(e);
}
// Since this added in global,should use ref to keep update
const [onRawWheel, onFireFoxScroll] = useFrameWheel(
inVirtual,
isScrollAtTop,
isScrollAtBottom,
offsetY => {
syncScrollTop(top => {
const newTop = top + offsetY;
return newTop;
});
},
);
// Mobile touch move
useMobileTouchMove(inVirtual, componentRef, (deltaY, smoothOffset) => {
if (originScroll(deltaY, smoothOffset)) {
return false;
}
onRawWheel({ preventDefault() {}, deltaY } as WheelEvent);
return true;
});
// Firefox only
function onMozMousePixelScroll(e: MouseEvent) {
if (inVirtual.value) {
e.preventDefault();
}
}
const removeEventListener = () => {
if (componentRef.value) {
componentRef.value.removeEventListener('wheel', onRawWheel);
componentRef.value.removeEventListener('DOMMouseScroll' as any, onFireFoxScroll);
componentRef.value.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll);
}
};
watchEffect(() => {
nextTick(() => {
if (componentRef.value) {
removeEventListener();
componentRef.value.addEventListener('wheel', onRawWheel);
componentRef.value.addEventListener('DOMMouseScroll' as any, onFireFoxScroll);
componentRef.value.addEventListener('MozMousePixelScroll', onMozMousePixelScroll);
}
});
});
onBeforeUnmount(() => {
removeEventListener();
});
// ================================= Ref ==================================
const scrollTo = useScrollTo(
componentRef,
state,
heights,
props,
getKey,
collectHeight,
syncScrollTop,
);
const componentStyle = computed(() => {
let cs: CSSProperties | null = null;
if (props.height) {
cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle };
if (inVirtual.value) {
cs!.overflowY = 'hidden';
if (state.scrollMoving) {
cs!.pointerEvents = 'none';
}
}
}
return cs;
});
return {
state,
componentStyle,
scrollTo,
onFallbackScroll,
onScrollBar,
componentRef,
inVirtual,
calRes,
collectHeight,
setInstance,
sharedConfig,
};
},
render() {
const {
prefixCls = 'rc-virtual-list',
height,
itemHeight,
// eslint-disable-next-line no-unused-vars
fullHeight,
data,
itemKey,
virtual,
component: Component = 'div',
onScroll,
children,
style,
class: className,
...restProps
} = { ...this.$props, ...this.$attrs } as any;
const mergedClassName = classNames(prefixCls, className);
const { scrollTop, mergedData } = this.state;
const { scrollHeight, offset, start, end } = this.calRes;
const {
componentStyle,
onFallbackScroll,
onScrollBar,
inVirtual,
collectHeight,
sharedConfig,
setInstance,
} = this;
const listChildren = renderChildren(
mergedData,
start,
end,
setInstance,
children,
sharedConfig,
);
return (
<div
style={{
...style,
position: 'relative',
}}
class={mergedClassName}
{...restProps}
>
<Component
class={`${prefixCls}-holder`}
style={componentStyle}
ref="componentRef"
onScroll={onFallbackScroll}
>
<Filler
prefixCls={prefixCls}
height={scrollHeight}
offset={offset}
onInnerResize={collectHeight}
>
{listChildren}
</Filler>
</Component>
{inVirtual && (
<ScrollBar
prefixCls={prefixCls}
scrollTop={scrollTop}
height={height}
scrollHeight={scrollHeight}
count={mergedData.length}
onScroll={onScrollBar}
onStartMove={() => {
this.state.scrollMoving = true;
}}
onStopMove={() => {
this.state.scrollMoving = false;
}}
/>
)}
</div>
);
},
});
export default List;