@egjs/flicking
Version:
Everyday 30 million people experience. It's reliable, flexible and extendable carousel.
548 lines (450 loc) • 17.6 kB
text/typescript
/*
* Copyright (c) 2015 NAVER Corp.
* egjs projects are licensed under the MIT license
*/
import { ComponentEvent } from "@egjs/component";
import ImReady from "@egjs/imready";
import Flicking, { FlickingOptions } from "../Flicking";
import Panel, { PanelOptions } from "../core/panel/Panel";
import FlickingError from "../core/FlickingError";
import { ALIGN, EVENTS } from "../const/external";
import * as ERROR from "../const/error";
import { getFlickingAttached, getMinusCompensatedIndex, includes, parsePanelAlign } from "../utils";
import RenderingStrategy from "./strategy/RenderingStrategy";
export interface RendererOptions {
align?: FlickingOptions["align"];
strategy: RenderingStrategy;
}
/**
* A component that manages {@link Panel} and its elements
* @ko {@link Panel}과 그 엘리먼트들을 관리하는 컴포넌트
*/
abstract class Renderer {
// Internal States
protected _flicking: Flicking | null;
protected _panels: Panel[];
protected _rendering: boolean;
// Options
protected _align: NonNullable<RendererOptions["align"]>;
protected _strategy: RendererOptions["strategy"];
// Internal states Getter
/**
* Array of panels
* @ko 전체 패널들의 배열
* @type {Panel[]}
* @readonly
* @see Panel
*/
public get panels() { return this._panels; }
/**
* A boolean value indicating whether rendering is in progress
* @ko 현재 렌더링이 시작되어 끝나기 전까지의 상태인지의 여부
* @type {boolean}
* @readonly
* @internal
*/
public get rendering() { return this._rendering; }
/**
* Count of panels
* @ko 전체 패널의 개수
* @type {number}
* @readonly
*/
public get panelCount() { return this._panels.length; }
/**
* @internal
*/
public get strategy() { return this._strategy; }
// Options Getter
/**
* A {@link Panel}'s {@link Panel#align align} value that applied to all panels
* @ko {@link Panel}에 공통적으로 적용할 {@link Panel#align align} 값
* @type {Constants.ALIGN | string | number}
*/
public get align() { return this._align; }
// Options Setter
public set align(val: NonNullable<RendererOptions["align"]>) {
this._align = val;
const panelAlign = parsePanelAlign(val);
this._panels.forEach(panel => { panel.align = panelAlign; });
}
/**
* @param {object} options An options object<ko>옵션 오브젝트</ko>
* @param {Constants.ALIGN | string | number} [options.align="center"] An {@link Flicking#align align} value that will be applied to all panels<ko>전체 패널에 적용될 {@link Flicking#align align} 값</ko>
* @param {object} [options.strategy] An instance of RenderingStrategy(internal module)<ko>RenderingStrategy의 인스턴스(내부 모듈)</ko>
*/
public constructor({
align = ALIGN.CENTER,
strategy
}: RendererOptions) {
this._flicking = null;
this._panels = [];
this._rendering = false;
// Bind options
this._align = align;
this._strategy = strategy;
}
/**
* Render panel elements inside the camera element
* @ko 패널 엘리먼트들을 카메라 엘리먼트 내부에 렌더링합니다
* @method
* @abstract
* @memberof Renderer
* @instance
* @name render
* @chainable
* @return {this}
*/
public abstract render(): Promise<void>;
protected abstract _collectPanels(): void;
protected abstract _createPanel(el: any, options: Omit<PanelOptions, "elementProvider">): Panel;
/**
* Initialize Renderer
* @ko Renderer를 초기화합니다
* @param {Flicking} flicking An instance of {@link Flicking}<ko>Flicking의 인스턴스</ko>
* @chainable
* @return {this}
*/
public init(flicking: Flicking): this {
this._flicking = flicking;
this._collectPanels();
return this;
}
/**
* Destroy Renderer and return to initial state
* @ko Renderer를 초기 상태로 되돌립니다
* @return {void}
*/
public destroy(): void {
this._flicking = null;
this._panels = [];
}
/**
* Return the {@link Panel} at the given index. `null` if it doesn't exists.
* @ko 주어진 인덱스에 해당하는 {@link Panel}을 반환합니다. 주어진 인덱스에 해당하는 패널이 존재하지 않을 경우 `null`을 반환합니다.
* @return {Panel | null} Panel at the given index<ko>주어진 인덱스에 해당하는 패널</ko>
* @see Panel
*/
public getPanel(index: number): Panel | null {
return this._panels[index] || null;
}
public forceRenderAllPanels(): Promise<void> {
this._panels.forEach(panel => panel.markForShow());
return Promise.resolve();
}
/**
* Update all panel sizes
* @ko 모든 패널의 크기를 업데이트합니다
* @chainable
* @return {this}
*/
public updatePanelSize(): this {
const flicking = getFlickingAttached(this._flicking);
const panels = this._panels;
if (panels.length <= 0) return this;
if (flicking.panelsPerView > 0) {
const firstPanel = panels[0];
firstPanel.resize();
this._updatePanelSizeByGrid(firstPanel, panels);
} else {
flicking.panels.forEach(panel => panel.resize());
}
return this;
}
/**
* Insert new panels at given index
* This will increase index of panels after by the number of panels added
* @ko 주어진 인덱스에 새로운 패널들을 추가합니다
* 해당 인덱스보다 같거나 큰 인덱스를 가진 기존 패널들은 추가한 패널의 개수만큼 인덱스가 증가합니다.
* @param {Array<object>} items An array of items to insert<ko>추가할 아이템들의 배열</ko>
* @param {number} [items.index] Index to insert new panels at<ko>새로 패널들을 추가할 인덱스</ko>
* @param {any[]} [items.elements] An array of element or framework component with element in it<ko>엘리먼트의 배열 혹은 프레임워크에서 엘리먼트를 포함한 컴포넌트들의 배열</ko>
* @param {boolean} [items.hasDOMInElements] Whether it contains actual DOM elements. If set to true, renderer will add them to the camera element<ko>내부에 실제 DOM 엘리먼트들을 포함하고 있는지 여부. true로 설정할 경우, 렌더러는 해당 엘리먼트들을 카메라 엘리먼트 내부에 추가합니다</ko>
* @return {Panel[]} An array of prepended panels<ko>추가된 패널들의 배열</ko>
*/
public batchInsert(...items: Array<{
index: number;
elements: any[];
hasDOMInElements: boolean;
}>): Panel[] {
const allPanelsInserted = this.batchInsertDefer(...items);
if (allPanelsInserted.length <= 0) return [];
this.updateAfterPanelChange(allPanelsInserted, []);
return allPanelsInserted;
}
/**
* Defers update
* camera position & others will be updated after calling updateAfterPanelChange
* @internal
*/
public batchInsertDefer(...items: Array<{
index: number;
elements: any[];
hasDOMInElements: boolean;
}>) {
const panels = this._panels;
const flicking = getFlickingAttached(this._flicking);
const prevFirstPanel = panels[0];
const align = parsePanelAlign(this._align);
const allPanelsInserted = items.reduce((addedPanels, item) => {
const insertingIdx = getMinusCompensatedIndex(item.index, panels.length);
const panelsPushed = panels.slice(insertingIdx);
const panelsInserted = item.elements.map((el, idx) => this._createPanel(el, { index: insertingIdx + idx, align, flicking }));
panels.splice(insertingIdx, 0, ...panelsInserted);
if (item.hasDOMInElements) {
// Insert the actual elements as camera element's children
this._insertPanelElements(panelsInserted, panelsPushed[0] ?? null);
}
// Resize the newly added panels
if (flicking.panelsPerView > 0) {
const firstPanel = prevFirstPanel || panelsInserted[0].resize();
this._updatePanelSizeByGrid(firstPanel, panelsInserted);
} else {
panelsInserted.forEach(panel => panel.resize());
}
// Update panel indexes & positions
panelsPushed.forEach(panel => {
panel.increaseIndex(panelsInserted.length);
panel.updatePosition();
});
return [...addedPanels, ...panelsInserted];
}, []);
return allPanelsInserted;
}
/**
* Remove the panel at the given index
* This will decrease index of panels after by the number of panels removed
* @ko 주어진 인덱스의 패널을 제거합니다
* 해당 인덱스보다 큰 인덱스를 가진 기존 패널들은 제거한 패널의 개수만큼 인덱스가 감소합니다
* @param {Array<object>} items An array of items to remove<ko>제거할 아이템들의 배열</ko>
* @param {number} [items.index] Index of panel to remove<ko>제거할 패널의 인덱스</ko>
* @param {number} [items.deleteCount=1] Number of panels to remove from index<ko>`index` 이후로 제거할 패널의 개수</ko>
* @param {boolean} [items.hasDOMInElements=1] Whether it contains actual DOM elements. If set to true, renderer will remove them from the camera element<ko>내부에 실제 DOM 엘리먼트들을 포함하고 있는지 여부. true로 설정할 경우, 렌더러는 해당 엘리먼트들을 카메라 엘리먼트 내부에서 제거합니다</ko>
* @return An array of removed panels<ko>제거된 패널들의 배열</ko>
*/
public batchRemove(...items: Array<{
index: number;
deleteCount: number;
hasDOMInElements: boolean;
}>): Panel[] {
const allPanelsRemoved = this.batchRemoveDefer(...items);
if (allPanelsRemoved.length <= 0) return [];
this.updateAfterPanelChange([], allPanelsRemoved);
return allPanelsRemoved;
}
/**
* Defers update
* camera position & others will be updated after calling updateAfterPanelChange
* @internal
*/
public batchRemoveDefer(...items: Array<{
index: number;
deleteCount: number;
hasDOMInElements: boolean;
}>) {
const panels = this._panels;
const flicking = getFlickingAttached(this._flicking);
const { control } = flicking;
const activePanel = control.activePanel;
const allPanelsRemoved = items.reduce((removed, item) => {
const { index, deleteCount } = item;
const removingIdx = getMinusCompensatedIndex(index, panels.length);
const panelsPulled = panels.slice(removingIdx + deleteCount);
const panelsRemoved = panels.splice(removingIdx, deleteCount);
if (panelsRemoved.length <= 0) return [];
// Update panel indexes & positions
panelsPulled.forEach(panel => {
panel.decreaseIndex(panelsRemoved.length);
panel.updatePosition();
});
if (item.hasDOMInElements) {
this._removePanelElements(panelsRemoved);
}
// Remove panel elements
panelsRemoved.forEach(panel => panel.destroy());
if (includes(panelsRemoved, activePanel)) {
control.resetActive();
}
return [...removed, ...panelsRemoved];
}, []);
return allPanelsRemoved;
}
/**
* @internal
*/
public updateAfterPanelChange(panelsAdded: Panel[], panelsRemoved: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const { camera, control } = flicking;
const panels = this._panels;
const activePanel = control.activePanel;
// Update camera & control
this._updateCameraAndControl();
void this.render();
if (!flicking.animating) {
if (!activePanel || activePanel.removed) {
if (panels.length <= 0) {
// All panels removed
camera.lookAt(0);
} else {
let targetIndex = activePanel?.index ?? 0;
if (targetIndex > panels.length - 1) {
targetIndex = panels.length - 1;
}
void control.moveToPanel(panels[targetIndex], {
duration: 0
}).catch(() => void 0);
}
} else {
void control.moveToPanel(activePanel, {
duration: 0
}).catch(() => void 0);
}
}
flicking.camera.updateOffset();
if (panelsAdded.length > 0 || panelsRemoved.length > 0) {
flicking.trigger(new ComponentEvent(EVENTS.PANEL_CHANGE, {
added: panelsAdded,
removed: panelsRemoved
}));
this.checkPanelContentsReady([
...panelsAdded,
...panelsRemoved
]);
}
}
/**
* @internal
*/
public checkPanelContentsReady(checkingPanels: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const resizeOnContentsReady = flicking.resizeOnContentsReady;
const panels = this._panels;
if (!resizeOnContentsReady || flicking.virtualEnabled) return;
const hasContents = (panel: Panel) => panel.element && !!panel.element.querySelector("img, video");
checkingPanels = checkingPanels.filter(panel => hasContents(panel));
if (checkingPanels.length <= 0) return;
const contentsReadyChecker = new ImReady();
checkingPanels.forEach(panel => {
panel.loading = true;
});
contentsReadyChecker.on("readyElement", e => {
if (!this._flicking) {
// Renderer's destroy() is called before
contentsReadyChecker.destroy();
return;
}
const panel = checkingPanels[e.index];
const camera = flicking.camera;
const control = flicking.control;
const prevProgressInPanel = control.activePanel
? camera.getProgressInPanel(control.activePanel)
: 0;
panel.loading = false;
panel.resize();
panels.slice(panel.index + 1).forEach(panelBehind => panelBehind.updatePosition());
if (!flicking.initialized) return;
camera.updateRange();
camera.updateOffset();
camera.updateAnchors();
if (control.animating) {
// TODO: Need Axes update
} else {
control.updatePosition(prevProgressInPanel);
control.updateInput();
}
});
contentsReadyChecker.on("preReady", e => {
if (this._flicking) {
void this.render();
}
if (e.readyCount === e.totalCount) {
contentsReadyChecker.destroy();
}
});
contentsReadyChecker.on("ready", () => {
if (this._flicking) {
void this.render();
}
contentsReadyChecker.destroy();
});
contentsReadyChecker.check(checkingPanels.map(panel => panel.element));
}
protected _updateCameraAndControl() {
const flicking = getFlickingAttached(this._flicking);
const { camera, control } = flicking;
camera.updateRange();
camera.updateOffset();
camera.updateAnchors();
camera.resetNeedPanelHistory();
control.updateInput();
}
protected _showOnlyVisiblePanels(flicking: Flicking) {
const panels = flicking.renderer.panels;
const camera = flicking.camera;
const visibleIndexes = camera.visiblePanels.reduce((visibles, panel) => {
visibles[panel.index] = true;
return visibles;
}, {});
panels.forEach(panel => {
if (panel.index in visibleIndexes || panel.loading) {
panel.markForShow();
} else if (!flicking.holding) {
// During the input sequence,
// Do not remove panel elements as it won't trigger touchend event.
panel.markForHide();
}
});
}
protected _updatePanelSizeByGrid(referencePanel: Panel, panels: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const panelsPerView = flicking.panelsPerView;
if (panelsPerView <= 0) {
throw new FlickingError(ERROR.MESSAGE.WRONG_OPTION("panelsPerView", panelsPerView), ERROR.CODE.WRONG_OPTION);
}
if (panels.length <= 0) return;
const viewportSize = flicking.camera.size;
const gap = referencePanel.margin.prev + referencePanel.margin.next;
const panelSize = (viewportSize - gap * (panelsPerView - 1)) / panelsPerView;
const panelSizeObj = flicking.horizontal
? { width: panelSize }
: { height: panelSize };
const firstPanelSizeObj = {
size: panelSize,
margin: referencePanel.margin,
...(!flicking.horizontal && { height: referencePanel.height})
};
if (!flicking.noPanelStyleOverride) {
this._strategy.updatePanelSizes(flicking, panelSizeObj);
}
flicking.panels.forEach(panel => panel.resize(firstPanelSizeObj));
}
protected _removeAllChildsFromCamera() {
const flicking = getFlickingAttached(this._flicking);
const cameraElement = flicking.camera.element;
// Remove other elements
while (cameraElement.firstChild) {
cameraElement.removeChild(cameraElement.firstChild);
}
}
protected _insertPanelElements(panels: Panel[], nextSibling: Panel | null = null) {
const flicking = getFlickingAttached(this._flicking);
const camera = flicking.camera;
const cameraElement = camera.element;
const nextSiblingElement = nextSibling?.element || null;
const fragment = document.createDocumentFragment();
panels.forEach(panel => fragment.appendChild(panel.element));
cameraElement.insertBefore(fragment, nextSiblingElement);
}
protected _removePanelElements(panels: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const cameraElement = flicking.camera.element;
panels.forEach(panel => {
cameraElement.removeChild(panel.element);
});
}
protected _afterRender() {
const flicking = getFlickingAttached(this._flicking);
flicking.camera.applyTransform();
}
}
export default Renderer;