From 2a6e0fd68cc32f61608c4bc15d63e89891eaa165 Mon Sep 17 00:00:00 2001 From: gxuud Date: Thu, 7 Apr 2022 08:55:55 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E8=99=9A=E6=8B=9F=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=BB=84=E4=BB=B6=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../virtual-list/__tests__/index.spec.ts | 8 +++ .../devui-vue/devui/virtual-list/index.ts | 14 +++++ .../virtual-list/src/virtual-list-types.ts | 9 +++ .../devui/virtual-list/src/virtual-list.css | 3 + .../devui/virtual-list/src/virtual-list.tsx | 16 +++++ .../docs/components/virtual-list/index.md | 60 +++++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 packages/devui-vue/devui/virtual-list/__tests__/index.spec.ts create mode 100644 packages/devui-vue/devui/virtual-list/index.ts create mode 100644 packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts create mode 100644 packages/devui-vue/devui/virtual-list/src/virtual-list.css create mode 100644 packages/devui-vue/devui/virtual-list/src/virtual-list.tsx create mode 100644 packages/devui-vue/docs/components/virtual-list/index.md diff --git a/packages/devui-vue/devui/virtual-list/__tests__/index.spec.ts b/packages/devui-vue/devui/virtual-list/__tests__/index.spec.ts new file mode 100644 index 0000000000..ca65892ae6 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/__tests__/index.spec.ts @@ -0,0 +1,8 @@ +import { mount } from '@vue/test-utils'; +import { VirtualList } from '../index'; + +describe('VirtualList test', () => { + it('VirtualList init render', async () => { + // todo + }); +}); diff --git a/packages/devui-vue/devui/virtual-list/index.ts b/packages/devui-vue/devui/virtual-list/index.ts new file mode 100644 index 0000000000..7a4089afa4 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/index.ts @@ -0,0 +1,14 @@ +import type { App } from 'vue'; +import VirtualList from './src/virtual-list'; + +export { VirtualList }; + +export default { + title: 'VirtualList 虚拟列表', + category: '通用', + status: '10%', + install(app: App): void { + app.use(VirtualList as any); + app.component(VirtualList.name, VirtualList); + } +}; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts new file mode 100644 index 0000000000..70e63ca141 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts @@ -0,0 +1,9 @@ +import type { ExtractPropTypes } from 'vue'; + +export const virtualListProps = { + /* test: { + type: Object as PropType<{ xxx: xxx }> + } */ +} as const; + +export type VirtualListProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.css b/packages/devui-vue/devui/virtual-list/src/virtual-list.css new file mode 100644 index 0000000000..52264f3550 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list.css @@ -0,0 +1,3 @@ +.virtual-list { + /* your style */ +} diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx new file mode 100644 index 0000000000..08ea9447ad --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx @@ -0,0 +1,16 @@ +import type { SetupContext } from 'vue'; +import { defineComponent } from 'vue'; +import { virtualListProps, VirtualListProps } from './virtual-list-types'; + +export default defineComponent({ + name: 'DVirtualList', + props: virtualListProps, + emits: [], + setup(props: VirtualListProps, ctx: SetupContext) { + return () => { + return ( +
virtual-list
+ ); + }; + } +}); diff --git a/packages/devui-vue/docs/components/virtual-list/index.md b/packages/devui-vue/docs/components/virtual-list/index.md new file mode 100644 index 0000000000..5b4ac41b17 --- /dev/null +++ b/packages/devui-vue/docs/components/virtual-list/index.md @@ -0,0 +1,60 @@ +# VirtualList virtual-list + +// todo 组件描述 + +### 何时使用 + +// todo 使用时机描述 + +### 基本用法 + +// todo 用法描述 + +:::demo // todo 展开代码的内部描述 + +```vue + + + + + +``` + +::: + +### VirtualList + +VirtualList 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | 全局配置项 | +| ---- | ---- | ---- | ---- | --------- | --------- | +| | | | | | | +| | | | | | | +| | | | | | | + +VirtualList 事件 + +| 事件 | 类型 | 说明 | 跳转 Demo | +| ---- | ---- | ---- | --------- | +| | | | | +| | | | | +| | | | | From 04a3e9b747b9c72e7f7efb3743391bbc81f542ad Mon Sep 17 00:00:00 2001 From: gxuud Date: Fri, 8 Apr 2022 09:20:41 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E8=99=9A=E6=8B=9F=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=9F=BA=E6=9C=AC=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../virtual-list/src/components/filler.tsx | 29 ++++++++++++ .../src/components/scroll-bar.tsx | 14 ++++++ .../virtual-list/src/virtual-list-types.ts | 37 ++++++++++++++-- .../devui/virtual-list/src/virtual-list.tsx | 44 +++++++++++++++++-- .../docs/components/virtual-list/index.md | 28 ++++++------ 5 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 packages/devui-vue/devui/virtual-list/src/components/filler.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx diff --git a/packages/devui-vue/devui/virtual-list/src/components/filler.tsx b/packages/devui-vue/devui/virtual-list/src/components/filler.tsx new file mode 100644 index 0000000000..ed50349d21 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/components/filler.tsx @@ -0,0 +1,29 @@ +import type { CSSProperties, SetupContext } from 'vue'; +import { defineComponent, ref } from 'vue'; +import { virtualListFllterProps } from '../virtual-list-types'; + +const INIT_INNER_STYLE: CSSProperties = { display: 'flex', flexDirection: 'column' }; + +export default defineComponent({ + name: 'DVirtualListFllter', + props: virtualListFllterProps, + setup(props, ctx: SetupContext) { + const outerStyle = ref({}); + const innerStyle = ref(INIT_INNER_STYLE); + ctx.slots.default?.(); + return () => ( +
+
+ +
+
+ ); + } +}); diff --git a/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx b/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx new file mode 100644 index 0000000000..90641f53ff --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx @@ -0,0 +1,14 @@ +import { defineComponent } from 'vue'; +import { virtualListScrollBarProps } from '../virtual-list-types'; + +export default defineComponent({ + name: 'DVirtualListScrollBar', + props: virtualListScrollBarProps, + setup() { + return () => { + return ( +
DVirtualListScrollBar
+ ); + }; + } +}); diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts index 70e63ca141..ff0fde937b 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts @@ -1,9 +1,38 @@ -import type { ExtractPropTypes } from 'vue'; +import type { PropType, ExtractPropTypes, CSSProperties } from 'vue'; export const virtualListProps = { - /* test: { - type: Object as PropType<{ xxx: xxx }> - } */ + data: { + type: Array, + default: () => [], + }, + style: { + type: Object as PropType, + }, + class: { + type: String, + default: '', + }, + component: { + type: String, + default: 'div', + }, + height: { + type: Number, + }, +} as const; + +export const virtualListFllterProps = { + data: { + type: Array, + default: () => [], + }, +} as const; + +export const virtualListScrollBarProps = { + data: { + type: Array, + default: () => [], + }, } as const; export type VirtualListProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx index 08ea9447ad..e38dcfc695 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx @@ -1,15 +1,53 @@ import type { SetupContext } from 'vue'; -import { defineComponent } from 'vue'; +import { defineComponent, toRefs, ref } from 'vue'; import { virtualListProps, VirtualListProps } from './virtual-list-types'; +import Filler from './components/filler'; +import ScrollBar from './components/scroll-bar'; export default defineComponent({ name: 'DVirtualList', props: virtualListProps, - emits: [], setup(props: VirtualListProps, ctx: SetupContext) { + const { style, class: className, component, data, ...restProps } = toRefs(props); + const componentRef = ref(null); + const scrollTop = ref(0); + const setScrollTop = (newValue: number | ((val: number) => number)) => { + let val: number; + if (typeof newValue === 'function') { + val = newValue(scrollTop.value); + } else { + val = newValue; + } + scrollTop.value = val; + }; + const onComponentScroll = (event: UIEvent) => { + const currentScrollTop = (event.currentTarget as HTMLElement).scrollTop; + if (currentScrollTop !== scrollTop.value) { + setScrollTop(currentScrollTop); + } + }; return () => { + const Component: unknown = component.value; return ( -
virtual-list
+
e} + > + + + {Array.from({ length: 100 }).map((_, index) => { + return
index: {index}
; + })} +
+
+ +
); }; } diff --git a/packages/devui-vue/docs/components/virtual-list/index.md b/packages/devui-vue/docs/components/virtual-list/index.md index 5b4ac41b17..754f7aadd2 100644 --- a/packages/devui-vue/docs/components/virtual-list/index.md +++ b/packages/devui-vue/docs/components/virtual-list/index.md @@ -1,20 +1,20 @@ -# VirtualList virtual-list +# VirtualList 虚拟列表 -// todo 组件描述 +虚拟列表无限滚动 ### 何时使用 -// todo 使用时机描述 +大量列表数据显示 ### 基本用法 -// todo 用法描述 - :::demo // todo 展开代码的内部描述 ```vue From 8b763821eae9c945fb32c83211fbe97d7ba987be Mon Sep 17 00:00:00 2001 From: gxuud Date: Thu, 14 Apr 2022 19:02:25 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E8=99=9A=E6=8B=9F=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=9F=BA=E6=9C=AC=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../virtual-list/src/components/filler.tsx | 39 +- .../virtual-list/src/components/item.tsx | 23 + .../src/components/resize-observer.tsx | 100 +++++ .../src/components/scroll-bar.tsx | 212 ++++++++- .../src/hooks/use-frame-wheel.tsx | 55 +++ .../virtual-list/src/hooks/use-heights.tsx | 57 +++ .../src/hooks/use-mobile-touch-move.tsx | 89 ++++ .../src/hooks/use-origin-scroll.tsx | 33 ++ .../virtual-list/src/hooks/use-scroll-to.tsx | 105 +++++ .../virtual-list/src/hooks/use-virtual.tsx | 20 + .../devui-vue/devui/virtual-list/src/raf.ts | 50 +++ .../devui-vue/devui/virtual-list/src/utils.ts | 97 ++++ .../virtual-list/src/virtual-list-types.ts | 94 +++- .../devui/virtual-list/src/virtual-list.tsx | 420 +++++++++++++++++- .../docs/components/virtual-list/index.md | 17 +- 15 files changed, 1353 insertions(+), 58 deletions(-) create mode 100644 packages/devui-vue/devui/virtual-list/src/components/item.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-frame-wheel.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-origin-scroll.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx create mode 100644 packages/devui-vue/devui/virtual-list/src/raf.ts create mode 100644 packages/devui-vue/devui/virtual-list/src/utils.ts diff --git a/packages/devui-vue/devui/virtual-list/src/components/filler.tsx b/packages/devui-vue/devui/virtual-list/src/components/filler.tsx index ed50349d21..d275bfec23 100644 --- a/packages/devui-vue/devui/virtual-list/src/components/filler.tsx +++ b/packages/devui-vue/devui/virtual-list/src/components/filler.tsx @@ -1,6 +1,7 @@ import type { CSSProperties, SetupContext } from 'vue'; -import { defineComponent, ref } from 'vue'; +import { defineComponent, ref, watch } from 'vue'; import { virtualListFllterProps } from '../virtual-list-types'; +import ResizeObserver from './resize-observer'; const INIT_INNER_STYLE: CSSProperties = { display: 'flex', flexDirection: 'column' }; @@ -10,19 +11,33 @@ export default defineComponent({ setup(props, ctx: SetupContext) { const outerStyle = ref({}); const innerStyle = ref(INIT_INNER_STYLE); - ctx.slots.default?.(); + watch([() => props.height, () => props.offset], () => { + if (props.offset !== undefined) { + outerStyle.value = { height: `${props.height}px`, position: 'relative', overflow: 'hidden' }; + innerStyle.value = { + ...innerStyle.value, + transform: `translateY(${props.offset}px)`, + position: 'absolute', + left: 0, + right: 0, + top: 0, + }; + } + }); + return () => (
-
- -
+ { + if (offsetHeight && props.onInnerResize) { + props.onInnerResize(); + } + }} + > +
+ {ctx.slots.default?.()} +
+
); } diff --git a/packages/devui-vue/devui/virtual-list/src/components/item.tsx b/packages/devui-vue/devui/virtual-list/src/components/item.tsx new file mode 100644 index 0000000000..b44e1aaced --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/components/item.tsx @@ -0,0 +1,23 @@ +import type { FunctionalComponent, PropType, VNodeProps, Slots } from 'vue'; +import { cloneVNode } from 'vue'; +import { flattenChildren } from '../utils'; + + +export interface ItemProps { + setRef: (element: HTMLElement & { $el: never }) => void; +} + +const Item: FunctionalComponent = ({ setRef }, { slots }) => { + const children = flattenChildren((slots as Slots).default?.()); + + return children && children.length + ? cloneVNode(children[0], { ref: setRef as VNodeProps['ref'], }) + : children; +}; +Item.props = { + setRef: { + type: Function as PropType<(element: HTMLElement) => void>, + }, +}; + +export default Item; diff --git a/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx b/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx new file mode 100644 index 0000000000..3c94695264 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx @@ -0,0 +1,100 @@ +import { defineComponent, reactive, ref, getCurrentInstance, onMounted, onUpdated, onUnmounted } from 'vue'; +import { findDOMNode } from '../utils'; +import { resizeObserverProps } from '../virtual-list-types'; + +interface ResizeObserverState { + height: number; + width: number; + offsetHeight: number; + offsetWidth: number; +} + + +export default defineComponent({ + name: 'ResizeObserver', + props: resizeObserverProps, + emits: ['resize'], + setup(props, { slots }) { + + const state = reactive({ + width: 0, + height: 0, + offsetHeight: 0, + offsetWidth: 0, + }); + const currentElement = ref(null); + const resizeObserver = ref(null); + const destroyObserver = () => { + if (resizeObserver.value) { + resizeObserver.value.disconnect(); + resizeObserver.value = null; + } + }; + + const onTriggerResize: ResizeObserverCallback = (entries: ResizeObserverEntry[]) => { + const { onResize } = props; + + const target = entries[0].target as HTMLElement; + + const { width, height } = target.getBoundingClientRect(); + const { offsetWidth, offsetHeight } = target; + + const fixedWidth = Math.floor(width); + const fixedHeight = Math.floor(height); + + if ( + state.width !== fixedWidth || + state.height !== fixedHeight || + state.offsetWidth !== offsetWidth || + state.offsetHeight !== offsetHeight + ) { + const size = { width: fixedWidth, height: fixedHeight, offsetWidth, offsetHeight }; + + Object.assign(state, size); + if (onResize) { + Promise.resolve().then(() => { + onResize( + { + ...size, + offsetWidth, + offsetHeight, + }, + target, + ); + }); + } + } + }; + const instance = getCurrentInstance(); + const registerObserver = () => { + const { disabled } = props; + if (disabled) { + destroyObserver(); + return; + } + const element = findDOMNode(instance) as Element; + const elementChanged = element !== currentElement.value; + if (elementChanged) { + destroyObserver(); + currentElement.value = element; + } + + if (!resizeObserver.value && element) { + resizeObserver.value = new ResizeObserver(onTriggerResize); + resizeObserver.value.observe(element); + } + }; + onMounted(() => { + registerObserver(); + }); + onUpdated(() => { + registerObserver(); + }); + onUnmounted(() => { + destroyObserver(); + }); + return () => { + return slots.default?.()[0]; + }; + } +}); diff --git a/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx b/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx index 90641f53ff..4e4831e9ad 100644 --- a/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx +++ b/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx @@ -1,13 +1,217 @@ -import { defineComponent } from 'vue'; -import { virtualListScrollBarProps } from '../virtual-list-types'; +import type { Ref } from 'vue'; +import { defineComponent, ref, reactive, computed, onBeforeUnmount, onMounted, inject, watch } from 'vue'; +import { virtualListScrollBarProps, GetScrollBarRefType } from '../virtual-list-types'; +import raf from '../raf'; + +interface ScrollBarState { + dragging: boolean; + pageY: number | null; + startTop: number | null; + visible: boolean; +} + +const MIN_SIZE = 20; + +function getPageY(e: MouseEvent | TouchEvent) { + return 'touches' in e ? e.touches[0].pageY : e.pageY; +} export default defineComponent({ name: 'DVirtualListScrollBar', props: virtualListScrollBarProps, - setup() { + setup(props) { + const scrollbarRef = ref(null); + const getScrollBarRef = inject>('getScrollBarRef'); + watch( + scrollbarRef, + () => { + if (getScrollBarRef) { + getScrollBarRef.value = () => scrollbarRef; + } + } + ); + const thumbRef = ref(null); + const moveRaf = ref(0); + const state = reactive({ + dragging: false, + pageY: null, + startTop: null, + visible: false, + }); + const visibleTimeout = ref(null); + const canScroll = computed(() => { + return (props.scrollHeight || 0) > (props.height || 0); + }); + const getSpinHeight = () => { + const { height = 0, count = 0 } = props; + let baseHeight = (height / count) * 10; + baseHeight = Math.max(baseHeight, MIN_SIZE); + baseHeight = Math.min(baseHeight, height / 2); + return Math.floor(baseHeight); + }; + const getEnableScrollRange = () => { + const { scrollHeight = 0, height = 0 } = props; + return scrollHeight - height || 0; + }; + const getEnableHeightRange = () => { + const { height = 0 } = props; + const spinHeight = getSpinHeight(); + return height - spinHeight || 0; + }; + const getTop = () => { + const { scrollTop = 0 } = props; + const enableScrollRange = getEnableScrollRange(); + const enableHeightRange = getEnableHeightRange(); + if (scrollTop === 0 || enableScrollRange === 0) { + return 0; + } + const ptg = scrollTop / enableScrollRange; + return ptg * enableHeightRange; + }; + const onMouseMove = (e: MouseEvent | TouchEvent) => { + const { dragging, pageY, startTop } = state; + const { onScroll } = props; + raf.cancel(moveRaf.value); + if (dragging) { + const offsetY = getPageY(e) - (pageY || 0); + const newTop = (startTop || 0) + offsetY; + const enableScrollRange = getEnableScrollRange(); + const enableHeightRange = getEnableHeightRange(); + const ptg = enableHeightRange ? newTop / enableHeightRange : 0; + const newScrollTop = Math.ceil(ptg * enableScrollRange); + moveRaf.value = raf(() => { + if (onScroll) { + onScroll(newScrollTop); + } + }); + } + }; + const onMouseUp = (callback: () => void) => { + const { onStopMove } = props; + state.dragging = false; + if (onStopMove) { + onStopMove(); + } + if (callback) { + callback(); + } + }; + + const onMouseDown = (e: MouseEvent | TouchEvent, callback: () => void) => { + const { onStartMove } = props; + Object.assign(state, { + dragging: true, + pageY: getPageY(e), + startTop: getTop(), + }); + if (onStartMove) { + onStartMove(); + } + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', () => onMouseUp(callback)); + thumbRef?.value?.addEventListener( + 'touchmove', + onMouseMove, + ({ passive: false } as EventListenerOptions) , + ); + thumbRef?.value?.addEventListener('touchend', () => onMouseUp(callback)); + e.stopPropagation(); + e.preventDefault(); + }; + + const removeEvents = () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', () => onMouseUp(removeEvents)); + + scrollbarRef?.value?.removeEventListener( + 'touchstart', + (e: TouchEvent) => { e.preventDefault(); }, + ({ passive: false } as EventListenerOptions) , + ); + thumbRef?.value?.removeEventListener( + 'touchstart', + (e) => onMouseDown(e, removeEvents), + ({ passive: false } as EventListenerOptions) , + ); + thumbRef?.value?.removeEventListener( + 'touchmove', + onMouseMove, + ({ passive: false } as EventListenerOptions) , + ); + thumbRef?.value?.removeEventListener('touchend', () => onMouseUp(removeEvents)); + + raf.cancel(moveRaf.value); + }; + const onContainerMouseDown = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + onBeforeUnmount(() => { + removeEvents(); + if (visibleTimeout.value) { + clearTimeout(visibleTimeout.value); + } + }); + + onMounted(() => { + scrollbarRef?.value?.addEventListener( + 'touchstart', + (e: TouchEvent) => { + e.preventDefault(); + }, + ({ passive: false } as EventListenerOptions) , + ); + thumbRef.value?.addEventListener( + 'touchstart', + (e) => onMouseDown(e, removeEvents), + ({ passive: false } as EventListenerOptions) , + ); + }); + + const delayHidden = () => { + if (visibleTimeout.value) { + clearTimeout(visibleTimeout.value); + } + state.visible = true; + + visibleTimeout.value = setTimeout(() => { + state.visible = false; + }, 2000); + }; + return () => { + const mergedVisible = canScroll.value; return ( -
DVirtualListScrollBar
+
+
onMouseDown(e, removeEvents)} + /> +
); }; } diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-frame-wheel.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-frame-wheel.tsx new file mode 100644 index 0000000000..0d116559da --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-frame-wheel.tsx @@ -0,0 +1,55 @@ +import type { Ref } from 'vue'; +import raf from '../raf'; +import { isFF } from '../utils'; +import useOriginScroll from './use-origin-scroll'; + +interface FireFoxDOMMouseScrollEvent { + detail?: number; + preventDefault?: () => void; +} + +export default function useFrameWheel( + inVirtual: Ref, + isScrollAtTop: Ref, + isScrollAtBottom: Ref, + onWheelDelta: (offset: number) => void, +): [(e: WheelEvent) => void, (e: FireFoxDOMMouseScrollEvent) => void] { + let offsetRef = 0; + let nextFrame: number | null | undefined = null; + + let wheelValue: number | null | undefined = null; + let isMouseScroll = false; + + const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom); + + const onRawWheel = (event: { preventDefault?: (offset?: number) => void; deltaY: number }) => { + if (!inVirtual.value) {return;} + + if (nextFrame) { + raf.cancel(nextFrame); + } + + const { deltaY } = event; + offsetRef += deltaY; + wheelValue = deltaY; + + if (originScroll(deltaY, false)) {return;} + + if (!isFF) { + event?.preventDefault?.(); + } + + nextFrame = raf(() => { + const patchMultiple = isMouseScroll ? 10 : 1; + onWheelDelta(offsetRef * patchMultiple); + offsetRef = 0; + }); + }; + + const onFireFoxScroll = (event: FireFoxDOMMouseScrollEvent) => { + if (!inVirtual.value) {return;} + isMouseScroll = event.detail === wheelValue; + }; + + return [onRawWheel, onFireFoxScroll]; +} diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx new file mode 100644 index 0000000000..c41c8f8cc8 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx @@ -0,0 +1,57 @@ +import type { VNodeProps, Ref, ShallowRef } from 'vue'; +import { GetKey, CacheMap } from '../virtual-list-types'; +import { watch, ref } from 'vue'; + +export default function useHeights( + mergedData: ShallowRef, + getKey: GetKey, + onItemAdd?: ((item: T) => void) | null, + onItemRemove?: ((item: T) => void) | null, +): [(item: T, instance: HTMLElement & { $el: never }) => void, () => void, CacheMap, Ref] { + const instance = new Map(); + let heights = new Map(); + const updatedMark = ref(Symbol('update')); + watch(mergedData, () => { + heights = new Map(); + updatedMark.value = Symbol('update'); + }); + let heightUpdateId = 0; + function collectHeight() { + heightUpdateId += 1; + const currentId = heightUpdateId; + Promise.resolve().then(() => { + if (currentId !== heightUpdateId) {return;} + instance.forEach((element, key) => { + if (element && element.offsetParent) { + const { offsetHeight } = element; + if (heights.get(key) !== offsetHeight) { + updatedMark.value = Symbol('update'); + heights.set(key, element.offsetHeight); + } + } + }); + }); + } + + function setInstance(item: T, ins: HTMLElement & { $el: never }) { + const key = getKey(item); + const origin = instance.get(key); + + if (ins) { + instance.set(key, ins.$el || ins); + collectHeight(); + } else { + instance.delete(key); + } + + if (!origin !== !ins) { + if (ins) { + onItemAdd?.(item); + } else { + onItemRemove?.(item); + } + } + } + + return [setInstance, collectHeight, heights, updatedMark]; +} diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx new file mode 100644 index 0000000000..288d1c0581 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx @@ -0,0 +1,89 @@ +import type { Ref } from 'vue'; +import { onBeforeUnmount, watch, onMounted } from 'vue'; + +const SMOOTH_PTG = 14 / 15; +export default function useMobileTouchMove( + inVirtual: Ref, + listRef: Ref, + callback: (offsetY: number, smoothOffset?: boolean) => boolean, +): void { + let touched = false; + let touchY = 0; + let element: HTMLElement | null = null; + let interval: NodeJS.Timer | null = null; + + const onTouchMove = (e: TouchEvent) => { + if (touched) { + const currentY = Math.ceil(e.touches[0].pageY); + let offsetY = touchY - currentY; + touchY = currentY; + + if (callback(offsetY)) { + e.preventDefault(); + } + + if (interval) { + clearInterval(interval); + } + interval = setInterval(() => { + offsetY *= SMOOTH_PTG; + + if (!callback(offsetY, true) || Math.abs(offsetY) <= 0.1) { + if (interval) { + clearInterval(interval); + } + } + }, 16); + } + }; + const cleanUpEvents = () => { + if (element) { + element.removeEventListener('touchmove', onTouchMove); + element.removeEventListener('touchend', () => { + touched = false; + cleanUpEvents(); + }); + } + }; + + const onTouchEnd = () => { + touched = false; + + cleanUpEvents(); + }; + const onTouchStart = (e: TouchEvent) => { + cleanUpEvents(); + + if (e.touches.length === 1 && !touched) { + touched = true; + touchY = Math.ceil(e.touches[0].pageY); + + element = e.target as HTMLElement; + element.addEventListener('touchmove', onTouchMove, { passive: false }); + element.addEventListener('touchend', onTouchEnd); + } + }; + + let noop: () => void | undefined; + + onMounted(() => { + document.addEventListener('touchmove', noop, { passive: false }); + watch( + inVirtual, + val => { + listRef.value?.removeEventListener('touchstart', onTouchStart); + cleanUpEvents(); + if (interval) { + clearInterval(interval); + } + if (val) { + listRef.value?.addEventListener('touchstart', onTouchStart, { passive: false }); + } + }, + { immediate: true }, + ); + }); + onBeforeUnmount(() => { + document.removeEventListener('touchmove', noop); + }); +} diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-origin-scroll.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-origin-scroll.tsx new file mode 100644 index 0000000000..a15e30f1d8 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-origin-scroll.tsx @@ -0,0 +1,33 @@ +import type { Ref } from 'vue'; + +export default (isScrollAtTop: Ref, isScrollAtBottom: Ref): (deltaY: number, smoothOffset: boolean) => boolean => { + let lock = false; + let lockTimeout: NodeJS.Timeout | null = null; + function lockScroll() { + if (lockTimeout) { + clearTimeout(lockTimeout); + } + + lock = true; + + lockTimeout = setTimeout(() => { + lock = false; + }, 50); + } + return (deltaY: number, smoothOffset = false) => { + const originScroll = + (deltaY < 0 && isScrollAtTop.value) || + (deltaY > 0 && isScrollAtBottom.value); + + if (smoothOffset && originScroll) { + if (lockTimeout) { + clearTimeout(lockTimeout); + } + lock = false; + } else if (!originScroll || lock) { + lockScroll(); + } + + return !lock && originScroll; + }; +}; diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx new file mode 100644 index 0000000000..d5c5e1875f --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx @@ -0,0 +1,105 @@ +import type { ShallowRef, Ref } from 'vue'; +import type { VirtualListProps, CacheMap } from '../virtual-list-types'; +import raf from '../raf'; +type GetKey> = (item: T) => string | number | undefined; +type IUseScrollToArg = null | number | { offset: number; align: 'top' | 'bottom'; index: number; key: string | number }; +export default function useScrollTo( + containerRef: Ref, + mergedData: ShallowRef, + heights: CacheMap, + props: VirtualListProps, + getKey: GetKey, + collectHeight: () => void, + syncScrollTop: (newTop: number) => void, + triggerFlash: () => void, +): (arg?: IUseScrollToArg) => void { + let scroll: number; + + return (arg?: IUseScrollToArg) => { + if (arg === null || arg === undefined) { + triggerFlash(); + return; + } + + if (scroll) { + raf.cancel(scroll); + } + const data = mergedData.value; + const itemHeight = props.itemHeight; + if (typeof arg === 'number') { + syncScrollTop(arg); + } else if (arg && typeof arg === 'object') { + let index: number; + const { align } = arg; + + if (arg.index) { + index = arg.index; + } else { + index = data.findIndex( + (item) => getKey(item as Record) === arg.key + ); + } + + const { offset = 0 } = arg; + + const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => { + if (times < 0 || !containerRef.value) { return; } + + const height = containerRef.value.clientHeight; + let needCollectHeight = false; + let newTargetAlign = targetAlign; + + if (height) { + const mergedAlign = targetAlign || align; + let stackTop = 0; + let itemTop = 0; + let itemBottom = 0; + + const maxLen = Math.min(data.length, index); + for (let i = 0; i <= maxLen; i += 1) { + const key = getKey(data[i] as Record); + itemTop = stackTop; + const cacheHeight = heights.get(key); + itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); + stackTop = itemBottom; + if (i === index && cacheHeight === undefined) { + needCollectHeight = true; + } + } + const scrollTop = containerRef.value.scrollTop; + + let targetTop: number | null = null; + + switch (mergedAlign) { + case 'top': + targetTop = itemTop - offset; + break; + case 'bottom': + targetTop = itemBottom - height + offset; + break; + default: { + const scrollBottom = scrollTop + height; + if (itemTop < scrollTop) { + newTargetAlign = 'top'; + } else if (itemBottom > scrollBottom) { + newTargetAlign = 'bottom'; + } + } + } + if (targetTop !== null && targetTop !== scrollTop) { + syncScrollTop(targetTop); + } + } + + scroll = raf(() => { + if (needCollectHeight) { + collectHeight(); + } + syncScroll(times - 1, newTargetAlign); + }); + }; + + syncScroll(3); + } + }; +} diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx new file mode 100644 index 0000000000..7664b0c5cf --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx @@ -0,0 +1,20 @@ +import type { ComputedRef } from 'vue'; +import { computed } from 'vue'; +import { VirtualListProps } from '../virtual-list-types'; + +interface IUseVirtual { + isVirtual: ComputedRef; + inVirtual: ComputedRef; +} + +export default function useVirtual(props: VirtualListProps): IUseVirtual { + const isVirtual = computed(() => { + const { height, itemHeight, virtual } = props; + return !!(virtual !== false && height && itemHeight); + }); + const inVirtual = computed(() => { + const { height, itemHeight, data } = props; + return isVirtual.value && data && itemHeight * data.length > height; + }); + return { isVirtual, inVirtual }; +} diff --git a/packages/devui-vue/devui/virtual-list/src/raf.ts b/packages/devui-vue/devui/virtual-list/src/raf.ts new file mode 100644 index 0000000000..64030b9211 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/raf.ts @@ -0,0 +1,50 @@ +let raf = (callback: FrameRequestCallback) => +setTimeout(callback, 16); +let caf = (num: number) => clearTimeout(num); + +if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) { + raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback); + caf = (handle: number) => window.cancelAnimationFrame(handle); +} + +let rafUUID = 0; +const rafIds = new Map(); + +function cleanup(id: number) { + rafIds.delete(id); +} + +export default function wrapperRaf(callback: () => void, times = 1): number { + rafUUID += 1; + const id = rafUUID; + + function callRef(leftTimes: number) { + if (leftTimes === 0) { + // Clean up + cleanup(id); + + // Trigger + callback(); + } else { + // Next raf + const realId = raf(() => { + callRef(leftTimes - 1); + }); + + // Bind real raf id + rafIds.set(id, realId); + } + } + + callRef(times); + + return id; +} + +wrapperRaf.cancel = (id: number) => { + const realId = rafIds.get(id); + if (realId) { + cleanup(realId); + return caf(realId); + } + return undefined; +}; diff --git a/packages/devui-vue/devui/virtual-list/src/utils.ts b/packages/devui-vue/devui/virtual-list/src/utils.ts new file mode 100644 index 0000000000..77523029a3 --- /dev/null +++ b/packages/devui-vue/devui/virtual-list/src/utils.ts @@ -0,0 +1,97 @@ +import type { VNode, ComponentInternalInstance } from 'vue'; +import { isVNode, Fragment, Comment, Text } from 'vue'; + + +export const isValid = (value?: unknown): boolean => { + return value !== undefined && value !== null && value !== ''; +}; + +export const isEmptyElement = (c?: VNode): boolean => { + return ( + !!c && + (c.type === Comment || + (c.type === Fragment && c?.children?.length === 0) || + (c.type === Text && c?.children?.trim() === '')) + ); +}; + +export const flattenChildren = (children?: VNode[], filterEmpty = true): VNode[] => { + const temp = Array.isArray(children) ? children : [children]; + const res: VNode[] = []; + temp.forEach(child => { + if (Array.isArray(child)) { + res.push(...flattenChildren(child, filterEmpty)); + } else if (child && child.type === Fragment) { + res.push(...flattenChildren(child.children, filterEmpty)); + } else if (child && isVNode(child)) { + if (filterEmpty && !isEmptyElement(child)) { + res.push(child); + } else if (!filterEmpty) { + res.push(child); + } + } else if (isValid(child)) { + res.push(child); + } + }); + return res; +}; + +export const findDOMNode = (instance: ComponentInternalInstance | null): Element => { + let node = instance?.vnode?.el || (instance && (instance?.$el || instance)); + while (node && !node.tagName) { + node = node.nextSibling; + } + return node; +}; + +export const isFF = typeof navigator === 'object' && /Firefox/i.test(navigator.userAgent); + +let raf = (callback: FrameRequestCallback) => +setTimeout(callback, 16); +let caf = (num: number) => clearTimeout(num); + +if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) { + raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback); + caf = (handle: number) => window.cancelAnimationFrame(handle); +} + +let rafUUID = 0; +const rafIds = new Map(); + +function cleanup(id: number) { + rafIds.delete(id); +} + +export default function wrapperRaf(callback: () => void, times = 1): number { + rafUUID += 1; + const id = rafUUID; + + function callRef(leftTimes: number) { + if (leftTimes === 0) { + // Clean up + cleanup(id); + + // Trigger + callback(); + } else { + // Next raf + const realId = raf(() => { + callRef(leftTimes - 1); + }); + + // Bind real raf id + rafIds.set(id, realId); + } + } + + callRef(times); + + return id; +} + +wrapperRaf.cancel = (id: number) => { + const realId = rafIds.get(id); + if (realId) { + cleanup(realId); + return caf(realId); + } +}; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts index ff0fde937b..fe97c18514 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts @@ -1,8 +1,15 @@ -import type { PropType, ExtractPropTypes, CSSProperties } from 'vue'; +import type { PropType, ExtractPropTypes, CSSProperties, VNodeTypes, Ref } from 'vue'; + +interface ResizeObserverSize { + width: number; + height: number; + offsetWidth: number; + offsetHeight: number; +} export const virtualListProps = { data: { - type: Array, + type: Array as PropType[]>, default: () => [], }, style: { @@ -18,21 +25,92 @@ export const virtualListProps = { }, height: { type: Number, + default: 0, + }, + itemHeight: { + type: Number, + default: 0, + }, + virtual: { + type: Boolean, + default: true, + }, + fullHeight: { + type: Boolean, + }, + itemKey: { + type: [String, Number, Function] as PropType) => string | number)>, + required: true, + }, + onScroll: { + type: Function as PropType<(event: UIEvent) => void>, + }, + onVisibleChange: { + type: Function as PropType<(list: unknown[], data: unknown[]) => void>, }, } as const; export const virtualListFllterProps = { - data: { - type: Array, - default: () => [], + height: { + type: Number, }, + offset: { + type: Number || undefined, + }, + disabled: { + type: Function as PropType<() => void>, + }, + onInnerResize: { + type: Function as PropType<() => void>, + } } as const; export const virtualListScrollBarProps = { - data: { - type: Array, - default: () => [], + scrollTop: { + type: Number + }, + scrollHeight: { + type: Number + }, + height: { + type: Number + }, + count: { + type: Number + }, + onScroll: { + type: Function as PropType<(scrollTop: number) => void>, + }, + onStartMove: { + type: Function as PropType<() => void>, + }, + onStopMove: { + type: Function as PropType<() => void>, }, } as const; +export const resizeObserverProps = { + disabled: { + type: Boolean, + }, + onResize: { + type: Function as PropType<(size: ResizeObserverSize, element: HTMLElement) => void> + }, +} as const; + +export type RenderFunc = ( + item: T, + index: number, + props: { style?: CSSProperties }, +) => VNodeTypes; + +export interface SharedConfig { + getKey: (item: T) => string | number | undefined; +} +export type GetScrollBarRefType = ((() => Ref) | null); + +export type GetKey> = (item: T) => string | number | undefined; + +export type CacheMap = Map; + export type VirtualListProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx index e38dcfc695..ea35018a39 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx @@ -1,33 +1,377 @@ -import type { SetupContext } from 'vue'; -import { defineComponent, toRefs, ref } from 'vue'; -import { virtualListProps, VirtualListProps } from './virtual-list-types'; +import type { SetupContext, CSSProperties, Ref } from 'vue'; +import { + defineComponent, + toRefs, + ref, + shallowRef, + reactive, + computed, + watch, + toRaw, + onMounted, + onUpdated, + nextTick, + watchEffect, + onBeforeUnmount, + provide, +} from 'vue'; +import { virtualListProps, VirtualListProps, RenderFunc, SharedConfig, GetScrollBarRefType } from './virtual-list-types'; +import useVirtual from './hooks/use-virtual'; +import useHeights from './hooks/use-heights'; +import useOriginScroll from './hooks/use-origin-scroll'; +import useFrameWheel from './hooks/use-frame-wheel'; +import useScrollTo from './hooks/use-scroll-to'; +import useMobileTouchMove from './hooks/use-mobile-touch-move'; import Filler from './components/filler'; import ScrollBar from './components/scroll-bar'; +import Item from './components/item'; + +function renderChildren( + list: T[], + startIndex: number, + endIndex: number, + setNodeRef: (item: T, element: HTMLElement & { $el: never }) => void, + { getKey }: SharedConfig, + renderFunc: RenderFunc, +) { + if (renderFunc === undefined) { return ''; } + return list.slice(startIndex, endIndex + 1).map((item, index) => { + const eleIndex = startIndex + index; + const node = renderFunc(item, eleIndex, {}); + const key = getKey(item); + return ( + setNodeRef(item, ele)}> + {node} + + ); + }); +} + +export interface ListState { + scrollTop: number; + scrollMoving: boolean; +} + +const ScrollStyle: CSSProperties = { + overflowY: 'auto', + overflowAnchor: 'none', +}; + +type ItemKeyFunction = (_item: Record) => string | number; export default defineComponent({ name: 'DVirtualList', props: virtualListProps, setup(props: VirtualListProps, ctx: SetupContext) { - const { style, class: className, component, data, ...restProps } = toRefs(props); - const componentRef = ref(null); - const scrollTop = ref(0); - const setScrollTop = (newValue: number | ((val: number) => number)) => { - let val: number; - if (typeof newValue === 'function') { - val = newValue(scrollTop.value); + const { style, class: className, component, ...restProps } = toRefs(props); + const { isVirtual, inVirtual } = useVirtual(props); + const state = reactive({ + scrollTop: 0, + scrollMoving: false, + }); + const data = computed(() => { + return props.data || []; + }); + const mergedData = shallowRef[]>([]); + watch( + data, + () => { + mergedData.value = toRaw(data.value).slice(); + }, + { immediate: true }, + ); + const itemKey = shallowRef(null); + watch( + () => props.itemKey, + (val) => { + if (typeof val === 'function') { + itemKey.value = val as unknown as ItemKeyFunction; + } else { + itemKey.value = item => item?.[val]; + } + }, + { immediate: true }, + ); + const componentRef = ref(); + const fillerInnerRef = ref(); + const getScrollBarRef = ref>(); + provide('getScrollBarRef', getScrollBarRef); + const getKey = (item: Record) => { + if (!itemKey.value) { return; } + return itemKey.value(item); + }; + const sharedConfig: SharedConfig> = { + getKey, + }; + + 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( + [isVirtual, mergedData], + () => { + if (!isVirtual.value) { + Object.assign(calRes, { + scrollHeight: undefined, + start: 0, + end: mergedData.value.length - 1, + offset: undefined, + }); + } + }, + { immediate: true }, + ); + + watch( + [isVirtual, mergedData, offsetHeight, inVirtual], + () => { + if (isVirtual.value && !inVirtual.value) { + Object.assign(calRes, { + scrollHeight: offsetHeight.value, + start: 0, + end: mergedData.value.length - 1, + offset: undefined, + }); + } + }, + { immediate: true }, + ); + + watch( + [ + inVirtual, + isVirtual, + () => state.scrollTop, + mergedData, + updatedMark, + () => props.height, + offsetHeight, + ], + () => { + if (!isVirtual.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 currentData = mergedData.value; + const scrollTop = state.scrollTop; + const { itemHeight, height } = props; + const scrollTopHeight = scrollTop + height; + for (let i = 0; i < dataLen; i += 1) { + const currentItem = currentData[i]; + const key = getKey(currentItem); + let cacheHeight = heights.get(key); + if (cacheHeight === undefined) { + cacheHeight = itemHeight; + } + const currentItemBottom = itemTop + cacheHeight; + if (startIndex === undefined && currentItemBottom >= scrollTop) { + startIndex = i; + startOffset = itemTop; + } + if (endIndex === undefined && currentItemBottom > scrollTopHeight) { + endIndex = i; + } + itemTop = currentItemBottom; + } + if (startIndex === undefined) { + startIndex = 0; + startOffset = 0; + } + if (endIndex === undefined) { + endIndex = dataLen - 1; + } + endIndex = Math.min(endIndex + 1, dataLen); + Object.assign(calRes, { + scrollHeight: itemTop, + start: startIndex, + end: endIndex, + offset: startOffset, + }); + }, + { immediate: true }, + ); + + const maxScrollHeight = computed(() => (calRes.scrollHeight || 0) - props.height); + + const 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); + + const syncScrollTop = (newTop: number | ((prev: number) => number)) => { + let value: number; + if (typeof newTop === 'function') { + value = newTop(state.scrollTop); } else { - val = newValue; + value = newTop; } - scrollTop.value = val; + const alignedTop = keepInRange(value); + if (componentRef.value) { + componentRef.value.scrollTop = alignedTop; + } + state.scrollTop = alignedTop; + }; + + const onScrollBar = (newScrollTop: number) => { + const newTop = newScrollTop; + syncScrollTop(newTop); }; - const onComponentScroll = (event: UIEvent) => { - const currentScrollTop = (event.currentTarget as HTMLElement).scrollTop; - if (currentScrollTop !== scrollTop.value) { - setScrollTop(currentScrollTop); + + const onFallbackScroll = (e: UIEvent) => { + const { scrollTop: newScrollTop } = e.currentTarget as Element; + if (Math.abs(newScrollTop - state.scrollTop) >= 1) { + syncScrollTop(newScrollTop); } + props.onScroll?.(e); }; + + const [onRawWheel, onFireFoxScroll] = useFrameWheel( + isVirtual, + isScrollAtTop, + isScrollAtBottom, + (offsetY: number) => { + syncScrollTop(top => { + const newTop = top + offsetY; + return newTop; + }); + }, + ); + + useMobileTouchMove(isVirtual, componentRef, (deltaY, smoothOffset) => { + if (originScroll(deltaY, !!smoothOffset)) { + return false; + } + onRawWheel({ deltaY } as WheelEvent); + return true; + }); + + const onMozMousePixelScroll = (e: Event) => { + if (isVirtual.value) { + e.preventDefault(); + } + }; + + const removeEventListener = () => { + if (componentRef.value) { + componentRef.value.removeEventListener( + 'wheel', + onRawWheel, + ({ passive: false } as EventListenerOptions), + ); + componentRef.value.removeEventListener('DOMMouseScroll', onFireFoxScroll); + componentRef.value.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll); + } + }; + + watchEffect(() => { + nextTick(() => { + if (componentRef.value) { + removeEventListener(); + componentRef.value.addEventListener( + 'wheel', + onRawWheel, + ({ passive: false } as EventListenerOptions), + ); + componentRef.value.addEventListener('DOMMouseScroll', onFireFoxScroll); + componentRef.value.addEventListener('MozMousePixelScroll', onMozMousePixelScroll); + } + }); + }); + + onBeforeUnmount(() => { + removeEventListener(); + }); + + const scrollTo = useScrollTo( + componentRef, + mergedData, + heights, + props, + getKey, + collectHeight, + syncScrollTop, + () => { + const sbf = getScrollBarRef.value?.().value; + sbf?.delayHidden(); + }, + ); + + ctx.expose({ + scrollTo, + }); + + const componentStyle = computed(() => { + let cs: CSSProperties | null = null; + if (props.height) { + cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle }; + if (isVirtual.value) { + cs.overflowY = 'hidden'; + if (state.scrollMoving) { + cs.pointerEvents = 'none'; + } + } + } + return cs; + }); + + // Returns the data in the view + 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 () => { - const Component: unknown = component.value; + const Component = component.value; return (
e} > - - {Array.from({ length: 100 }).map((_, index) => { - return
index: {index}
; - })} -
+ + renderChildren( + mergedData.value, + calRes.start, + calRes.end, + setInstance, + sharedConfig, + ctx.slots.default as RenderFunc, + ), + }} + />
- + {isVirtual.value && ( + { + state.scrollMoving = true; + }} + onStopMove={() => { + state.scrollMoving = false; + }} + /> + )}
); }; diff --git a/packages/devui-vue/docs/components/virtual-list/index.md b/packages/devui-vue/docs/components/virtual-list/index.md index 754f7aadd2..72891171cc 100644 --- a/packages/devui-vue/docs/components/virtual-list/index.md +++ b/packages/devui-vue/docs/components/virtual-list/index.md @@ -12,9 +12,11 @@ ```vue From 41075773c92c59b7cf4af0e38f20b1745d778c15 Mon Sep 17 00:00:00 2001 From: gxuud Date: Sat, 16 Apr 2022 09:20:30 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(virtual-list):=20eslint=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../virtual-list/src/components/item.tsx | 24 +++- .../src/components/resize-observer.tsx | 21 ++-- .../src/components/scroll-bar.tsx | 51 ++++----- .../src/hooks/use-frame-wheel.tsx | 5 +- .../virtual-list/src/hooks/use-heights.tsx | 2 - .../src/hooks/use-mobile-touch-move.tsx | 16 +-- .../virtual-list/src/hooks/use-scroll-to.tsx | 105 ------------------ .../devui-vue/devui/virtual-list/src/raf.ts | 50 --------- .../devui-vue/devui/virtual-list/src/utils.ts | 58 +--------- .../virtual-list/src/virtual-list-types.ts | 5 +- .../devui/virtual-list/src/virtual-list.tsx | 64 ++--------- 11 files changed, 84 insertions(+), 317 deletions(-) delete mode 100644 packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx delete mode 100644 packages/devui-vue/devui/virtual-list/src/raf.ts diff --git a/packages/devui-vue/devui/virtual-list/src/components/item.tsx b/packages/devui-vue/devui/virtual-list/src/components/item.tsx index b44e1aaced..aa0d77c943 100644 --- a/packages/devui-vue/devui/virtual-list/src/components/item.tsx +++ b/packages/devui-vue/devui/virtual-list/src/components/item.tsx @@ -1,6 +1,7 @@ -import type { FunctionalComponent, PropType, VNodeProps, Slots } from 'vue'; +import type { FunctionalComponent, PropType, VNodeProps, Slots, VNode } from 'vue'; import { cloneVNode } from 'vue'; import { flattenChildren } from '../utils'; +import { RenderFunc, SharedConfig } from '../virtual-list-types'; export interface ItemProps { @@ -20,4 +21,25 @@ Item.props = { }, }; +export function renderChildren( + list: T[], + startIndex: number, + endIndex: number, + setNodeRef: (item: T, element: HTMLElement & { $el: never }) => void, + { getKey }: SharedConfig, + renderFunc: RenderFunc, +): string | VNode[] { + if (renderFunc === undefined) { return ''; } + return list.slice(startIndex, endIndex + 1).map((item, index) => { + const eleIndex = startIndex + index; + const node = renderFunc(item, eleIndex, {}); + const key = getKey(item); + return ( + setNodeRef(item, ele)}> + {node} + + ); + }); +} + export default Item; diff --git a/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx b/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx index 3c94695264..3403d277ba 100644 --- a/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx +++ b/packages/devui-vue/devui/virtual-list/src/components/resize-observer.tsx @@ -1,3 +1,4 @@ +import type { ComponentInternalInstance, VNode } from 'vue'; import { defineComponent, reactive, ref, getCurrentInstance, onMounted, onUpdated, onUnmounted } from 'vue'; import { findDOMNode } from '../utils'; import { resizeObserverProps } from '../virtual-list-types'; @@ -72,16 +73,18 @@ export default defineComponent({ destroyObserver(); return; } - const element = findDOMNode(instance) as Element; - const elementChanged = element !== currentElement.value; - if (elementChanged) { - destroyObserver(); - currentElement.value = element; - } + if (instance) { + const element = findDOMNode(instance as ComponentInternalInstance & { $el: VNode['el'] }) as Element; + const elementChanged = element !== currentElement.value; + if (elementChanged) { + destroyObserver(); + currentElement.value = element; + } - if (!resizeObserver.value && element) { - resizeObserver.value = new ResizeObserver(onTriggerResize); - resizeObserver.value.observe(element); + if (!resizeObserver.value && element) { + resizeObserver.value = new ResizeObserver(onTriggerResize); + resizeObserver.value.observe(element); + } } }; onMounted(() => { diff --git a/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx b/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx index 4e4831e9ad..cd1a53f8b3 100644 --- a/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx +++ b/packages/devui-vue/devui/virtual-list/src/components/scroll-bar.tsx @@ -1,7 +1,5 @@ -import type { Ref } from 'vue'; -import { defineComponent, ref, reactive, computed, onBeforeUnmount, onMounted, inject, watch } from 'vue'; -import { virtualListScrollBarProps, GetScrollBarRefType } from '../virtual-list-types'; -import raf from '../raf'; +import { defineComponent, ref, reactive, computed, onBeforeUnmount, onMounted } from 'vue'; +import { virtualListScrollBarProps } from '../virtual-list-types'; interface ScrollBarState { dragging: boolean; @@ -10,8 +8,6 @@ interface ScrollBarState { visible: boolean; } -const MIN_SIZE = 20; - function getPageY(e: MouseEvent | TouchEvent) { return 'touches' in e ? e.touches[0].pageY : e.pageY; } @@ -19,17 +15,8 @@ function getPageY(e: MouseEvent | TouchEvent) { export default defineComponent({ name: 'DVirtualListScrollBar', props: virtualListScrollBarProps, - setup(props) { + setup(props, ctx) { const scrollbarRef = ref(null); - const getScrollBarRef = inject>('getScrollBarRef'); - watch( - scrollbarRef, - () => { - if (getScrollBarRef) { - getScrollBarRef.value = () => scrollbarRef; - } - } - ); const thumbRef = ref(null); const moveRaf = ref(0); const state = reactive({ @@ -39,25 +26,30 @@ export default defineComponent({ visible: false, }); const visibleTimeout = ref(null); + const canScroll = computed(() => { return (props.scrollHeight || 0) > (props.height || 0); }); + const getSpinHeight = () => { const { height = 0, count = 0 } = props; let baseHeight = (height / count) * 10; - baseHeight = Math.max(baseHeight, MIN_SIZE); + baseHeight = Math.max(baseHeight, 20); baseHeight = Math.min(baseHeight, height / 2); return Math.floor(baseHeight); }; + const getEnableScrollRange = () => { const { scrollHeight = 0, height = 0 } = props; return scrollHeight - height || 0; }; + const getEnableHeightRange = () => { const { height = 0 } = props; const spinHeight = getSpinHeight(); return height - spinHeight || 0; }; + const getTop = () => { const { scrollTop = 0 } = props; const enableScrollRange = getEnableScrollRange(); @@ -68,10 +60,11 @@ export default defineComponent({ const ptg = scrollTop / enableScrollRange; return ptg * enableHeightRange; }; + const onMouseMove = (e: MouseEvent | TouchEvent) => { const { dragging, pageY, startTop } = state; const { onScroll } = props; - raf.cancel(moveRaf.value); + window.cancelAnimationFrame(moveRaf.value); if (dragging) { const offsetY = getPageY(e) - (pageY || 0); const newTop = (startTop || 0) + offsetY; @@ -79,13 +72,14 @@ export default defineComponent({ const enableHeightRange = getEnableHeightRange(); const ptg = enableHeightRange ? newTop / enableHeightRange : 0; const newScrollTop = Math.ceil(ptg * enableScrollRange); - moveRaf.value = raf(() => { + moveRaf.value = window.requestAnimationFrame(() => { if (onScroll) { onScroll(newScrollTop); } }); } }; + const onMouseUp = (callback: () => void) => { const { onStopMove } = props; state.dragging = false; @@ -96,7 +90,6 @@ export default defineComponent({ callback(); } }; - const onMouseDown = (e: MouseEvent | TouchEvent, callback: () => void) => { const { onStartMove } = props; Object.assign(state, { @@ -140,8 +133,9 @@ export default defineComponent({ ); thumbRef?.value?.removeEventListener('touchend', () => onMouseUp(removeEvents)); - raf.cancel(moveRaf.value); + window.cancelAnimationFrame(moveRaf.value); }; + const onContainerMouseDown = (e: MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -169,19 +163,22 @@ export default defineComponent({ ); }); - const delayHidden = () => { + const onShowBar = () => { if (visibleTimeout.value) { clearTimeout(visibleTimeout.value); } state.visible = true; - visibleTimeout.value = setTimeout(() => { state.visible = false; - }, 2000); + }, 1000); }; + ctx.expose({ + onShowBar, + }); + return () => { - const mergedVisible = canScroll.value; + const display = canScroll.value && state.visible ? undefined : 'none'; return (
{ + nextFrame = window.requestAnimationFrame(() => { const patchMultiple = isMouseScroll ? 10 : 1; onWheelDelta(offsetRef * patchMultiple); offsetRef = 0; diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx index c41c8f8cc8..5cf2e21501 100644 --- a/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx @@ -36,14 +36,12 @@ export default function useHeights( function setInstance(item: T, ins: HTMLElement & { $el: never }) { const key = getKey(item); const origin = instance.get(key); - if (ins) { instance.set(key, ins.$el || ins); collectHeight(); } else { instance.delete(key); } - if (!origin !== !ins) { if (ins) { onItemAdd?.(item); diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx index 288d1c0581..9bbddee7d6 100644 --- a/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-mobile-touch-move.tsx @@ -1,5 +1,5 @@ import type { Ref } from 'vue'; -import { onBeforeUnmount, watch, onMounted } from 'vue'; +import { watch, onMounted } from 'vue'; const SMOOTH_PTG = 14 / 15; export default function useMobileTouchMove( @@ -17,17 +17,14 @@ export default function useMobileTouchMove( const currentY = Math.ceil(e.touches[0].pageY); let offsetY = touchY - currentY; touchY = currentY; - if (callback(offsetY)) { e.preventDefault(); } - if (interval) { clearInterval(interval); } interval = setInterval(() => { offsetY *= SMOOTH_PTG; - if (!callback(offsetY, true) || Math.abs(offsetY) <= 0.1) { if (interval) { clearInterval(interval); @@ -36,6 +33,7 @@ export default function useMobileTouchMove( }, 16); } }; + const cleanUpEvents = () => { if (element) { element.removeEventListener('touchmove', onTouchMove); @@ -48,26 +46,21 @@ export default function useMobileTouchMove( const onTouchEnd = () => { touched = false; - cleanUpEvents(); }; + const onTouchStart = (e: TouchEvent) => { cleanUpEvents(); - if (e.touches.length === 1 && !touched) { touched = true; touchY = Math.ceil(e.touches[0].pageY); - element = e.target as HTMLElement; element.addEventListener('touchmove', onTouchMove, { passive: false }); element.addEventListener('touchend', onTouchEnd); } }; - let noop: () => void | undefined; - onMounted(() => { - document.addEventListener('touchmove', noop, { passive: false }); watch( inVirtual, val => { @@ -83,7 +76,4 @@ export default function useMobileTouchMove( { immediate: true }, ); }); - onBeforeUnmount(() => { - document.removeEventListener('touchmove', noop); - }); } diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx deleted file mode 100644 index d5c5e1875f..0000000000 --- a/packages/devui-vue/devui/virtual-list/src/hooks/use-scroll-to.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { ShallowRef, Ref } from 'vue'; -import type { VirtualListProps, CacheMap } from '../virtual-list-types'; -import raf from '../raf'; -type GetKey> = (item: T) => string | number | undefined; -type IUseScrollToArg = null | number | { offset: number; align: 'top' | 'bottom'; index: number; key: string | number }; -export default function useScrollTo( - containerRef: Ref, - mergedData: ShallowRef, - heights: CacheMap, - props: VirtualListProps, - getKey: GetKey, - collectHeight: () => void, - syncScrollTop: (newTop: number) => void, - triggerFlash: () => void, -): (arg?: IUseScrollToArg) => void { - let scroll: number; - - return (arg?: IUseScrollToArg) => { - if (arg === null || arg === undefined) { - triggerFlash(); - return; - } - - if (scroll) { - raf.cancel(scroll); - } - const data = mergedData.value; - const itemHeight = props.itemHeight; - if (typeof arg === 'number') { - syncScrollTop(arg); - } else if (arg && typeof arg === 'object') { - let index: number; - const { align } = arg; - - if (arg.index) { - index = arg.index; - } else { - index = data.findIndex( - (item) => getKey(item as Record) === arg.key - ); - } - - const { offset = 0 } = arg; - - const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => { - if (times < 0 || !containerRef.value) { return; } - - const height = containerRef.value.clientHeight; - let needCollectHeight = false; - let newTargetAlign = targetAlign; - - if (height) { - const mergedAlign = targetAlign || align; - let stackTop = 0; - let itemTop = 0; - let itemBottom = 0; - - const maxLen = Math.min(data.length, index); - for (let i = 0; i <= maxLen; i += 1) { - const key = getKey(data[i] as Record); - itemTop = stackTop; - const cacheHeight = heights.get(key); - itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); - stackTop = itemBottom; - if (i === index && cacheHeight === undefined) { - needCollectHeight = true; - } - } - const scrollTop = containerRef.value.scrollTop; - - let targetTop: number | null = null; - - switch (mergedAlign) { - case 'top': - targetTop = itemTop - offset; - break; - case 'bottom': - targetTop = itemBottom - height + offset; - break; - default: { - const scrollBottom = scrollTop + height; - if (itemTop < scrollTop) { - newTargetAlign = 'top'; - } else if (itemBottom > scrollBottom) { - newTargetAlign = 'bottom'; - } - } - } - if (targetTop !== null && targetTop !== scrollTop) { - syncScrollTop(targetTop); - } - } - - scroll = raf(() => { - if (needCollectHeight) { - collectHeight(); - } - syncScroll(times - 1, newTargetAlign); - }); - }; - - syncScroll(3); - } - }; -} diff --git a/packages/devui-vue/devui/virtual-list/src/raf.ts b/packages/devui-vue/devui/virtual-list/src/raf.ts deleted file mode 100644 index 64030b9211..0000000000 --- a/packages/devui-vue/devui/virtual-list/src/raf.ts +++ /dev/null @@ -1,50 +0,0 @@ -let raf = (callback: FrameRequestCallback) => +setTimeout(callback, 16); -let caf = (num: number) => clearTimeout(num); - -if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) { - raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback); - caf = (handle: number) => window.cancelAnimationFrame(handle); -} - -let rafUUID = 0; -const rafIds = new Map(); - -function cleanup(id: number) { - rafIds.delete(id); -} - -export default function wrapperRaf(callback: () => void, times = 1): number { - rafUUID += 1; - const id = rafUUID; - - function callRef(leftTimes: number) { - if (leftTimes === 0) { - // Clean up - cleanup(id); - - // Trigger - callback(); - } else { - // Next raf - const realId = raf(() => { - callRef(leftTimes - 1); - }); - - // Bind real raf id - rafIds.set(id, realId); - } - } - - callRef(times); - - return id; -} - -wrapperRaf.cancel = (id: number) => { - const realId = rafIds.get(id); - if (realId) { - cleanup(realId); - return caf(realId); - } - return undefined; -}; diff --git a/packages/devui-vue/devui/virtual-list/src/utils.ts b/packages/devui-vue/devui/virtual-list/src/utils.ts index 77523029a3..1471bd4559 100644 --- a/packages/devui-vue/devui/virtual-list/src/utils.ts +++ b/packages/devui-vue/devui/virtual-list/src/utils.ts @@ -11,7 +11,7 @@ export const isEmptyElement = (c?: VNode): boolean => { !!c && (c.type === Comment || (c.type === Fragment && c?.children?.length === 0) || - (c.type === Text && c?.children?.trim() === '')) + (c.type === Text && (c?.children as string)?.trim() === '')) ); }; @@ -22,7 +22,7 @@ export const flattenChildren = (children?: VNode[], filterEmpty = true): VNode[] if (Array.isArray(child)) { res.push(...flattenChildren(child, filterEmpty)); } else if (child && child.type === Fragment) { - res.push(...flattenChildren(child.children, filterEmpty)); + res.push(...flattenChildren(child.children as VNode[], filterEmpty)); } else if (child && isVNode(child)) { if (filterEmpty && !isEmptyElement(child)) { res.push(child); @@ -30,13 +30,13 @@ export const flattenChildren = (children?: VNode[], filterEmpty = true): VNode[] res.push(child); } } else if (isValid(child)) { - res.push(child); + res.push(child as unknown as VNode); } }); return res; }; -export const findDOMNode = (instance: ComponentInternalInstance | null): Element => { +export const findDOMNode = (instance: (ComponentInternalInstance & { $el: VNode['el'] }) | null): VNode['el'] => { let node = instance?.vnode?.el || (instance && (instance?.$el || instance)); while (node && !node.tagName) { node = node.nextSibling; @@ -45,53 +45,3 @@ export const findDOMNode = (instance: ComponentInternalInstance | null): Element }; export const isFF = typeof navigator === 'object' && /Firefox/i.test(navigator.userAgent); - -let raf = (callback: FrameRequestCallback) => +setTimeout(callback, 16); -let caf = (num: number) => clearTimeout(num); - -if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) { - raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback); - caf = (handle: number) => window.cancelAnimationFrame(handle); -} - -let rafUUID = 0; -const rafIds = new Map(); - -function cleanup(id: number) { - rafIds.delete(id); -} - -export default function wrapperRaf(callback: () => void, times = 1): number { - rafUUID += 1; - const id = rafUUID; - - function callRef(leftTimes: number) { - if (leftTimes === 0) { - // Clean up - cleanup(id); - - // Trigger - callback(); - } else { - // Next raf - const realId = raf(() => { - callRef(leftTimes - 1); - }); - - // Bind real raf id - rafIds.set(id, realId); - } - } - - callRef(times); - - return id; -} - -wrapperRaf.cancel = (id: number) => { - const realId = rafIds.get(id); - if (realId) { - cleanup(realId); - return caf(realId); - } -}; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts index fe97c18514..5a093a651c 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts @@ -107,7 +107,10 @@ export type RenderFunc = ( export interface SharedConfig { getKey: (item: T) => string | number | undefined; } -export type GetScrollBarRefType = ((() => Ref) | null); + +export interface IScrollBarExposeFunction { + onShowBar?: () => void; +} export type GetKey> = (item: T) => string | number | undefined; diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx index ea35018a39..3e08d0bbfb 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx @@ -1,4 +1,4 @@ -import type { SetupContext, CSSProperties, Ref } from 'vue'; +import type { SetupContext, CSSProperties, HTMLAttributes } from 'vue'; import { defineComponent, toRefs, @@ -12,40 +12,18 @@ import { onUpdated, nextTick, watchEffect, - onBeforeUnmount, - provide, + onBeforeUnmount } from 'vue'; -import { virtualListProps, VirtualListProps, RenderFunc, SharedConfig, GetScrollBarRefType } from './virtual-list-types'; +import type { VirtualListProps, RenderFunc, SharedConfig, IScrollBarExposeFunction } from './virtual-list-types'; +import { virtualListProps } from './virtual-list-types'; import useVirtual from './hooks/use-virtual'; import useHeights from './hooks/use-heights'; import useOriginScroll from './hooks/use-origin-scroll'; import useFrameWheel from './hooks/use-frame-wheel'; -import useScrollTo from './hooks/use-scroll-to'; import useMobileTouchMove from './hooks/use-mobile-touch-move'; import Filler from './components/filler'; import ScrollBar from './components/scroll-bar'; -import Item from './components/item'; - -function renderChildren( - list: T[], - startIndex: number, - endIndex: number, - setNodeRef: (item: T, element: HTMLElement & { $el: never }) => void, - { getKey }: SharedConfig, - renderFunc: RenderFunc, -) { - if (renderFunc === undefined) { return ''; } - return list.slice(startIndex, endIndex + 1).map((item, index) => { - const eleIndex = startIndex + index; - const node = renderFunc(item, eleIndex, {}); - const key = getKey(item); - return ( - setNodeRef(item, ele)}> - {node} - - ); - }); -} +import { renderChildren } from './components/item'; export interface ListState { scrollTop: number; @@ -94,8 +72,7 @@ export default defineComponent({ ); const componentRef = ref(); const fillerInnerRef = ref(); - const getScrollBarRef = ref>(); - provide('getScrollBarRef', getScrollBarRef); + const barRef = ref(); const getKey = (item: Record) => { if (!itemKey.value) { return; } return itemKey.value(item); @@ -261,11 +238,12 @@ export default defineComponent({ syncScrollTop(newTop); }; - const onFallbackScroll = (e: UIEvent) => { + const onComponentScroll = (e: UIEvent) => { const { scrollTop: newScrollTop } = e.currentTarget as Element; if (Math.abs(newScrollTop - state.scrollTop) >= 1) { syncScrollTop(newScrollTop); } + barRef?.value?.onShowBar?.(); props.onScroll?.(e); }; @@ -326,24 +304,6 @@ export default defineComponent({ removeEventListener(); }); - const scrollTo = useScrollTo( - componentRef, - mergedData, - heights, - props, - getKey, - collectHeight, - syncScrollTop, - () => { - const sbf = getScrollBarRef.value?.().value; - sbf?.delayHidden(); - }, - ); - - ctx.expose({ - scrollTo, - }); - const componentStyle = computed(() => { let cs: CSSProperties | null = null; if (props.height) { @@ -371,7 +331,7 @@ export default defineComponent({ ); return () => { - const Component = component.value; + const Component = component.value as keyof HTMLAttributes; return (
e} > {isVirtual.value && ( Date: Sat, 16 Apr 2022 18:38:51 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(virtual-list):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=EF=BC=8C=E5=88=A0=E9=99=A4=E6=97=A0=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../virtual-list/src/hooks/use-heights.tsx | 10 ---- .../virtual-list/src/hooks/use-virtual.tsx | 8 +-- .../virtual-list/src/virtual-list-types.ts | 17 +----- .../devui/virtual-list/src/virtual-list.css | 3 - .../devui/virtual-list/src/virtual-list.tsx | 58 +++++++------------ .../docs/components/virtual-list/index.md | 42 ++++++++------ 6 files changed, 51 insertions(+), 87 deletions(-) delete mode 100644 packages/devui-vue/devui/virtual-list/src/virtual-list.css diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx index 5cf2e21501..117371b341 100644 --- a/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-heights.tsx @@ -5,8 +5,6 @@ import { watch, ref } from 'vue'; export default function useHeights( mergedData: ShallowRef, getKey: GetKey, - onItemAdd?: ((item: T) => void) | null, - onItemRemove?: ((item: T) => void) | null, ): [(item: T, instance: HTMLElement & { $el: never }) => void, () => void, CacheMap, Ref] { const instance = new Map(); let heights = new Map(); @@ -35,20 +33,12 @@ export default function useHeights( function setInstance(item: T, ins: HTMLElement & { $el: never }) { const key = getKey(item); - const origin = instance.get(key); if (ins) { instance.set(key, ins.$el || ins); collectHeight(); } else { instance.delete(key); } - if (!origin !== !ins) { - if (ins) { - onItemAdd?.(item); - } else { - onItemRemove?.(item); - } - } } return [setInstance, collectHeight, heights, updatedMark]; diff --git a/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx b/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx index 7664b0c5cf..ff445b6154 100644 --- a/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx +++ b/packages/devui-vue/devui/virtual-list/src/hooks/use-virtual.tsx @@ -9,12 +9,12 @@ interface IUseVirtual { export default function useVirtual(props: VirtualListProps): IUseVirtual { const isVirtual = computed(() => { - const { height, itemHeight, virtual } = props; - return !!(virtual !== false && height && itemHeight); + const { height, virtual } = props; + return !!(virtual !== false && height); }); const inVirtual = computed(() => { - const { height, itemHeight, data } = props; - return isVirtual.value && data && itemHeight * data.length > height; + const { height, data } = props; + return isVirtual.value && data && 20 * data.length > height; }); return { isVirtual, inVirtual }; } diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts index 5a093a651c..11725e1cfd 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list-types.ts @@ -1,4 +1,4 @@ -import type { PropType, ExtractPropTypes, CSSProperties, VNodeTypes, Ref } from 'vue'; +import type { PropType, ExtractPropTypes, CSSProperties, VNodeTypes } from 'vue'; interface ResizeObserverSize { width: number; @@ -12,20 +12,13 @@ export const virtualListProps = { type: Array as PropType[]>, default: () => [], }, - style: { - type: Object as PropType, - }, - class: { - type: String, - default: '', - }, component: { type: String, default: 'div', }, height: { type: Number, - default: 0, + default: 100, }, itemHeight: { type: Number, @@ -42,12 +35,6 @@ export const virtualListProps = { type: [String, Number, Function] as PropType) => string | number)>, required: true, }, - onScroll: { - type: Function as PropType<(event: UIEvent) => void>, - }, - onVisibleChange: { - type: Function as PropType<(list: unknown[], data: unknown[]) => void>, - }, } as const; export const virtualListFllterProps = { diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.css b/packages/devui-vue/devui/virtual-list/src/virtual-list.css deleted file mode 100644 index 52264f3550..0000000000 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list.css +++ /dev/null @@ -1,3 +0,0 @@ -.virtual-list { - /* your style */ -} diff --git a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx index 3e08d0bbfb..b347ed18c5 100644 --- a/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx +++ b/packages/devui-vue/devui/virtual-list/src/virtual-list.tsx @@ -1,7 +1,6 @@ import type { SetupContext, CSSProperties, HTMLAttributes } from 'vue'; import { defineComponent, - toRefs, ref, shallowRef, reactive, @@ -12,9 +11,10 @@ import { onUpdated, nextTick, watchEffect, - onBeforeUnmount + onBeforeUnmount, + unref } from 'vue'; -import type { VirtualListProps, RenderFunc, SharedConfig, IScrollBarExposeFunction } from './virtual-list-types'; +import type { VirtualListProps, RenderFunc, IScrollBarExposeFunction } from './virtual-list-types'; import { virtualListProps } from './virtual-list-types'; import useVirtual from './hooks/use-virtual'; import useHeights from './hooks/use-heights'; @@ -41,7 +41,6 @@ export default defineComponent({ name: 'DVirtualList', props: virtualListProps, setup(props: VirtualListProps, ctx: SetupContext) { - const { style, class: className, component, ...restProps } = toRefs(props); const { isVirtual, inVirtual } = useVirtual(props); const state = reactive({ scrollTop: 0, @@ -77,15 +76,10 @@ export default defineComponent({ if (!itemKey.value) { return; } return itemKey.value(item); }; - const sharedConfig: SharedConfig> = { - getKey, - }; const [setInstance, collectHeight, heights, updatedMark] = useHeights>( mergedData, getKey, - null, - null, ); const calRes = reactive<{ @@ -155,24 +149,21 @@ export default defineComponent({ offsetHeight, ], () => { - if (!isVirtual.value || !inVirtual.value) { - return; - } + if (!isVirtual.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 currentData = mergedData.value; + const mergedDataValue = unref(mergedData); const scrollTop = state.scrollTop; - const { itemHeight, height } = props; + const { height } = props; const scrollTopHeight = scrollTop + height; - for (let i = 0; i < dataLen; i += 1) { - const currentItem = currentData[i]; - const key = getKey(currentItem); + for (let i = 0; i < mergedDataValue.length; i += 1) { + const mergedDataItem = mergedDataValue[i]; + const key = getKey(mergedDataItem); let cacheHeight = heights.get(key); if (cacheHeight === undefined) { - cacheHeight = itemHeight; + cacheHeight = 20; } const currentItemBottom = itemTop + cacheHeight; if (startIndex === undefined && currentItemBottom >= scrollTop) { @@ -189,9 +180,9 @@ export default defineComponent({ startOffset = 0; } if (endIndex === undefined) { - endIndex = dataLen - 1; + endIndex = mergedDataValue.length - 1; } - endIndex = Math.min(endIndex + 1, dataLen); + endIndex = Math.min(endIndex + 1, mergedDataValue.length); Object.assign(calRes, { scrollHeight: itemTop, start: startIndex, @@ -244,7 +235,7 @@ export default defineComponent({ syncScrollTop(newScrollTop); } barRef?.value?.onShowBar?.(); - props.onScroll?.(e); + ctx.emit('scroll', e); }; const [onRawWheel, onFireFoxScroll] = useFrameWheel( @@ -307,7 +298,7 @@ export default defineComponent({ const componentStyle = computed(() => { let cs: CSSProperties | null = null; if (props.height) { - cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle }; + cs = { maxHeight: isVirtual.value ? props.height + 'px' : undefined, ...ScrollStyle }; if (isVirtual.value) { cs.overflowY = 'hidden'; if (state.scrollMoving) { @@ -318,28 +309,19 @@ export default defineComponent({ return cs; }); - // Returns the data in the view watch( [() => calRes.start, () => calRes.end, mergedData], () => { - if (props.onVisibleChange) { - const renderList = mergedData.value.slice(calRes.start, calRes.end + 1); - props.onVisibleChange(renderList, mergedData.value); - } + const renderList = mergedData.value.slice(calRes.start, calRes.end + 1); + ctx.emit('show-change', renderList, mergedData.value); }, { flush: 'post' }, ); return () => { - const Component = component.value as keyof HTMLAttributes; + const Component = props.component as keyof HTMLAttributes; return ( -
e} - > +
, + { getKey }, + ctx.slots.item as RenderFunc, ), }} /> diff --git a/packages/devui-vue/docs/components/virtual-list/index.md b/packages/devui-vue/docs/components/virtual-list/index.md index 72891171cc..13c898f8eb 100644 --- a/packages/devui-vue/docs/components/virtual-list/index.md +++ b/packages/devui-vue/docs/components/virtual-list/index.md @@ -8,12 +8,12 @@ ### 基本用法 -:::demo // todo 展开代码的内部描述 +:::demo 渲染五千条数据 ```vue