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

405 lines
11 KiB
TypeScript
Raw Normal View History

2020-10-01 09:20:10 +00:00
import {
ref,
defineComponent,
PropType,
watchEffect,
Component,
computed,
nextTick,
onBeforeUnmount,
reactive,
CSSProperties,
} from 'vue';
import { Key } from '../_util/type';
2020-09-26 14:52:40 +00:00
import Filler from './Filler';
2020-09-28 11:14:00 +00:00
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';
2020-10-01 09:20:10 +00:00
import { RenderFunc, SharedConfig } from './interface';
2020-09-28 11:14:00 +00:00
const EMPTY_DATA = [];
2020-09-26 14:52:40 +00:00
2020-10-01 09:20:10 +00:00
const ScrollStyle: CSSProperties = {
2020-09-26 14:52:40 +00:00
overflowY: 'auto',
overflowAnchor: 'none',
};
2020-10-01 09:20:10 +00:00
function renderChildren<T>(
list: T[],
startIndex: number,
endIndex: number,
setNodeRef: (item: T, element: HTMLElement) => void,
renderFunc: RenderFunc<T>,
{ getKey }: SharedConfig<T>,
) {
2020-09-28 11:14:00 +00:00
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>
);
});
}
2020-10-01 09:20:10 +00:00
export interface ListState<T = object> {
scrollTop: number;
scrollMoving: boolean;
mergedData: T[];
}
2020-09-28 11:14:00 +00:00
2020-10-01 09:20:10 +00:00
const List = defineComponent({
2020-09-28 11:14:00 +00:00
inheritAttrs: false,
2020-09-26 14:52:40 +00:00
name: 'List',
2020-10-01 09:20:10 +00:00
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,
2020-10-01 09:20:10 +00:00
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,
2020-10-01 09:20:10 +00:00
children: PropTypes.func,
onScroll: PropTypes.func,
2020-10-07 14:49:01 +00:00
onMousedown: PropTypes.func,
onMouseenter: PropTypes.func,
2020-10-01 09:20:10 +00:00
},
2020-09-28 11:14:00 +00:00
setup(props) {
// ================================= MISC =================================
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
const inVirtual = computed(() => {
const { height, itemHeight, data, virtual } = props;
2020-10-01 09:20:10 +00:00
return !!(
virtual !== false &&
height &&
itemHeight &&
data &&
itemHeight * data.length > height
);
2020-09-28 11:14:00 +00:00
});
2020-10-01 09:20:10 +00:00
const state = reactive<ListState>({
2020-09-28 11:14:00 +00:00
scrollTop: 0,
scrollMoving: false,
2020-10-01 09:20:10 +00:00
mergedData: computed(() => props.data || EMPTY_DATA) as any,
2020-09-28 11:14:00 +00:00
});
2020-10-01 09:20:10 +00:00
const componentRef = ref<Element>();
2020-09-28 11:14:00 +00:00
// =============================== Item Key ===============================
2020-10-01 09:20:10 +00:00
const getKey = (item: Record<string, any>) => {
2020-09-28 11:14:00 +00:00
if (typeof props.itemKey === 'function') {
return props.itemKey(item);
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
return item[props.itemKey];
};
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
const sharedConfig = {
getKey,
};
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// ================================ Scroll ================================
2020-10-01 09:20:10 +00:00
function syncScrollTop(newTop: number | ((prev: number) => number)) {
let value: number;
2020-09-28 11:14:00 +00:00
if (typeof newTop === 'function') {
value = newTop(state.scrollTop);
} else {
value = newTop;
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
const alignedTop = keepInRange(value);
2020-09-26 14:52:40 +00:00
2020-10-01 09:20:10 +00:00
if (componentRef.value) {
componentRef.value.scrollTop = alignedTop;
2020-09-29 07:16:56 +00:00
}
2020-09-28 13:21:43 +00:00
state.scrollTop = alignedTop;
2020-09-28 11:14:00 +00:00
}
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// ================================ Height ================================
2020-09-28 13:21:43 +00:00
const [setInstance, collectHeight, heights] = useHeights(getKey, null, null);
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// ========================== Visible Calculation =========================
const calRes = computed(() => {
if (!inVirtual.value) {
return {
scrollHeight: undefined,
start: 0,
end: state.mergedData.length - 1,
offset: undefined,
};
}
let itemTop = 0;
2020-10-01 09:20:10 +00:00
let startIndex: number | undefined;
let startOffset: number | undefined;
let endIndex: number | undefined;
2020-09-28 11:14:00 +00:00
const dataLen = state.mergedData.length;
for (let i = 0; i < dataLen; i += 1) {
const item = state.mergedData[i];
const key = getKey(item);
2020-09-28 13:21:43 +00:00
const cacheHeight = heights[key];
2020-09-28 11:14:00 +00:00
const currentItemBottom =
2020-10-01 09:20:10 +00:00
itemTop + (cacheHeight === undefined ? props.itemHeight! : cacheHeight);
2020-09-28 11:14:00 +00:00
if (currentItemBottom >= state.scrollTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
// Check item bottom in the range. We will render additional one item for motion usage
2020-10-01 09:20:10 +00:00
if (currentItemBottom > state.scrollTop + props.height! && endIndex === undefined) {
2020-09-28 11:14:00 +00:00
endIndex = i;
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
itemTop = currentItemBottom;
}
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// 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;
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
// Give cache to improve scroll experience
endIndex = Math.min(endIndex + 1, state.mergedData.length);
return {
scrollHeight: itemTop,
start: startIndex,
end: endIndex,
offset: startOffset,
};
2020-09-26 14:52:40 +00:00
});
2020-09-28 11:14:00 +00:00
// =============================== In Range ===============================
2020-10-01 09:20:10 +00:00
const maxScrollHeight = computed(() => calRes.value.scrollHeight! - props.height!);
2020-09-26 14:52:40 +00:00
2020-10-01 09:20:10 +00:00
function keepInRange(newScrollTop: number) {
2020-09-28 11:14:00 +00:00
let newTop = Math.max(newScrollTop, 0);
if (!Number.isNaN(maxScrollHeight.value)) {
newTop = Math.min(newTop, maxScrollHeight.value);
}
return newTop;
}
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
const isScrollAtTop = computed(() => state.scrollTop <= 0);
const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value);
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom);
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// ================================ Scroll ================================
2020-10-01 09:20:10 +00:00
function onScrollBar(newScrollTop: number) {
2020-09-28 11:14:00 +00:00
const newTop = newScrollTop;
syncScrollTop(newTop);
}
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// This code may only trigger in test case.
// But we still need a sync if some special escape
2020-10-01 09:20:10 +00:00
function onFallbackScroll(e: UIEvent) {
const { scrollTop: newScrollTop } = e.currentTarget as Element;
2020-09-28 11:14:00 +00:00
if (newScrollTop !== state.scrollTop) {
syncScrollTop(newScrollTop);
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
// 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;
2020-09-26 14:52:40 +00:00
});
2020-09-28 11:14:00 +00:00
},
);
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
// Mobile touch move
useMobileTouchMove(inVirtual, componentRef, (deltaY, smoothOffset) => {
if (originScroll(deltaY, smoothOffset)) {
return false;
2020-09-26 14:52:40 +00:00
}
2020-10-01 09:20:10 +00:00
onRawWheel({ preventDefault() {}, deltaY } as WheelEvent);
2020-09-28 11:14:00 +00:00
return true;
});
// Firefox only
2020-10-01 09:20:10 +00:00
function onMozMousePixelScroll(e: MouseEvent) {
if (inVirtual.value) {
e.preventDefault();
}
}
const removeEventListener = () => {
2020-10-01 09:20:10 +00:00
if (componentRef.value) {
componentRef.value.removeEventListener('wheel', onRawWheel);
2020-10-07 14:49:01 +00:00
componentRef.value.removeEventListener('DOMMouseScroll' as any, onFireFoxScroll);
2020-10-01 09:20:10 +00:00
componentRef.value.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll);
}
};
2020-09-28 11:14:00 +00:00
watchEffect(() => {
nextTick(() => {
2020-10-01 09:20:10 +00:00
if (componentRef.value) {
removeEventListener();
2020-10-01 09:20:10 +00:00
componentRef.value.addEventListener('wheel', onRawWheel);
2020-10-07 14:49:01 +00:00
componentRef.value.addEventListener('DOMMouseScroll' as any, onFireFoxScroll);
2020-10-01 09:20:10 +00:00
componentRef.value.addEventListener('MozMousePixelScroll', onMozMousePixelScroll);
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
});
});
2020-09-26 14:52:40 +00:00
onBeforeUnmount(() => {
removeEventListener();
});
2020-09-28 11:14:00 +00:00
// ================================= Ref ==================================
const scrollTo = useScrollTo(
componentRef,
2020-09-29 07:16:56 +00:00
state,
2020-09-28 11:14:00 +00:00
heights,
2020-09-29 07:16:56 +00:00
props,
2020-09-28 11:14:00 +00:00
getKey,
collectHeight,
syncScrollTop,
);
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
const componentStyle = computed(() => {
2020-10-01 09:20:10 +00:00
let cs: CSSProperties | null = null;
2020-09-28 11:14:00 +00:00
if (props.height) {
cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle };
if (inVirtual.value) {
2020-10-01 09:20:10 +00:00
cs!.overflowY = 'hidden';
2020-09-28 11:14:00 +00:00
if (state.scrollMoving) {
2020-10-01 09:20:10 +00:00
cs!.pointerEvents = 'none';
2020-09-28 11:14:00 +00:00
}
}
2020-09-26 14:52:40 +00:00
}
2020-09-28 11:14:00 +00:00
return cs;
});
return {
state,
componentStyle,
scrollTo,
onFallbackScroll,
onScrollBar,
componentRef,
inVirtual,
calRes,
collectHeight,
setInstance,
sharedConfig,
};
2020-09-26 14:52:40 +00:00
},
render() {
const {
2020-09-28 11:14:00 +00:00
prefixCls = 'rc-virtual-list',
2020-09-26 14:52:40 +00:00
height,
itemHeight,
2020-09-28 11:14:00 +00:00
// eslint-disable-next-line no-unused-vars
2020-09-29 07:16:56 +00:00
fullHeight,
2020-09-26 14:52:40 +00:00
data,
itemKey,
virtual,
2020-09-28 11:14:00 +00:00
component: Component = 'div',
onScroll,
children,
2020-09-30 08:21:04 +00:00
style,
class: className,
2020-09-26 14:52:40 +00:00
...restProps
2020-10-07 14:49:01 +00:00
} = { ...this.$props, ...this.$attrs } as any;
2020-09-28 11:14:00 +00:00
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,
);
2020-09-26 14:52:40 +00:00
2020-09-28 11:14:00 +00:00
return (
<div
style={{
...style,
position: 'relative',
}}
class={mergedClassName}
{...restProps}
>
2020-09-26 14:52:40 +00:00
<Component
2020-09-28 11:14:00 +00:00
class={`${prefixCls}-holder`}
style={componentStyle}
2020-10-01 09:20:10 +00:00
ref="componentRef"
2020-09-28 11:14:00 +00:00
onScroll={onFallbackScroll}
2020-09-26 14:52:40 +00:00
>
2020-09-28 11:14:00 +00:00
<Filler
prefixCls={prefixCls}
height={scrollHeight}
offset={offset}
onInnerResize={collectHeight}
>
{listChildren}
2020-09-26 14:52:40 +00:00
</Filler>
</Component>
2020-09-28 11:14:00 +00:00
{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>
2020-09-26 14:52:40 +00:00
);
},
2020-10-01 09:20:10 +00:00
});
2020-09-28 11:14:00 +00:00
export default List;