import { computed, defineComponent, nextTick, onMounted, onUnmounted, onUpdated, ref, SlotsType, VNode, watch } from 'vue'; import { SetupComponentOptions } from './types'; import { Container, ElementLocator, RenderItem, Sprite, Transform } from '@/core/render'; import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; import { hyper, Transition } from 'mutate-animate'; import { clamp } from 'lodash-es'; import { IActionEvent, IWheelEvent, MouseType } from '@/core/render/event'; export const enum ScrollDirection { Horizontal, Vertical } export interface ScrollExpose { /** * 控制滚动条滚动至目标位置 * @param y 滚动至的目标位置 * @param time 滚动的动画时长,默认为无动画 */ scrollTo(y: number, time?: number): void; } export interface ScrollProps { loc: ElementLocator; hor?: boolean; noscroll?: boolean; /** * 滚动到最下方(最右方)时的填充大小,如果默认的高度计算方式有误, * 那么可以调整此参数来修复错误 */ padHeight?: number; } type ScrollSlots = SlotsType<{ default: () => VNode | VNode[]; }>; const scrollProps = { props: ['hor', 'noscroll', 'loc', 'padHeight'] } satisfies SetupComponentOptions<ScrollProps, {}, string, ScrollSlots>; /** 滚动条图示的最短长度 */ const SCROLL_MIN_LENGTH = 20; /** 滚动条图示的宽度 */ const SCROLL_WIDTH = 10; /** 滚动条的颜色 */ const SCROLL_COLOR = '#ddd'; /** * 滚动条组件,具有虚拟滚动功能,即在画面外的不渲染。参数参考 {@link ScrollProps},暴露接口参考 {@link ScrollExpose} * * --- * * 使用时,建议使用平铺式布局,即包含很多子元素,而不要用一个 container 将所有内容包裹, * 每个子元素的高度(宽度)不建议过大,以更好地通过虚拟滚动优化 * * **推荐写法**: * ```tsx * <Scroll> * <item /> * <item /> * ...其他元素 * <item /> * <item /> * </Scroll> * ``` * **不推荐**使用这种写法: * ```tsx * <Scroll> * <container> * <item /> * </container> * <Scroll> * ``` */ export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>( (props, { slots, expose }) => { /** 滚动条的定位 */ const sp = ref<ElementLocator>([0, 0, 1, 1]); const listenedChild: Set<RenderItem> = new Set(); const areaMap: Map<RenderItem, [number, number]> = new Map(); const content = ref<Container>(); const scroll = ref<Sprite>(); const width = computed(() => props.loc[2] ?? 200); const height = computed(() => props.loc[3] ?? 200); const direction = computed(() => props.hor ? ScrollDirection.Horizontal : ScrollDirection.Vertical ); /** 滚动内容的当前位置 */ let contentPos = 0; /** 滚动条的当前位置 */ let scrollPos = 0; /** 滚动内容的目标位置 */ let contentTarget = 0; /** 滚动条的目标位置 */ let scrollTarget = 0; /** 滚动内容的长度 */ let maxLength = 0; /** 滚动条的长度 */ let scrollLength = SCROLL_MIN_LENGTH; const transition = new Transition(); transition.value.scroll = 0; transition.value.showScroll = 0; transition.mode(hyper('sin', 'out')).absolute(); //#region 滚动操作 transition.ticker.add(() => { if (scrollPos !== scrollTarget) { scrollPos = transition.value.scroll; content.value?.update(); } if (contentPos !== contentTarget) { contentPos = transition.value.showScroll; checkAllItem(); updatePosition(); content.value?.update(); } }); /** * 滚动到目标值 * @param time 动画时长 */ const scrollTo = (y: number, time: number = 0) => { if (maxLength < height.value) return; const max = maxLength - height.value; const target = clamp(y, 0, max); contentTarget = target; if (direction.value === ScrollDirection.Horizontal) { scrollTarget = (width.value - scrollLength) * (contentTarget / max); } else { scrollTarget = (height.value - scrollLength) * (contentTarget / max); } transition.time(time).transition('scroll', scrollTarget); transition.time(time).transition('showScroll', target); }; /** * 计算一个元素会在画面上显示的区域 */ const getArea = (item: RenderItem, rect: DOMRectReadOnly) => { if (direction.value === ScrollDirection.Horizontal) { areaMap.set(item, [rect.left - width.value, rect.right]); } else { areaMap.set(item, [rect.top - height.value, rect.bottom]); } }; /** * 检查一个元素是否需要显示,不需要则隐藏 */ const checkItem = (item: RenderItem) => { const area = areaMap.get(item); if (!area) { item.show(); return; } const [min, max] = area; if (contentPos > min - 10 && contentPos < max + 10) { item.show(); } else { item.hide(); } }; /** * 对所有元素执行显示检查 */ const checkAllItem = () => { content.value?.children.forEach(v => checkItem(v)); }; /** * 当一个元素的矩阵发生变换时执行,检查其显示区域 */ const onTransform = (item: RenderItem) => { const rect = item.getBoundingRect(); getArea(item, rect); checkItem(item); }; /** * 更新滚动条位置 */ const updatePosition = () => { if (direction.value === ScrollDirection.Horizontal) { scrollLength = Math.max( SCROLL_MIN_LENGTH, (width.value / maxLength) * width.value ); const h = props.noscroll ? height.value : height.value - SCROLL_WIDTH; sp.value = [0, h, width.value, SCROLL_WIDTH]; } else { scrollLength = clamp( (height.value / maxLength) * height.value, SCROLL_MIN_LENGTH, height.value - 10 ); const w = props.noscroll ? width.value : width.value - SCROLL_WIDTH; sp.value = [w, 0, SCROLL_WIDTH, height.value]; } }; let updating = false; const updateScroll = () => { if (!content.value || updating) return; updating = true; nextTick(() => { updating = false; }); let max = 0; listenedChild.forEach(v => v.off('transform', onTransform)); listenedChild.clear(); areaMap.clear(); content.value.children.forEach(v => { const rect = v.getBoundingRect(); if (direction.value === ScrollDirection.Horizontal) { if (rect.right > max) { max = rect.right; } } else { if (rect.bottom > max) { max = rect.bottom; } } getArea(v, rect); v.on('transform', onTransform); listenedChild.add(v); checkItem(v); }); maxLength = max + (props.padHeight ?? 0); updatePosition(); scroll.value?.update(); }; watch(() => props.loc, updateScroll); onUpdated(updateScroll); onMounted(updateScroll); onUnmounted(() => { listenedChild.forEach(v => v.off('transform', onTransform)); }); //#endregion //#region 渲染滚动 const drawScroll = (canvas: MotaOffscreenCanvas2D) => { if (props.noscroll) return; const ctx = canvas.ctx; ctx.lineCap = 'round'; ctx.lineWidth = 3; ctx.strokeStyle = SCROLL_COLOR; ctx.beginPath(); const scroll = transition.value.scroll; if (direction.value === ScrollDirection.Horizontal) { ctx.moveTo(scroll + 5, 5); ctx.lineTo(scroll + scrollLength - 5, 5); } else { ctx.moveTo(5, scroll + 5); ctx.lineTo(5, scroll + scrollLength - 5); } ctx.stroke(); }; const renderContent = ( canvas: MotaOffscreenCanvas2D, children: RenderItem[], transform: Transform ) => { const ctx = canvas.ctx; ctx.save(); if (direction.value === ScrollDirection.Horizontal) { ctx.translate(-contentPos, 0); } else { ctx.translate(0, -contentPos); } children.forEach(v => { if (v.hidden) return; v.renderContent(canvas, transform); }); ctx.restore(); }; //#endregion //#region 事件监听 const wheelScroll = (delta: number, max: number) => { const sign = Math.sign(delta); const dx = Math.abs(delta); const movement = Math.min(max, dx) * sign; scrollTo(contentTarget + movement, dx > 10 ? 300 : 0); }; const wheel = (ev: IWheelEvent) => { if (direction.value === ScrollDirection.Horizontal) { if (ev.wheelX !== 0) { wheelScroll(ev.wheelX, width.value / 5); } else if (ev.wheelY !== 0) { wheelScroll(ev.wheelY, width.value / 5); } } else { wheelScroll(ev.wheelY, height.value / 5); } }; const getPos = (ev: IActionEvent, absolute: boolean = false) => { if (absolute) { if (direction.value === ScrollDirection.Horizontal) { return ev.absoluteX; } else { return ev.absoluteY; } } else { if (direction.value === ScrollDirection.Horizontal) { return ev.offsetX; } else { return ev.offsetY; } } }; let identifier = -2; let downPos = 0; /** 拖动内容时,内容原本所在的位置 */ let contentBefore = 0; const down = (ev: IActionEvent) => { identifier = ev.identifier; downPos = getPos(ev, true); contentBefore = contentTarget; }; const move = (ev: IActionEvent) => { if (ev.identifier !== identifier) return; let pos = 0; if (ev.touch) { pos = getPos(ev); } else { if (ev.buttons & MouseType.Left) { pos = getPos(ev); } else { return; } } const movement = pos - downPos; scrollTo(contentBefore - movement, 0); }; /** 最初滚动条在哪 */ let scrollBefore = 0; /** 本次拖动滚动条的操作标识符 */ let scrollIdentifier = -2; /** 点击滚动条时,点击位置在平行于滚动条方向的位置 */ let scrollDownPos = 0; /** 是否是点击了滚动条区域中滚动条之外的地方,这样视为类滚轮操作 */ let scrollMutate = false; /** 点击滚动条时,点击位置垂直于滚动条方向的位置 */ let scrollPin = 0; /** * 获取点击滚动条时,垂直于滚动条方向的位置 */ const getScrollPin = (ev: IActionEvent) => { if (direction.value === ScrollDirection.Horizontal) { return ev.absoluteY; } else { return ev.absoluteX; } }; const downScroll = (ev: IActionEvent) => { scrollBefore = contentTarget; scrollIdentifier = ev.identifier; const pos = getPos(ev, true); // 计算点击在了滚动条的哪个位置 const offsetPos = getPos(ev); const sEnd = scrollPos + scrollLength; if (offsetPos >= scrollPos && offsetPos <= sEnd) { scrollDownPos = pos - scrollPos; scrollMutate = false; scrollPin = getScrollPin(ev); } else { scrollMutate = true; } }; const moveScroll = (ev: IActionEvent) => { if (ev.identifier !== scrollIdentifier || scrollMutate) return; const pos = getPos(ev, true); const scrollPos = pos - scrollDownPos; let deltaPin = 0; let threshold = 0; if (ev.touch) { const pin = getScrollPin(ev); deltaPin = Math.abs(pin - scrollPin); threshold = 200; } else { const pin = getScrollPin(ev); deltaPin = Math.abs(pin - scrollPin); threshold = 100; } if (deltaPin > threshold) { scrollTo(scrollBefore, 0); } else { const pos = (scrollPos / height.value) * maxLength; scrollTo(pos, 0); } }; const upScroll = (ev: IActionEvent) => { if (!scrollMutate) return; const pos = getPos(ev); if (pos < scrollPos) { scrollTo(contentTarget - 50, 300); } else { scrollTo(contentTarget + 50, 300); } }; //#endregion onMounted(() => { scroll.value?.root?.on('move', move); scroll.value?.root?.on('move', moveScroll); }); onUnmounted(() => { scroll.value?.root?.off('move', move); scroll.value?.root?.off('move', moveScroll); transition.ticker.destroy(); }); expose<ScrollExpose>({ scrollTo }); return () => { return ( <container loc={props.loc} onWheel={wheel}> <container-custom loc={[0, 0, props.loc[2], props.loc[3]]} ref={content} onDown={down} render={renderContent} zIndex={0} > {slots.default?.()} </container-custom> <sprite nocache loc={sp.value} ref={scroll} render={drawScroll} onDown={downScroll} onUp={upScroll} zIndex={10} ></sprite> </container> ); }; }, scrollProps );