UNPKG

@egjs/conveyer

Version:

Conveyer adds Drag gestures to your Native Scroll.

703 lines (650 loc) 23.9 kB
/** * egjs-conveyer * Copyright (c) 2022-present NAVER Corp. * MIT license */ import Axes, { OnChange, OnHold, PanInput, WheelInput } from "@egjs/axes"; import Component from "@egjs/component"; import { diff } from "@egjs/children-differ"; import { IS_IE } from "./browser"; import { ReactiveSubscribe, Reactive, Ref } from "@cfcs/core"; import { ConveyerEvents, ConveyerItem, ConveyerOptions, FindItemOptions, ConveyerReactiveState, ScrollIntoViewOptions, } from "./types"; import { instanceOfElement, isIntersection, isString } from "./utils"; /** * Conveyer adds Drag gestures to your Native Scroll. * @ko Conveyer는 네이티브 스크롤에 드래그 제스처를 추가합니다. * @extends Component * @support {"ie": "9+(with polyfill)", "ch" : "latest", "ff" : "latest", "sf" : "latest", "edge" : "latest", "ios" : "7+", "an" : "4.X+"} * @example ```html <div class="items"> <div class="item"></div> <div class="item"></div> <div class="item"></div> </div> <script> import Conveyer from "@egjs/conveyer"; const conveyer = new Conveyer(".items"); </script> ``` */ @ReactiveSubscribe class Conveyer extends Component<ConveyerEvents> { protected _scrollAreaElement: HTMLElement; protected _axes: Axes | null = null; protected _items: ConveyerItem[] = []; protected _size = 0; protected _scrollSize = 0; protected _options: ConveyerOptions; protected _animateParam: { expectedPos: number; offset: number; } | null = null; private _resizeObserver: ResizeObserver | null = null; private _scrollTimer = 0; private _isWheelScroll = false; private _isDragScroll = false; private _isAnimationScroll = false; private _scrollArea: string | HTMLElement | Ref<HTMLElement>; private _panInput: PanInput | null = null; private _wheelInput: WheelInput | null = null; /** * Whether the scroll has reached the start. * @ko 스크롤이 시작에 닿았는지 여부. * @name Conveyer#isReachStart * @type {boolean} * @default true * @readonly * @example * ```js * import { Conveyer } from "@egjs/conveyer"; * * const conveyer = new Conveyer(".container"); * * conveyer.isReachStart * ``` */ @Reactive("isReachStart") private _isReachStart = true; /** * Whether the scroll has reached the end. * @ko 스크롤이 끝에 닿았는지 여부. * @name Conveyer#isReachEnd * @type {boolean} * @default false * @readonly * @example * ```js * import { Conveyer } from "@egjs/conveyer"; * * const conveyer = new Conveyer(".container"); * * conveyer.isReachEnd * ``` */ @Reactive("isReachEnd") private _isReachEnd = false; /** * the scroll position of the container * @ko 컨테이너의 스크롤 위치 * @name Conveyer#scrollPos * @type {number} * @default 0 * @readonly * @example * ```js * import { Conveyer } from "@egjs/conveyer"; * * const conveyer = new Conveyer(".container"); * * conveyer.scrollPos * ``` */ @Reactive("scrollPos") protected _pos = 0; /** * @param - A base element for a module <ko>모듈을 적용할 기준 엘리먼트</ko> * @param - The option object of the InfiniteGrid module <ko>eg.InfiniteGrid 모듈의 옵션 객체</ko> */ constructor(scrollArea: string | HTMLElement | Ref<HTMLElement>, options: ConveyerOptions = {}) { super(); this._options = { horizontal: true, useDrag: true, useSideWheel: false, autoInit: true, boundaryMargin: 0, scrollDebounce: 100, useResizeObserver: false, ...options, }; this._scrollArea = scrollArea; if (this._options.autoInit) { this.init(); } } /** * Finds an element for that direction. * @ko 해당 방향에 대해 엘리먼트를 찾는다.] * @see {@link /docs/examples/Methods direction's example} page for detailed information * @param - direction of the element. "start" and "end" find inside. "prev" and "next" find outside. <ko>엘리먼트의 방향. "start", "end"는 안쪽으로 찾는다. "prev", "next"는 바깥쪽으로 찾는다.</ko> * @param - Options for the `findElement` method. <ko>findElement 메서드의 옵션</ko> * @example * <p align="center"> * <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView1.png" height="200" /> * </p> * <p align="center"> * <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView2.png" height="210" /> * </p> */ public findElement(direction: "start" | "end" | "prev" | "next", options: FindItemOptions = {}) { return this.findItem(direction, options)?.element || null; } /** * Finds an item for an element or its direction. * @ko 엘리먼트 또는 해당 방향에 대해 아이템을 찾는다. * @see {@link /docs/examples/Methods direction's example} page for detailed information * @param - direction of the element. "start" and "end" find inside. "prev" and "next" find outside. <ko>엘리먼트의 방향. "start", "end"는 안쪽으로 찾는다. "prev", "next"는 바깥쪽으로 찾는다.</ko> * @param - Options for the `findItem` method. <ko>`findItem` 메서드의 옵션</ko> * @example * <p align="center"> * <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView1.png" height="200" /> * </p> * <p align="center"> * <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView2.png" height="210" /> * </p> */ public findItem( target: HTMLElement | "start" | "end" | "prev" | "next", options: FindItemOptions = {}, ): ConveyerItem | null { const pos = this._pos; const scrollSize = this._scrollSize; const size = this._size; const hitTest = options?.hitTest ?? 1; const items = [...this._items]; const length = items.length; const endPos = pos + size; const sibling = options.sibling; const intersection = options.intersection; const startVirtualItem = { pos: 0, size: 0 }; const endVirtualItem = { pos: scrollSize, size: 0 }; if (items[0].pos > 0) { items.unshift(startVirtualItem); } if (length && items[length - 1].pos + items[length - 1].size < scrollSize) { items.push(endVirtualItem); } let selectedItem: ConveyerItem | undefined; if (target === "start") { if (pos < 0) { return null; } const selectedItems = items.filter(item => { const itemSize = item.size; const dist = item.pos - pos; const dist2 = dist + itemSize; return (dist >= 0) || (dist2 > 0 && isIntersection(dist, dist2, "end", intersection)) || (dist2 >= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest)); }); selectedItem = (selectedItems[0] === startVirtualItem && selectedItems[1]) || selectedItems[0]; } else if (target === "end") { if (pos > scrollSize - size) { return null; } const selectedItems = items.filter(item => { const itemSize = item.size; const dist = item.pos + itemSize - endPos; const dist2 = dist - itemSize; return dist <= 0 || (dist2 < 0 && isIntersection(dist2, dist, "start", intersection)) || (dist2 <= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest)); }).reverse(); selectedItem = (selectedItems[0] === endVirtualItem && selectedItems[1]) || selectedItems[0]; } else if (target === "prev") { selectedItem = items.filter(item => { const itemSize = item.size; const dist = item.pos + itemSize - pos; const dist2 = dist - itemSize; return dist <= 0 || (dist2 < 0 && isIntersection(dist2, dist, "start", intersection)) || (dist2 <= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest)); }).reverse()[0] || startVirtualItem; } else if (target === "next") { selectedItem = items.filter(item => { const itemSize = item.size; const dist = item.pos - endPos; const dist2 = dist + itemSize; return dist >= 0 || (dist2 > 0 && isIntersection(dist, dist2, "end", intersection)) || (dist2 >= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest)); })[0] || endVirtualItem; } else { return this._getItem(target); } if (sibling && selectedItem) { const selectedIndex = items.indexOf(selectedItem); if (selectedIndex > -1) { selectedItem = items[selectedIndex + sibling]; } } return selectedItem || null; } /** * Scrolls an element or an item in that direction into the view. * @ko 엘리먼트나 해당 방향에 있는 아이템을 뷰안으로 스크롤을 한다. * @see {@link /docs/examples/Methods target's example} page for detailed information * @param - direction of the element. "start" and "end" find inside. "prev" and "next" find outside. <ko>엘리먼트의 방향. "start", "end"는 안쪽으로 찾는다. "prev", "next"는 바깥쪽으로 찾는다.</ko> * @param - Options for the `scrollIntoView` method. <ko>`scrollIntoView` 메서드의 옵션</ko> * @example * <p align="center"> * <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView1.png" height="200" /> * </p> * <p align="center"> * <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView2.png" height="210" /> * </p> */ public scrollIntoView(target: HTMLElement | "start" | "end" | "prev" | "next", options: ScrollIntoViewOptions = {}) { let item = this.findItem(target, options); if (!item) { return; } const duration = options.duration || 0; let nextScrollPos = this._getNextScrollPos(item, options); if (isString(target) && options.excludeStand && nextScrollPos === this._pos) { const selectedIndex = this._items.indexOf(item); if (selectedIndex === -1) { return; } const sibling = target === "start" || target === "prev" ? -1 : 1; item = this._items[selectedIndex + sibling]; if (!item) { return; } nextScrollPos = this._getNextScrollPos(item, options); } this.scrollBy(nextScrollPos - this._pos, duration); } /** * Scrolls by the given position amount. * @ko 주어진 위치 양만큼 스크롤한다. * @param - Amount of position to scroll by. <ko>스크롤할 위치의 양.</ko> * @param - Duration to scroll by that position. <ko>해당 위치만큼 스크롤하는 시간</ko> */ public scrollBy(pos: number, duration = 0) { this._createAnimationParam(pos); this._axes!.setBy({ scroll: -pos }, duration); } /** * Scroll to the given position. * @ko 주어진 위치로 스크롤한다. * @param - Amount of position to scroll to. <ko>스크롤할 위치의 양.</ko> * @param - Duration to scroll to that position. <ko>해당 위치로 스크롤하는 시간</ko> */ public scrollTo(pos: number, duration = 0) { this.scrollBy(pos - this._pos, duration); } /** * Set the items directly to the Conveyer. * @ko Conveyer에 아이템들을 직접 설정한다. * @param - Items to set on Conveyer <ko>Conveyer에 설정할 아이템들</ko> */ public setItems(items: ConveyerItem[]) { this._items = items; } /** * Update the position and size information of items. * @ko 아이템들의 포지션, 사이즈 정보를 업데이트 한다. */ public updateItems() { const scrollAreaElement = this._scrollAreaElement; const itemSelector = this._options.itemSelector; const resizeObserver = this._resizeObserver; const prevItemElements = this._items.map(item => item.element!); const itemElements = [].slice.call( itemSelector ? scrollAreaElement.querySelectorAll(itemSelector) : scrollAreaElement.children, ); this.setItems(itemElements.map((el) => this._getItem(el))); if (resizeObserver) { const changed = diff(prevItemElements, itemElements); const removed = changed.removed; const added = changed.added; removed.forEach((index) => resizeObserver.unobserve(changed.prevList[index])); added.forEach((index) => resizeObserver.observe(changed.list[index])); } } /** * Update container size and scroll size. * @ko 컨테이너의 크기, 스크롤 사이즈를 업데이트 한다. */ public updateContainer() { const scrollAreaElement = this._scrollAreaElement; const horizontal = this._options.horizontal; const size = horizontal ? scrollAreaElement.clientWidth : scrollAreaElement.clientHeight; let scrollSize = horizontal ? scrollAreaElement.scrollWidth : scrollAreaElement.scrollHeight; // check decimal point if (IS_IE && scrollSize === size + 1) { const style = getComputedStyle(scrollAreaElement); const boxSizing = style.boxSizing; const borderSize = horizontal ? (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0) : (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0); const cssSize = parseFloat(horizontal ? style.width : style.height) || 0; const contentSize = cssSize - (boxSizing === "border-box" ? borderSize : 0); // 0 < 0.5 && 0.5 < 1 if (size < contentSize && contentSize < size + 1) { scrollSize = size; } } this._size = size; this._scrollSize = scrollSize; this._refreshScroll(); this._onScroll(); } /** * Updating containers and items. * @ko 컨테이너와 아이템들을 업데이트 한다. * @method */ public update = () => { this.updateItems(); this.updateContainer(); } /** * Enables PanInput and WheelInput operations in mouse case. * @ko mouse 케이스에서 PanInput, WheelInput의 동작을 활성화한다. */ public enableInput() { this._panInput?.enable(); this._wheelInput?.enable(); } /** * Disables PanInput and WheelInput operations in mouse case. * @ko mouse 케이스에서 PanInput, WheelInput의 동작을 비활성화한다. */ public disableInput() { this._panInput?.disable(); this._wheelInput?.disable(); } /** * If you use the autoInit option as false, you can initialize it directly through the init method. * @ko autoInit 옵션을 false로 사용하는 경우 직접 init 메서드를 통해 초기화 할 수 있다. */ public init() { if (this._axes) { return; } const scrollArea = this._scrollArea; let el!: HTMLElement; if (isString(scrollArea)) { el = document.querySelector<HTMLElement>(scrollArea)!; } else if (instanceOfElement(scrollArea)) { el = scrollArea; } else if ("value" in scrollArea || "current" in scrollArea) { el = scrollArea.value! || scrollArea.current!; } if (!el) { return; } this._scrollAreaElement = el; let isDrag = false; const scrollAreaElement = this._scrollAreaElement; const options = this._options; const axes = new Axes({ scroll: { range: [-Infinity, Infinity], }, }, { deceleration: 0.005, round: 1, nested: options.nested, }, { scroll: 0, }); let isHold = false; axes.on({ "hold": e => { isHold = true; isDrag = false; const nativeEvent = this._getNativeEvent(e); if (!nativeEvent) { return; } if (options.preventDefault) { nativeEvent.preventDefault(); } }, "change": e => { const nativeEvent = this._getNativeEvent(e); const animateParam = this._animateParam; if (options.useSideWheel && this._isMixedWheel(nativeEvent)) { return; } this._isWheelScroll = !!nativeEvent && nativeEvent.type === "wheel"; this._isDragScroll = !!nativeEvent && !this._isWheelScroll; this._isAnimationScroll = !this._isWheelScroll && !isHold; isDrag = true; const scroll = e.delta.scroll; if (!e.isTrusted && animateParam) { animateParam.expectedPos -= scroll; if (options.horizontal) { scrollAreaElement.scrollLeft = animateParam.expectedPos + animateParam.offset; } else { scrollAreaElement.scrollTop = animateParam.expectedPos + animateParam.offset; } } else { this._animateParam = null; if (options.horizontal) { scrollAreaElement.scrollLeft -= scroll; } else { scrollAreaElement.scrollTop -= scroll; } } if (options.nested) { this._checkNestedMove(nativeEvent); } }, "release": e => { if (!isDrag) { e.setTo({ ...e.depaPos }, 0); } isHold = false; isDrag = false; }, }); this._axes = axes; if (options.useDrag) { this._panInput = new PanInput(scrollAreaElement, { preventClickOnDrag: options.preventClickOnDrag, preventDefaultOnDrag: options.preventDefaultOnDrag, inputType: ["mouse"], touchAction: "auto", }); axes.connect(options.horizontal ? ["scroll", ""] : ["", "scroll"], this._panInput); } if (options.useSideWheel) { this._wheelInput = new WheelInput(scrollAreaElement, { useNormalized: false, }); axes.connect(options.horizontal ? ["scroll", ""] : ["", "scroll"], this._wheelInput); } if (options.useResizeObserver && window.ResizeObserver) { this._resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => { const items = this._items; const length = items.length; let index = length; entries.forEach((entry: ResizeObserverEntry) => { if (entry.target !== this._scrollAreaElement) { for (let i = 0; i < length; i++) { if (items[i].element === entry.target) { index = Math.min(index, i); break; } } } }); for (let i = index; i < length; i++) { items[i] = this._getItem(items[i].element!); } this.updateContainer(); }); } this.update(); this._resizeObserver?.observe(scrollAreaElement); scrollAreaElement.addEventListener("scroll", this._onScroll); window.addEventListener("resize", this.update); } /** * Releases the instnace and events. * @ko 인스턴스와 이벤트를 해제한다. */ public destroy() { this._axes?.destroy(); this.unsubscribe(); this._scrollAreaElement?.removeEventListener("scroll", this._onScroll); this._resizeObserver?.disconnect(); if (typeof window !== "undefined") { window.removeEventListener("resize", this.update); } this.off(); this._panInput = null; this._wheelInput = null; this._axes = null; this._resizeObserver = null; } private _refreshScroll() { const horizontal = this._options.horizontal; const scrollAreaElement = this._scrollAreaElement; this._pos = Math.min( this._scrollSize - this._size, horizontal ? scrollAreaElement.scrollLeft : scrollAreaElement.scrollTop, ); } private _getItem(element: HTMLElement): ConveyerItem { const horizontal = this._options.horizontal; return { element, pos: horizontal ? element.offsetLeft : element.offsetTop, size: horizontal ? element.offsetWidth : element.offsetHeight, }; } private _getNativeEvent(e: OnHold | OnChange) { return e?.inputEvent?.srcEvent ? e.inputEvent?.srcEvent : e?.inputEvent; } private _getNextScrollPos(item: ConveyerItem, options: ScrollIntoViewOptions) { const size = this._size; const scrollSize = this._scrollSize; const align = options.align || "start"; const padding = options.offset || 0; const itemPos = item!.pos; const itemSize = item!.size; let scrollPos = 0; if (align === "start") { scrollPos = itemPos - padding; } else if (align === "end") { scrollPos = itemPos + itemSize - size + padding; } else if (align === "center") { scrollPos = itemPos + itemSize / 2 - size / 2 + padding; } scrollPos = Math.max(0, Math.min(scrollPos, scrollSize - size)); return scrollPos; } private _isMixedWheel(nativeEvent: any) { return !!nativeEvent && nativeEvent?.type === "wheel" && nativeEvent?.deltaX && nativeEvent?.deltaY; } private _checkNestedMove(nativeEvent: any) { if (this.isReachStart || this.isReachEnd) { nativeEvent.__childrenAxesAlreadyChanged = false; } } private _onScroll = (e?: any) => { if (e) { this._debounceScroll(); } this._refreshScroll(); const size = this._size; const scrollSize = this._scrollSize; const pos = this._pos; const boundaryMargin = this._options.boundaryMargin ?? 0; // enter start if (pos <= boundaryMargin && this.isReachStart !== true) { this._isReachStart = true; /** * This event is fired when scroll reach start. * @ko 스크롤이 앞에 닿으면 발생하는 이벤트이다. * @event Conveyer#reachStart */ this.trigger("reachStart"); } else if (pos > boundaryMargin && this.isReachStart !== false) { this._isReachStart = false; /** * This event is fired when scroll leave start. * @ko 스크롤이 앞에서 떠나면 발생하는 이벤트이다. * @event Conveyer#leaveStart */ this.trigger("leaveStart"); } // enter end if (scrollSize - size - pos < 1 + boundaryMargin && this.isReachEnd !== true) { this._isReachEnd = true; /** * This event is fired when scroll reach end. * @ko 스크롤이 끝에 닿으면 발생하는 이벤트이다. * @event Conveyer#reachEnd */ this.trigger("reachEnd"); } else if (!(scrollSize - size - pos < 1 + boundaryMargin) && this.isReachEnd !== false) { this._isReachEnd = false; /** * This event is fired when scroll leave end. * @ko 스크롤이 끝에서 떠나면 발생하는 이벤트이다. * @event Conveyer#leaveEnd */ this.trigger("leaveEnd"); } } private _debounceScroll() { if (!this._scrollTimer) { /** * This event is fired when begin scroll. * @ko 스크롤이 시작하면 발생하는 이벤트이다. * @event Conveyer#beginScroll */ this.trigger("beginScroll"); } window.clearTimeout(this._scrollTimer); this._scrollTimer = window.setTimeout(() => { const isWheelScroll = this._isWheelScroll; const isDragScroll = this._isDragScroll; const isAnimationScroll = this._isAnimationScroll; this._scrollTimer = 0; /** * This event is fired when finish scroll. * @ko 스크롤이 끝나면 발생하는 이벤트이다. * @event Conveyer#finishScroll * @param {OnFinishScroll} e - The object of data to be sent to an event <ko>이벤트에 전달되는 데이터 객체</ko> */ this.trigger("finishScroll", { isWheelScroll, isDragScroll, isAnimationScroll, isTrusted: isWheelScroll || isDragScroll || !isAnimationScroll, }); this._isWheelScroll = false; this._isDragScroll = false; this._isAnimationScroll = false; }, this._options.scrollDebounce); } private _createAnimationParam(pos: number) { // Save a decimal point before starting the animation // and in case of animation (isTrusted: false), add the offset and scroll. this._animateParam = { expectedPos: this._pos, offset: pos % 1, }; } } interface Conveyer extends ConveyerReactiveState, ReactiveSubscribe<ConveyerReactiveState> { } export default Conveyer;