ant-design-vue/components/vc-virtual-list/List.tsx

538 lines
15 KiB
Vue

import type { PropType, Component, CSSProperties } from 'vue';
import {
shallowRef,
toRaw,
onMounted,
onUpdated,
ref,
defineComponent,
watchEffect,
computed,
nextTick,
onBeforeUnmount,
reactive,
watch,
} from 'vue';
import type { 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 type { RenderFunc, SharedConfig } from './interface';
import supportsPassive from '../_util/supportsPassive';
const EMPTY_DATA = [];
const ScrollStyle: CSSProperties = {
overflowY: 'auto',
overflowAnchor: 'none',
};
export type ScrollAlign = 'top' | 'bottom' | 'auto';
export type ScrollConfig =
| {
index: number;
align?: ScrollAlign;
offset?: number;
}
| {
key: Key;
align?: ScrollAlign;
offset?: number;
};
export type ScrollTo = (arg: number | ScrollConfig) => void;
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 as HTMLElement)}>
{node}
</Item>
);
});
}
export interface ListState {
scrollTop: number;
scrollMoving: boolean;
}
const List = defineComponent({
name: 'List',
inheritAttrs: false,
props: {
prefixCls: String,
data: PropTypes.array,
height: Number,
itemHeight: Number,
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight: { type: Boolean, default: undefined },
itemKey: {
type: [String, Number, Function] as PropType<Key | ((item: Record<string, any>) => Key)>,
required: true,
},
component: {
type: [String, Object] as PropType<string | Component>,
},
/** Set `false` will always use real scroll instead of virtual one */
virtual: { type: Boolean, default: undefined },
children: Function,
onScroll: Function,
onMousedown: Function,
onMouseenter: Function,
onVisibleChange: Function as PropType<(visibleList: any[], fullList: any[]) => void>,
},
setup(props, { expose }) {
// ================================= MISC =================================
const useVirtual = computed(() => {
const { height, itemHeight, virtual } = props;
return !!(virtual !== false && height && itemHeight);
});
const inVirtual = computed(() => {
const { height, itemHeight, data } = props;
return useVirtual.value && data && itemHeight * data.length > height;
});
const state = reactive<ListState>({
scrollTop: 0,
scrollMoving: false,
});
const data = computed(() => {
return props.data || EMPTY_DATA;
});
const mergedData = shallowRef([]);
watch(
data,
() => {
mergedData.value = toRaw(data.value).slice();
},
{ immediate: true },
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const itemKey = shallowRef((_item: Record<string, any>) => undefined);
watch(
() => props.itemKey,
val => {
if (typeof val === 'function') {
itemKey.value = val;
} else {
itemKey.value = item => item?.[val];
}
},
{ immediate: true },
);
const componentRef = ref<HTMLDivElement>();
const fillerInnerRef = ref<HTMLDivElement>();
const scrollBarRef = ref<any>(); // Hack on scrollbar to enable flash call
// =============================== Item Key ===============================
const getKey = (item: Record<string, any>) => {
return itemKey.value(item);
};
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, updatedMark] = useHeights(
mergedData,
getKey,
null,
null,
);
const calRes = reactive<{
scrollHeight?: number;
start?: number;
end?: number;
offset?: number;
}>({
scrollHeight: undefined,
start: 0,
end: 0,
offset: undefined,
});
const offsetHeight = ref(0);
onMounted(() => {
nextTick(() => {
offsetHeight.value = fillerInnerRef.value?.offsetHeight || 0;
});
});
onUpdated(() => {
nextTick(() => {
offsetHeight.value = fillerInnerRef.value?.offsetHeight || 0;
});
});
watch(
[useVirtual, mergedData],
() => {
if (!useVirtual.value) {
Object.assign(calRes, {
scrollHeight: undefined,
start: 0,
end: mergedData.value.length - 1,
offset: undefined,
});
}
},
{ immediate: true },
);
watch(
[useVirtual, mergedData, offsetHeight, inVirtual],
() => {
// Always use virtual scroll bar in avoid shaking
if (useVirtual.value && !inVirtual.value) {
Object.assign(calRes, {
scrollHeight: offsetHeight.value,
start: 0,
end: mergedData.value.length - 1,
offset: undefined,
});
}
},
{ immediate: true },
);
watch(
[
inVirtual,
useVirtual,
() => state.scrollTop,
mergedData,
updatedMark,
() => props.height,
offsetHeight,
],
() => {
if (!useVirtual.value || !inVirtual.value) {
return;
}
let itemTop = 0;
let startIndex: number | undefined;
let startOffset: number | undefined;
let endIndex: number | undefined;
const dataLen = mergedData.value.length;
const data = mergedData.value;
const scrollTop = state.scrollTop;
const { itemHeight, height } = props;
const scrollTopHeight = scrollTop + height;
for (let i = 0; i < dataLen; i += 1) {
const item = data[i];
const key = getKey(item);
let cacheHeight = heights.get(key);
if (cacheHeight === undefined) {
cacheHeight = itemHeight;
}
const currentItemBottom = itemTop + cacheHeight;
if (startIndex === undefined && currentItemBottom >= scrollTop) {
startIndex = i;
startOffset = itemTop;
}
// Check item bottom in the range. We will render additional one item for motion usage
if (endIndex === undefined && currentItemBottom > scrollTopHeight) {
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 = dataLen - 1;
}
// Give cache to improve scroll experience
endIndex = Math.min(endIndex + 1, dataLen);
Object.assign(calRes, {
scrollHeight: itemTop,
start: startIndex,
end: endIndex,
offset: startOffset,
});
},
{ immediate: true },
);
// =============================== In Range ===============================
const maxScrollHeight = computed(() => calRes.scrollHeight! - props.height!);
function keepInRange(newScrollTop: number) {
let newTop = newScrollTop;
if (!Number.isNaN(maxScrollHeight.value)) {
newTop = Math.min(newTop, maxScrollHeight.value);
}
newTop = Math.max(newTop, 0);
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);
}
// When data size reduce. It may trigger native scroll event back to fit scroll position
function onFallbackScroll(e: UIEvent) {
const { scrollTop: newScrollTop } = e.currentTarget as Element;
if (Math.abs(newScrollTop - state.scrollTop) >= 1) {
syncScrollTop(newScrollTop);
}
// Trigger origin onScroll
props.onScroll?.(e);
}
// Since this added in global,should use ref to keep update
const [onRawWheel, onFireFoxScroll] = useFrameWheel(
useVirtual,
isScrollAtTop,
isScrollAtBottom,
offsetY => {
syncScrollTop(top => {
const newTop = top + offsetY;
return newTop;
});
},
);
// Mobile touch move
useMobileTouchMove(useVirtual, componentRef, (deltaY, smoothOffset) => {
if (originScroll(deltaY, smoothOffset)) {
return false;
}
onRawWheel({ preventDefault() {}, deltaY } as WheelEvent);
return true;
});
// Firefox only
function onMozMousePixelScroll(e: MouseEvent) {
if (useVirtual.value) {
e.preventDefault();
}
}
const removeEventListener = () => {
if (componentRef.value) {
componentRef.value.removeEventListener(
'wheel',
onRawWheel,
supportsPassive ? ({ passive: false } as EventListenerOptions) : false,
);
componentRef.value.removeEventListener('DOMMouseScroll', onFireFoxScroll as any);
componentRef.value.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll as any);
}
};
watchEffect(() => {
nextTick(() => {
if (componentRef.value) {
removeEventListener();
componentRef.value.addEventListener(
'wheel',
onRawWheel,
supportsPassive ? ({ passive: false } as EventListenerOptions) : false,
);
componentRef.value.addEventListener('DOMMouseScroll', onFireFoxScroll as any);
componentRef.value.addEventListener('MozMousePixelScroll', onMozMousePixelScroll as any);
}
});
});
onBeforeUnmount(() => {
removeEventListener();
});
// ================================= Ref ==================================
const scrollTo = useScrollTo(
componentRef,
mergedData,
heights,
props,
getKey,
collectHeight,
syncScrollTop,
() => {
scrollBarRef.value?.delayHidden();
},
);
expose({
scrollTo,
});
const componentStyle = computed(() => {
let cs: CSSProperties | null = null;
if (props.height) {
cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle };
if (useVirtual.value) {
cs!.overflowY = 'hidden';
if (state.scrollMoving) {
cs!.pointerEvents = 'none';
}
}
}
return cs;
});
// ================================ Effect ================================
/** We need told outside that some list not rendered */
watch(
[() => calRes.start, () => calRes.end, mergedData],
() => {
if (props.onVisibleChange) {
const renderList = mergedData.value.slice(calRes.start, calRes.end + 1);
props.onVisibleChange(renderList, mergedData.value);
}
},
{ flush: 'post' },
);
return {
state,
mergedData,
componentStyle,
onFallbackScroll,
onScrollBar,
componentRef,
useVirtual,
calRes,
collectHeight,
setInstance,
sharedConfig,
scrollBarRef,
fillerInnerRef,
};
},
render() {
const {
prefixCls = 'rc-virtual-list',
height,
itemHeight,
// eslint-disable-next-line no-unused-vars
fullHeight,
data,
itemKey,
virtual,
component: Component = 'div',
onScroll,
children = this.$slots.default,
style,
class: className,
...restProps
} = { ...this.$props, ...this.$attrs } as any;
const mergedClassName = classNames(prefixCls, className);
const { scrollTop } = this.state;
const { scrollHeight, offset, start, end } = this.calRes;
const {
componentStyle,
onFallbackScroll,
onScrollBar,
useVirtual,
collectHeight,
sharedConfig,
setInstance,
mergedData,
} = this;
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}
ref="fillerInnerRef"
v-slots={{
default: () =>
renderChildren(mergedData, start, end, setInstance, children, sharedConfig),
}}
></Filler>
</Component>
{useVirtual && (
<ScrollBar
ref="scrollBarRef"
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;