UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

684 lines (557 loc) 19.2 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2014-2015 1&1 Internet AG, Germany, http://www.1und1.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. ************************************************************************ */ /** * A carousel is a widget which can switch between several sub pages {@link qx.ui.website.Widget}. * A page switch is triggered by a swipe to left, for next page, or a swipe to right for * previous page. Pages can also be switched by dragging. * * A carousel shows by default a pagination indicator at the bottom of the carousel. * * @require(qx.module.Transform) * @require(qx.module.event.Swipe) * @require(qx.module.event.GestureHandler) * @require(qx.module.event.Track) */ qx.Bootstrap.define("qx.ui.website.Carousel", { extend : qx.ui.website.Widget, statics: { _config : { /** * The time in milliseconds for the page switch animation. */ pageSwitchDuration: 500 }, /** * Factory method which converts the current collection into a collection of * Carousel widgets. * * @return {qx.ui.website.Carousel} A new carousel collection. * @attach {qxWeb} */ carousel: function() { var carousel = new qx.ui.website.Carousel(this); carousel.init(); return carousel; } }, construct : function(selector, context) { this.base(arguments, selector, context); }, members: { __active: null, __pageContainer: null, __scrollContainer: null, __paginationLabels: null, __startPosLeft: null, __pagination: null, _ie9: false, __blocked : false, // overridden init : function() { if (!this.base(arguments)) { return false; } this._ie9 = qx.core.Environment.get("browser.documentmode") === 9; if (this._ie9) { this.setConfig("pageSwitchDuration", 10); } else { this.addClass("qx-flex-ready"); } qxWeb(window).on("resize", this._onResize, this); var prefix = this.getCssPrefix(); this.__scrollContainer = qxWeb.create("<div>") .addClass(prefix + "-container") .appendTo(this); this.__pageContainer = qxWeb.create("<div>") .addClass("qx-hbox") .setStyle("height", "100%") .appendTo(this.__scrollContainer); this.__paginationLabels = []; this.__pagination = qxWeb.create("<div>") .addClasses([prefix + "-pagination", "qx-hbox", "qx-flex1"]) .setStyle("visibility", "excluded") .appendTo(this); if (this._ie9) { this.__pageContainer.setStyle("display", "table"); this.__pagination.setStyle("textAlign", "center"); } else { this.on("trackstart", this._onTrackStart, this) .on("track", this._onTrack, this) .on("trackend", this._onTrackEnd, this); } this.on("swipe", this._onSwipe, this); this.render(); return true; }, render: function() { var pages = this.find("." + this.getCssPrefix() + "-page"); pages.forEach(function(page) { this.addPage(qxWeb(page)); }.bind(this)); if (pages.length > 0) { this.setActive(pages.eq(0)); } return this; }, /** * Sets one of the Carousel's pages as active, meaning it will be * visible. * * @param page {qxWeb} The page to be activated */ setActive: function(page) { var old = this.__active; this.__active = page; this._update(); var data = {value: page, old: old, target: this}; this.emit("changeActive", data); }, /** * Returns the currently active (i.e. visible) page * @return {qxWeb} The active page */ getActive: function() { return this.__active; }, /** * Scrolls the carousel to the next page. * * @return {qx.ui.website.Carousel} Self instance for chaining */ nextPage: function() { var pages = this._getPages(); if (pages.length == 0) { return this; } var next = this.getActive().getNext(); // prevent overflow if we don't use the endless loop mode if (pages.length > 2) { if (next.length === 0) { next = pages.eq(0); } } if (next.length > 0) { this.setActive(next); } return this; }, /** * Scrolls the carousel to the previous page. * * @return {qx.ui.website.Carousel} Self instance for chaining */ previousPage: function() { var pages = this._getPages(); if (pages.length == 0) { return this; } var prev = this.getActive().getPrev(); // prevent overflow if we don't use the endless loop mode if (pages.length > 2) { if (prev.length == 0) { prev = pages.eq(pages.length - 1); } } if (prev.length > 0) { this.setActive(prev); } return this; }, /** * Adds a page to the Carousel. Updates the pagination, * scroll position, active property and the sizing. * @param child {qxWeb} The added child. */ addPage: function(child) { child.addClasses(["qx-flex1", this.getCssPrefix() + "-page"]) .appendTo(this.__pageContainer); if (this.find("." + this.getCssPrefix() + "-page").length > this.__paginationLabels.length) { var paginationLabel = this._createPaginationLabel(); this.__paginationLabels.push(paginationLabel); this.__pagination.append(paginationLabel); } this._updateWidth(); if (!this.getActive()) { this.setActive(child); } else if (this._getPages().length > 2) { this._updateOrder(); } if (this._ie9) { child.setStyle("display", "table-cell"); } this.find(".scroll").setStyle("touchAction", "pan-y"); // scroll as soon as we have the third page added if (this._getPages().length === 3 && !this._ie9) { this.__scrollContainer.translate([(-this.getWidth()) + "px", 0, 0]); } this._updatePagination(); }, /** * Removes a page from the Carousel. Updates the pagination, * scroll position, active property and the sizing. * @param child {qxWeb} The removed child. */ removePage: function(child) { child.remove(); // reset the active page if we don't have any page at all if (this._getPages().length == 0) { this.__pagination.empty(); this.__paginationLabels = []; this.setActive(null); return; } this._updateWidth(); if (this.getActive()[0] == child[0]) { this.setActive(this._getPages().eq(0)); } else if (this._getPages().length > 2) { this._updateOrder(); } else { // remove all order properties this._setOrder(this._getPages(), 0); } this.__paginationLabels.splice(child.priorPosition, 1)[0].remove(); for (var i = 0; i < this.__paginationLabels.length; i++) { this.__paginationLabels[i].getChildren(".label").setHtml((i + 1) + ""); } this._updatePagination(); }, /** * Updates the order, scroll position and pagination. */ _update: function() { if (!this.getActive()) { return; } // special case for only one page if (this._getPages().length < 2) { return; } else if (this._getPages().length == 2) { if (this._getPages()[0] === this.getActive()[0]) { this._translateTo(0); } else { this._translateTo(this.getWidth()); } this._updatePagination(); return; } var left; if (!this._ie9) { var direction = this._updateOrder(); if (direction == "right") { left = this._getPositionLeft() - this.__scrollContainer.getWidth(); } else if (direction == "left") { left = this._getPositionLeft() + this.__scrollContainer.getWidth(); } else if (this._getPages().length >= 3) { // back snapping if the order has not changed this._translateTo(this.getWidth()); return; } else { // do nothing if we don't have enough pages return; } if (left !== undefined) { // first, translate the old page into view this.__scrollContainer.translate([(-left) + "px", 0, 0]); // animate to the new page this._translateTo(this.getWidth()); } } else { var index = this._getPages().indexOf(this.getActive()); left = index * this.getWidth(); this._translateTo(left); } this._updatePagination(); }, /** * Updates the CSS order property of the flexbox layout. * The active page should be the second in order with a order property of '0'. * The page left to the active has the order property set to '-1' and is the * only one on the left side. All other pages get increasing order numbers and * are aligned on the right side. * * @return {String} The scroll direction, either 'left' or 'right'. */ _updateOrder: function() { if (this._ie9) { return "left"; } var scrollDirection; var pages = this._getPages(); var orderBefore = this._getOrder(this.getActive()); if (orderBefore > 0) { scrollDirection = "right"; } else if (orderBefore < 0) { scrollDirection = "left"; } var activeIndex = pages.indexOf(this.getActive()); this._setOrder(this.getActive(), 0);// active page should always have order 0 var order = 1; // order all pages with a higher index than the active page for (var i = activeIndex + 1; i < pages.length; i++) { // move the last page to the left of the active page if (activeIndex === 0 && i == pages.length - 1) { order = -1; } this._setOrder(pages.eq(i), order++); } // order all pages with a lower index than the active page for (i = 0; i < activeIndex; i++) { // move the last page to the left of the active page if (i == activeIndex - 1) { order = -1; } this._setOrder(pages.eq(i), order++); } return scrollDirection; }, /** * Updates the width of the container and the pages. */ _updateWidth: function() { if (!this.isRendered() || this.getProperty("offsetWidth") === 0) { this.setStyle("visibility", "hidden"); if (!this.hasListener("appear", this._updateWidth, this)) { this.once("appear", this._updateWidth, this); } return; } // set the initial transition on first appear if (this._getPositionLeft() === 0 && this._getPages().length > 2 && !this._ie9) { this.__scrollContainer.translate([(-this.getWidth()) + "px", 0, 0]); } // set the container width to total width of all pages var containerWidth = this.getWidth() * this._getPages().length; this.__pageContainer.setStyle("width", containerWidth + "px"); // set the width of all pages to the carousel width this._getPages().setStyle("width", this.getWidth() + "px"); this.setStyle("visibility", "visible"); }, /** * Handler for trackstart. It saves the initial scroll position and * cancels any running animation. */ _onTrackStart: function() { if (this.__blocked) { return; } this.__startPosLeft = this._getPositionLeft(); this.__scrollContainer // stop the current scroll animation .stop() // correct the scroll position as the stopped animation // resets to its initial value .translate([(-Math.round(this.__startPosLeft)) + "px", 0, 0]); }, /** * Track handler which updates the scroll position. * @param e {Event} The track event. */ _onTrack: function(e) { if (this.__blocked) { return; } if (e.delta.axis == "x" && this._getPages().length > 2) { this.__scrollContainer.translate([-(this.__startPosLeft - e.delta.x) + "px", 0, 0]); } }, /** * TrackEnd handler for enabling the scroll events. */ _onTrackEnd: function() { if (this.__startPosLeft == null || this.__blocked) { // don't end if we didn't start return; } // make sure the trackend handling is done after the swipe handling window.setTimeout(function() { if (this._getPages().length < 3 || this.__scrollContainer.isPlaying()) { return; } this.__startPosLeft = null; var width = this.getWidth(); var pages = this._getPages(); var oldActive = this.getActive(); // if more than 50% is visible of the previous page if (this._getPositionLeft() < (width - (width / 2))) { var prev = this.getActive().getPrev(); if (prev.length == 0) { prev = pages.eq(pages.length - 1); } this.setActive(prev); // if more than 50% is visible of the next page } else if (this._getPositionLeft() > (width + width / 2)) { var next = this.getActive().getNext(); if (next.length == 0) { next = pages.eq(0); } this.setActive(next); } if (this.getActive() == oldActive) { this._update(); } }.bind(this), 0); }, /** * Swipe handler which triggers page changes based on the * velocity and the direction. * @param e {Event} The swipe event. */ _onSwipe: function(e) { if (this.__blocked) { return; } var velocity = Math.abs(e.getVelocity()); if (e.getAxis() == "x" && velocity > 0.25) { if (e.getDirection() == "left") { this.nextPage(); } else if (e.getDirection() == "right") { this.previousPage(); } } }, /** * Factory method for a paginationLabel. * @return {qxWeb} the created pagination label. */ _createPaginationLabel : function() { var paginationIndex = this._getPages().length; return qxWeb.create('<div class="' + this.getCssPrefix() + '-pagination-label"></div>') .on("tap", this._onPaginationLabelTap, this) .append(qxWeb.create('<div class="label">' + paginationIndex + '</div>')); }, /** * Handles the tap on pagination labels and changes to the desired page. * @param e {Event} The tap event. */ _onPaginationLabelTap: function(e) { this.__paginationLabels.forEach(function(label, index) { if (label[0] === e.currentTarget) { var pages = this._getPages(); // wo don't reorder with two pages there just set the active property if (pages.length === 2) { this.setActive(pages.eq(index)); return; } var activeIndex = pages.indexOf(this.getActive()); var distance = index - activeIndex; // set the order to deault dom order this._setOrder(pages, 0); // get the active page into view this.__scrollContainer.translate([(-activeIndex * this.getWidth()) + "px", 0, 0]); this.__blocked = true; // animate to the desired page this._translateTo((activeIndex + distance) * this.getWidth()); this.__scrollContainer.once("animationEnd", function(page) { this.__blocked = false; // set the viewport back to the default position this.__scrollContainer.translate([(-this.getWidth()) + "px", 0, 0]); this.setActive(page); // this also updates the order this._updatePagination(); }.bind(this, pages.eq(index))); } }.bind(this)); }, /** * Updates the pagination indicator of this carousel. * Adds the 'active' CSS class to the currently visible page's * pagination button. */ _updatePagination : function() { // hide the pagination for one page this._getPages().length < 2 ? this.__pagination.setStyle("visibility", "excluded") : this.__pagination.setStyle("visibility", "visible"); this.__pagination.find("." + this.getCssPrefix() + "-pagination-label").removeClass("active"); var pages = this._getPages(); this.__paginationLabels[pages.indexOf(this.getActive())].addClass("active"); }, /** * Resize handler. It updates the sizes, snap points and scroll position. */ _onResize : function() { this._updateWidth(); if (this._getPages().length > 2) { this.__scrollContainer.translate([(-this.getWidth()) + "px", 0, 0]); } }, /** * Animates using CSS translations to the given left position. * @param left {Number} The new left position */ _translateTo: function(left) { this.__scrollContainer.animate({ duration: this.getConfig("pageSwitchDuration"), keep: 100, timing: "ease", keyFrames: { 0: {}, 100: { translate: [(-left) + "px", 0, 0] } } }); }, /** * Sets the given order on the given collection. * @param col {qxWeb} The collection to set the css property. * @param value {Number|String} The value for the order property */ _setOrder: function(col, value) { col.setStyles({ order: value, msFlexOrder: value }); }, /** * Returns the order css property of the given collection. * @param col {qxWeb} The collection to check. * @return {Number} The order as number. */ _getOrder: function(col) { var order = parseInt(col.getStyle("order")); if (isNaN(order)) { order = parseInt(col.getStyle("msFlexOrder")); } return order; }, /** * Returns a collection of all pages. * @return {qxWeb} All pages. */ _getPages: function() { return this.__pageContainer.find("." + this.getCssPrefix() + "-page"); }, /** * Returns the current left position. * @return {Number} The position in px. */ _getPositionLeft: function() { var containerRect = this.__scrollContainer[0].getBoundingClientRect(); var parentRect = this[0].getBoundingClientRect(); return -(containerRect.left - parentRect.left); }, // overridden dispose : function() { qxWeb(window).off("resize", this._onResize, this); this.off("trackstart", this._onTrackStart, this) .off("track", this._onTrack, this) .off("swipe", this._onSwipe, this) .off("trackend", this._onTrackEnd, this); return this.base(arguments); } }, defer: function(statics) { qxWeb.$attach({carousel : statics.carousel}); } });