// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Card slider implementation. Allows you to create interactions * that have items that can slide left to right to reveal additional items. * Works by adding the necessary event handlers to a specific DOM structure * including a frame, container and cards. * - The frame defines the boundary of one item. Each card will be expanded to * fill the width of the frame. This element is also overflow hidden so that * the additional items left / right do not trigger horizontal scrolling. * - The container is what all the touch events are attached to. This element * will be expanded to be the width of all cards. * - The cards are the individual viewable items. There should be one card for * each item in the list. Only one card will be visible at a time. Two cards * will be visible while you are transitioning between cards. * * This class is designed to work well on any hardware-accelerated touch device. * It should still work on pre-hardware accelerated devices it just won't feel * very good. It should also work well with a mouse. */ // Use an anonymous function to enable strict mode just for this file (which // will be concatenated with other files when embedded in Chrome cr.define('cr.ui', function() { 'use strict'; /** * @constructor * @param {!Element} frame The bounding rectangle that cards are visible in. * @param {!Element} container The surrounding element that will have event * listeners attached to it. * @param {number} cardWidth The width of each card should have. */ function CardSlider(frame, container, cardWidth) { /** * @type {!Element} * @private */ this.frame_ = frame; /** * @type {!Element} * @private */ this.container_ = container; /** * Array of card elements. * @type {!Array.<!Element>} * @private */ this.cards_ = []; /** * Index of currently shown card. * @type {number} * @private */ this.currentCard_ = -1; /** * @type {number} * @private */ this.cardWidth_ = cardWidth; /** * @type {!cr.ui.TouchHandler} * @private */ this.touchHandler_ = new cr.ui.TouchHandler(this.container_); } /** * The time to transition between cards when animating. Measured in ms. * @type {number} * @private * @const */ CardSlider.TRANSITION_TIME_ = 200; /** * The minimum velocity required to transition cards if they did not drag past * the halfway point between cards. Measured in pixels / ms. * @type {number} * @private * @const */ CardSlider.TRANSITION_VELOCITY_THRESHOLD_ = 0.2; CardSlider.prototype = { /** * The current left offset of the container relative to the frame. * @type {number} * @private */ currentLeft_: 0, /** * Initialize all elements and event handlers. Must call after construction * and before usage. * @param {boolean} ignoreMouseWheelEvents If true, horizontal mouse wheel * events will be ignored, rather than flipping between pages. */ initialize: function(ignoreMouseWheelEvents) { var view = this.container_.ownerDocument.defaultView; assert(view.getComputedStyle(this.container_).display == '-webkit-box', 'Container should be display -webkit-box.'); assert(view.getComputedStyle(this.frame_).overflow == 'hidden', 'Frame should be overflow hidden.'); assert(view.getComputedStyle(this.container_).position == 'static', 'Container should be position static.'); this.updateCardWidths_(); this.mouseWheelScrollAmount_ = 0; this.mouseWheelCardSelected_ = false; this.mouseWheelIsContinuous_ = false; this.scrollClearTimeout_ = null; if (!ignoreMouseWheelEvents) { this.frame_.addEventListener('mousewheel', this.onMouseWheel_.bind(this)); } this.container_.addEventListener( 'webkitTransitionEnd', this.onWebkitTransitionEnd_.bind(this)); // Also support touch events in case a touch screen happens to be // available. Ideally we would support touch events whenever they // are fired, but for now restrict this extra code to when we know // we want to support touch input. if (cr.isTouchOptimized) { var TouchHandler = cr.ui.TouchHandler; this.container_.addEventListener(TouchHandler.EventType.TOUCH_START, this.onTouchStart_.bind(this)); this.container_.addEventListener(TouchHandler.EventType.DRAG_START, this.onDragStart_.bind(this)); this.container_.addEventListener(TouchHandler.EventType.DRAG_MOVE, this.onDragMove_.bind(this)); this.container_.addEventListener(TouchHandler.EventType.DRAG_END, this.onDragEnd_.bind(this)); this.touchHandler_.enable(/* opt_capture */ false); } }, /** * Use in cases where the width of the frame has changed in order to update * the width of cards. For example should be used when orientation changes * in full width sliders. * @param {number} newCardWidth Width all cards should have, in pixels. */ resize: function(newCardWidth) { if (newCardWidth != this.cardWidth_) { this.cardWidth_ = newCardWidth; this.updateCardWidths_(); // Must upate the transform on the container to show the correct card. this.transformToCurrentCard_(); } }, /** * Sets the cards used. Can be called more than once to switch card sets. * @param {!Array.<!Element>} cards The individual viewable cards. * @param {number} index Index of the card to in the new set of cards to * navigate to. */ setCards: function(cards, index) { assert(index >= 0 && index < cards.length, 'Invalid index in CardSlider#setCards'); this.cards_ = cards; this.updateCardWidths_(); // Jump to the given card index. this.selectCard(index); }, /** * Updates the width of each card. * @private */ updateCardWidths_: function() { for (var i = 0, card; card = this.cards_[i]; i++) card.style.width = this.cardWidth_ + 'px'; }, /** * Returns the index of the current card. * @return {number} index of the current card. */ get currentCard() { return this.currentCard_; }, /** * Allows setting the current card index. * @param {number} index A new index to set the current index to. * @return {number} The new index after having been set. */ set currentCard(index) { return (this.currentCard_ = index); }, /** * Returns the number of cards. * @return {number} number of cards. */ get cardCount() { return this.cards_.length; }, /** * Returns the current card itself. * @return {!Element} the currently shown card. */ get currentCardValue() { return this.cards_[this.currentCard_]; }, /** * Returns the frame holding the cards. * @return {Element} The frame used to position the cards. */ get frame() { return this.frame_; }, /** * Handle horizontal scrolls to flip between pages. * @private */ onMouseWheel_: function(e) { if (e.wheelDeltaX == 0) return; // Continuous devices such as an Apple Touchpad or Apple MagicMouse will // send arbitrary delta values. Conversly, standard mousewheels will // send delta values in increments of 120. (There is of course a small // chance we mistake a continuous device for a non-continuous device. // Unfortunately there isn't a better way to do this until real touch // events are available to desktop clients.) var DISCRETE_DELTA = 120; if (e.wheelDeltaX % DISCRETE_DELTA) this.mouseWheelIsContinuous_ = true; if (this.mouseWheelIsContinuous_) { // For continuous devices, detect a page swipe when the accumulated // delta matches a pre-defined threshhold. After changing the page, // ignore wheel events for a short time before repeating this process. if (this.mouseWheelCardSelected_) return; this.mouseWheelScrollAmount_ += e.wheelDeltaX; if (Math.abs(this.mouseWheelScrollAmount_) >= 600) { var pagesToScroll = this.mouseWheelScrollAmount_ > 0 ? 1 : -1; if (!isRTL()) pagesToScroll *= -1; var newCardIndex = this.currentCard + pagesToScroll; newCardIndex = Math.min(this.cards_.length - 1, Math.max(0, newCardIndex)); this.selectCard(newCardIndex, true); this.mouseWheelCardSelected_ = true; } } else { // For discrete devices, consider each wheel tick a page change. var pagesToScroll = e.wheelDeltaX / DISCRETE_DELTA; if (!isRTL()) pagesToScroll *= -1; var newCardIndex = this.currentCard + pagesToScroll; newCardIndex = Math.min(this.cards_.length - 1, Math.max(0, newCardIndex)); this.selectCard(newCardIndex, true); } // We got a mouse wheel event, so cancel any pending scroll wheel timeout. if (this.scrollClearTimeout_ != null) clearTimeout(this.scrollClearTimeout_); // If we didn't use up all the scroll, hold onto it for a little bit, but // drop it after a delay. if (this.mouseWheelScrollAmount_ != 0) { this.scrollClearTimeout_ = setTimeout(this.clearMouseWheelScroll_.bind(this), 500); } }, /** * Resets the amount of horizontal scroll we've seen to 0. See * onMouseWheel_. * @private */ clearMouseWheelScroll_: function() { this.mouseWheelScrollAmount_ = 0; this.mouseWheelCardSelected_ = false; }, /** * Handles the ends of -webkit-transitions on -webkit-transform (animated * card switches). * @param {Event} e The webkitTransitionEnd event. * @private */ onWebkitTransitionEnd_: function(e) { // Ignore irrelevant transitions that might bubble up. if (e.target !== this.container_ || e.propertyName != '-webkit-transform') { return; } this.fireChangeEndedEvent_(true); }, /** * Dispatches a simple event to tell subscribers we're done moving to the * newly selected card. * @param {boolean} wasAnimated whether or not the change was animated. * @private */ fireChangeEndedEvent_: function(wasAnimated) { var e = document.createEvent('Event'); e.initEvent('cardSlider:card_change_ended', true, true); e.cardSlider = this; e.changedTo = this.currentCard_; e.wasAnimated = wasAnimated; this.container_.dispatchEvent(e); }, /** * Add a card to the card slider at a particular index. If the card being * added is inserted in front of the current card, cardSlider.currentCard * will be adjusted accordingly (to current card + 1). * @param {!Node} card A card that will be added to the card slider. * @param {number} index An index at which the given |card| should be * inserted. Must be positive and less than the number of cards. */ addCardAtIndex: function(card, index) { assert(card instanceof Node, '|card| isn\'t a Node'); this.assertValidIndex_(index); this.cards_ = Array.prototype.concat.call( this.cards_.slice(0, index), card, this.cards_.slice(index)); if (this.currentCard_ == -1) this.currentCard_ = 0; else if (index <= this.currentCard_) this.selectCard(this.currentCard_ + 1, false, true, true); this.fireAddedEvent_(card, index); }, /** * Append a card to the end of the list. * @param {!Node} card A card to add at the end of the card slider. */ appendCard: function(card) { assert(card instanceof Node, '|card| isn\'t a Node'); this.cards_.push(card); this.fireAddedEvent_(card, this.cards_.length - 1); }, /** * Dispatches a simple event to tell interested subscribers that a card was * added to this card slider. * @param {Node} card The recently added card. * @param {number} index The position of the newly added card. * @private */ fireAddedEvent_: function(card, index) { this.assertValidIndex_(index); var e = document.createEvent('Event'); e.initEvent('cardSlider:card_added', true, true); e.addedIndex = index; e.addedCard = card; this.container_.dispatchEvent(e); }, /** * Removes a card by index from the card slider. If the card to be removed * is the current card or in front of the current card, the current card * will be updated (to current card - 1). * @param {!Node} card A card to be removed. */ removeCard: function(card) { assert(card instanceof Node, '|card| isn\'t a Node'); this.removeCardAtIndex(this.cards_.indexOf(card)); }, /** * Removes a card by index from the card slider. If the card to be removed * is the current card or in front of the current card, the current card * will be updated (to current card - 1). * @param {number} index The index of the tile that should be removed. */ removeCardAtIndex: function(index) { this.assertValidIndex_(index); var removed = this.cards_.splice(index, 1).pop(); if (this.cards_.length == 0) this.currentCard_ = -1; else if (index < this.currentCard_) this.selectCard(this.currentCard_ - 1, false, true); this.fireRemovedEvent_(removed, index); }, /** * Dispatches a cardSlider:card_removed event so interested subscribers know * when a card was removed from this card slider. * @param {Node} card The recently removed card. * @param {number} index The index of the card before it was removed. * @private */ fireRemovedEvent_: function(card, index) { var e = document.createEvent('Event'); e.initEvent('cardSlider:card_removed', true, true); e.removedCard = card; e.removedIndex = index; this.container_.dispatchEvent(e); }, /** * Checks the the given |index| exists in this.cards_. * @param {number} index An index to check. * @private */ assertValidIndex_: function(index) { assert(index >= 0 && index < this.cards_.length); }, /** * Selects a new card, ensuring that it is a valid index, transforming the * view and possibly calling the change card callback. * @param {number} newCardIndex Index of card to show. * @param {boolean=} opt_animate If true will animate transition from * current position to new position. * @param {boolean=} opt_dontNotify If true, don't tell subscribers that * we've changed cards. * @param {boolean=} opt_forceChange If true, ignore if the card already * selected. */ selectCard: function(newCardIndex, opt_animate, opt_dontNotify, opt_forceChange) { this.assertValidIndex_(newCardIndex); var previousCard = this.currentCardValue; var isChangingCard = !this.cards_[newCardIndex].classList.contains('selected-card'); if (typeof opt_forceChange != 'undefined' && opt_forceChange) isChangingCard = true; if (isChangingCard) { if (previousCard) previousCard.classList.remove('selected-card'); this.currentCard_ = newCardIndex; this.currentCardValue.classList.add('selected-card'); } var willTransitionHappen = this.transformToCurrentCard_(opt_animate); if (isChangingCard && !opt_dontNotify) { var event = document.createEvent('Event'); event.initEvent('cardSlider:card_changed', true, true); event.cardSlider = this; event.wasAnimated = !!opt_animate; this.container_.dispatchEvent(event); // We also dispatch an event on the cards themselves. if (previousCard) { cr.dispatchSimpleEvent(previousCard, 'carddeselected', true, true); } cr.dispatchSimpleEvent(this.currentCardValue, 'cardselected', true, true); } // If we're not changing, animated, or transitioning, fire a // cardSlider:card_change_ended event right away. if ((!isChangingCard || !opt_animate || !willTransitionHappen) && !opt_dontNotify) { this.fireChangeEndedEvent_(false); } }, /** * Selects a card from the stack. Passes through to selectCard. * @param {Node} newCard The card that should be selected. * @param {boolean=} opt_animate Whether to animate. */ selectCardByValue: function(newCard, opt_animate) { var i = this.cards_.indexOf(newCard); assert(i != -1); this.selectCard(i, opt_animate); }, /** * Centers the view on the card denoted by this.currentCard. Can either * animate to that card or snap to it. * @param {boolean=} opt_animate If true will animate transition from * current position to new position. * @return {boolean} Whether or not a transformation was necessary. * @private */ transformToCurrentCard_: function(opt_animate) { var prevLeft = this.currentLeft_; this.currentLeft_ = -this.cardWidth_ * (isRTL() ? this.cards_.length - this.currentCard - 1 : this.currentCard); // If there's no change, return something to let the caller know there // won't be a transition occuring. if (prevLeft == this.currentLeft_) return false; // Animate to the current card, which will either transition if the // current card is new, or reset the existing card if we didn't drag // enough to change cards. var transition = ''; if (opt_animate) { transition = '-webkit-transform ' + CardSlider.TRANSITION_TIME_ + 'ms ease-in-out'; } this.container_.style.WebkitTransition = transition; this.translateTo_(this.currentLeft_); return true; }, /** * Moves the view to the specified position. * @param {number} x Horizontal position to move to. * @private */ translateTo_: function(x) { // We use a webkitTransform to slide because this is GPU accelerated on // Chrome and iOS. Once Chrome does GPU acceleration on the position // fixed-layout elements we could simply set the element's position to // fixed and modify 'left' instead. this.container_.style.WebkitTransform = 'translate3d(' + x + 'px, 0, 0)'; }, /* Touch ******************************************************************/ /** * Clear any transition that is in progress and enable dragging for the * touch. * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. * @private */ onTouchStart_: function(e) { this.container_.style.WebkitTransition = ''; e.enableDrag = true; }, /** * Tell the TouchHandler that dragging is acceptable when the user begins by * scrolling horizontally. * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. * @private */ onDragStart_: function(e) { e.enableDrag = Math.abs(e.dragDeltaX) > Math.abs(e.dragDeltaY); }, /** * On each drag move event reposition the container appropriately so the * cards look like they are sliding. * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. * @private */ onDragMove_: function(e) { var deltaX = e.dragDeltaX; // If dragging beyond the first or last card then apply a backoff so the // dragging feels stickier than usual. if (!this.currentCard && deltaX > 0 || this.currentCard == (this.cards_.length - 1) && deltaX < 0) { deltaX /= 2; } this.translateTo_(this.currentLeft_ + deltaX); }, /** * On drag end events we may want to transition to another card, depending * on the ending position of the drag and the velocity of the drag. * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. * @private */ onDragEnd_: function(e) { var deltaX = e.dragDeltaX; var velocity = this.touchHandler_.getEndVelocity().x; var newX = this.currentLeft_ + deltaX; var newCardIndex = Math.round(-newX / this.cardWidth_); if (newCardIndex == this.currentCard && Math.abs(velocity) > CardSlider.TRANSITION_VELOCITY_THRESHOLD_) { // If the drag wasn't far enough to change cards but the velocity was // high enough to transition anyways. If the velocity is to the left // (negative) then the user wishes to go right (card +1). newCardIndex += velocity > 0 ? -1 : 1; } this.selectCard(newCardIndex, /* animate */ true); }, /** * Cancel any current touch/slide as if we saw a touch end */ cancelTouch: function() { // Stop listening to any current touch this.touchHandler_.cancelTouch(); // Ensure we're at a card bounary this.transformToCurrentCard_(true); }, }; return { CardSlider: CardSlider }; });