1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview Card slider implementation. Allows you to create interactions
7 * that have items that can slide left to right to reveal additional items.
8 * Works by adding the necessary event handlers to a specific DOM structure
9 * including a frame, container and cards.
10 * - The frame defines the boundary of one item. Each card will be expanded to
11 * fill the width of the frame. This element is also overflow hidden so that
12 * the additional items left / right do not trigger horizontal scrolling.
13 * - The container is what all the touch events are attached to. This element
14 * will be expanded to be the width of all cards.
15 * - The cards are the individual viewable items. There should be one card for
16 * each item in the list. Only one card will be visible at a time. Two cards
17 * will be visible while you are transitioning between cards.
19 * This class is designed to work well on any hardware-accelerated touch device.
20 * It should still work on pre-hardware accelerated devices it just won't feel
21 * very good. It should also work well with a mouse.
24 // Use an anonymous function to enable strict mode just for this file (which
25 // will be concatenated with other files when embedded in Chrome
26 cr.define('cr.ui', function() {
31 * @param {!Element} frame The bounding rectangle that cards are visible in.
32 * @param {!Element} container The surrounding element that will have event
33 * listeners attached to it.
34 * @param {number} cardWidth The width of each card should have.
36 function CardSlider(frame, container, cardWidth) {
47 this.container_ = container;
50 * Array of card elements.
51 * @type {!Array.<!Element>}
57 * Index of currently shown card.
61 this.currentCard_ = -1;
67 this.cardWidth_ = cardWidth;
70 * @type {!cr.ui.TouchHandler}
73 this.touchHandler_ = new cr.ui.TouchHandler(this.container_);
78 * The time to transition between cards when animating. Measured in ms.
83 CardSlider.TRANSITION_TIME_ = 200;
87 * The minimum velocity required to transition cards if they did not drag past
88 * the halfway point between cards. Measured in pixels / ms.
93 CardSlider.TRANSITION_VELOCITY_THRESHOLD_ = 0.2;
96 CardSlider.prototype = {
98 * The current left offset of the container relative to the frame. This
99 * position does not include deltas from active drag operations, and
100 * always aligns with a frame boundary.
107 * Current offset relative to |currentLeft_| due to an active drag
115 * Initialize all elements and event handlers. Must call after construction
117 * @param {boolean} ignoreMouseWheelEvents If true, horizontal mouse wheel
118 * events will be ignored, rather than flipping between pages.
120 initialize: function(ignoreMouseWheelEvents) {
121 var view = this.container_.ownerDocument.defaultView;
122 assert(view.getComputedStyle(this.container_).display == '-webkit-box',
123 'Container should be display -webkit-box.');
124 assert(view.getComputedStyle(this.frame_).overflow == 'hidden',
125 'Frame should be overflow hidden.');
126 assert(view.getComputedStyle(this.container_).position == 'static',
127 'Container should be position static.');
129 this.updateCardWidths_();
131 this.mouseWheelScrollAmount_ = 0;
132 this.mouseWheelCardSelected_ = false;
133 this.mouseWheelIsContinuous_ = false;
134 this.scrollClearTimeout_ = null;
135 if (!ignoreMouseWheelEvents) {
136 this.frame_.addEventListener('mousewheel',
137 this.onMouseWheel_.bind(this));
139 this.container_.addEventListener(
140 'webkitTransitionEnd', this.onWebkitTransitionEnd_.bind(this));
142 // Also support touch events in case a touch screen happens to be
143 // available. Note that this has minimal impact in the common case of
144 // no touch events (eg. we're mainly just adding listeners for events that
145 // will never trigger).
146 var TouchHandler = cr.ui.TouchHandler;
147 this.container_.addEventListener(TouchHandler.EventType.TOUCH_START,
148 this.onTouchStart_.bind(this));
149 this.container_.addEventListener(TouchHandler.EventType.DRAG_START,
150 this.onDragStart_.bind(this));
151 this.container_.addEventListener(TouchHandler.EventType.DRAG_MOVE,
152 this.onDragMove_.bind(this));
153 this.container_.addEventListener(TouchHandler.EventType.DRAG_END,
154 this.onDragEnd_.bind(this));
156 this.touchHandler_.enable(/* opt_capture */ false);
160 * Use in cases where the width of the frame has changed in order to update
161 * the width of cards. For example should be used when orientation changes
162 * in full width sliders.
163 * @param {number} newCardWidth Width all cards should have, in pixels.
165 resize: function(newCardWidth) {
166 if (newCardWidth != this.cardWidth_) {
167 this.cardWidth_ = newCardWidth;
169 this.updateCardWidths_();
171 // Must upate the transform on the container to show the correct card.
172 this.transformToCurrentCard_();
177 * Sets the cards used. Can be called more than once to switch card sets.
178 * @param {!Array.<!Element>} cards The individual viewable cards.
179 * @param {number} index Index of the card to in the new set of cards to
182 setCards: function(cards, index) {
183 assert(index >= 0 && index < cards.length,
184 'Invalid index in CardSlider#setCards');
187 this.updateCardWidths_();
188 this.updateSelectedCardAttributes_();
190 // Jump to the given card index.
191 this.selectCard(index, false, false, true);
195 * Ensures that for all cards:
196 * - if the card is the current card, then it has 'selected-card' in its
197 * classList, and is visible for accessibility
198 * - if the card is not the selected card, then it does not have
199 * 'selected-card' in its classList, and is invisible for accessibility.
202 updateSelectedCardAttributes_: function() {
203 for (var i = 0; i < this.cards_.length; i++) {
204 if (i == this.currentCard_) {
205 this.cards_[i].classList.add('selected-card');
206 this.cards_[i].removeAttribute('aria-hidden');
208 this.cards_[i].classList.remove('selected-card');
209 this.cards_[i].setAttribute('aria-hidden', true);
215 * Updates the width of each card.
218 updateCardWidths_: function() {
219 for (var i = 0, card; card = this.cards_[i]; i++)
220 card.style.width = this.cardWidth_ + 'px';
224 * Returns the index of the current card.
225 * @return {number} index of the current card.
228 return this.currentCard_;
232 * Allows setting the current card index.
233 * @param {number} index A new index to set the current index to.
234 * @return {number} The new index after having been set.
236 set currentCard(index) {
237 return (this.currentCard_ = index);
241 * Returns the number of cards.
242 * @return {number} number of cards.
245 return this.cards_.length;
249 * Returns the current card itself.
250 * @return {!Element} the currently shown card.
252 get currentCardValue() {
253 return this.cards_[this.currentCard_];
257 * Returns the frame holding the cards.
258 * @return {Element} The frame used to position the cards.
265 * Handle horizontal scrolls to flip between pages.
268 onMouseWheel_: function(e) {
269 if (e.wheelDeltaX == 0)
272 // Continuous devices such as an Apple Touchpad or Apple MagicMouse will
273 // send arbitrary delta values. Conversly, standard mousewheels will
274 // send delta values in increments of 120. (There is of course a small
275 // chance we mistake a continuous device for a non-continuous device.
276 // Unfortunately there isn't a better way to do this until real touch
277 // events are available to desktop clients.)
278 var DISCRETE_DELTA = 120;
279 if (e.wheelDeltaX % DISCRETE_DELTA)
280 this.mouseWheelIsContinuous_ = true;
282 if (this.mouseWheelIsContinuous_) {
283 // For continuous devices, detect a page swipe when the accumulated
284 // delta matches a pre-defined threshhold. After changing the page,
285 // ignore wheel events for a short time before repeating this process.
286 if (this.mouseWheelCardSelected_) return;
287 this.mouseWheelScrollAmount_ += e.wheelDeltaX;
288 if (Math.abs(this.mouseWheelScrollAmount_) >= 600) {
289 var pagesToScroll = this.mouseWheelScrollAmount_ > 0 ? 1 : -1;
292 var newCardIndex = this.currentCard + pagesToScroll;
293 newCardIndex = Math.min(this.cards_.length - 1,
294 Math.max(0, newCardIndex));
295 this.selectCard(newCardIndex, true);
296 this.mouseWheelCardSelected_ = true;
299 // For discrete devices, consider each wheel tick a page change.
300 var pagesToScroll = e.wheelDeltaX / DISCRETE_DELTA;
303 var newCardIndex = this.currentCard + pagesToScroll;
304 newCardIndex = Math.min(this.cards_.length - 1,
305 Math.max(0, newCardIndex));
306 this.selectCard(newCardIndex, true);
309 // We got a mouse wheel event, so cancel any pending scroll wheel timeout.
310 if (this.scrollClearTimeout_ != null)
311 clearTimeout(this.scrollClearTimeout_);
312 // If we didn't use up all the scroll, hold onto it for a little bit, but
313 // drop it after a delay.
314 if (this.mouseWheelScrollAmount_ != 0) {
315 this.scrollClearTimeout_ =
316 setTimeout(this.clearMouseWheelScroll_.bind(this), 500);
321 * Resets the amount of horizontal scroll we've seen to 0. See
325 clearMouseWheelScroll_: function() {
326 this.mouseWheelScrollAmount_ = 0;
327 this.mouseWheelCardSelected_ = false;
331 * Handles the ends of -webkit-transitions on -webkit-transform (animated
333 * @param {Event} e The webkitTransitionEnd event.
336 onWebkitTransitionEnd_: function(e) {
337 // Ignore irrelevant transitions that might bubble up.
338 if (e.target !== this.container_ ||
339 e.propertyName != '-webkit-transform') {
342 this.fireChangeEndedEvent_(true);
346 * Dispatches a simple event to tell subscribers we're done moving to the
347 * newly selected card.
348 * @param {boolean} wasAnimated whether or not the change was animated.
351 fireChangeEndedEvent_: function(wasAnimated) {
352 var e = document.createEvent('Event');
353 e.initEvent('cardSlider:card_change_ended', true, true);
355 e.changedTo = this.currentCard_;
356 e.wasAnimated = wasAnimated;
357 this.container_.dispatchEvent(e);
361 * Add a card to the card slider at a particular index. If the card being
362 * added is inserted in front of the current card, cardSlider.currentCard
363 * will be adjusted accordingly (to current card + 1).
364 * @param {!Node} card A card that will be added to the card slider.
365 * @param {number} index An index at which the given |card| should be
366 * inserted. Must be positive and less than the number of cards.
368 addCardAtIndex: function(card, index) {
369 assert(card instanceof Node, '|card| isn\'t a Node');
370 this.assertValidIndex_(index);
371 this.cards_ = Array.prototype.concat.call(
372 this.cards_.slice(0, index), card, this.cards_.slice(index));
374 this.updateSelectedCardAttributes_();
376 if (this.currentCard_ == -1)
377 this.currentCard_ = 0;
378 else if (index <= this.currentCard_)
379 this.selectCard(this.currentCard_ + 1, false, true, true);
381 this.fireAddedEvent_(card, index);
385 * Append a card to the end of the list.
386 * @param {!Node} card A card to add at the end of the card slider.
388 appendCard: function(card) {
389 assert(card instanceof Node, '|card| isn\'t a Node');
390 this.cards_.push(card);
391 this.fireAddedEvent_(card, this.cards_.length - 1);
395 * Dispatches a simple event to tell interested subscribers that a card was
396 * added to this card slider.
397 * @param {Node} card The recently added card.
398 * @param {number} index The position of the newly added card.
401 fireAddedEvent_: function(card, index) {
402 this.assertValidIndex_(index);
403 var e = document.createEvent('Event');
404 e.initEvent('cardSlider:card_added', true, true);
405 e.addedIndex = index;
407 this.container_.dispatchEvent(e);
411 * Returns the card at a particular index.
412 * @param {number} index The index of the card to return.
413 * @return {!Element} The card at the given index.
415 getCardAtIndex: function(index) {
416 this.assertValidIndex_(index);
417 return this.cards_[index];
421 * Removes a card by index from the card slider. If the card to be removed
422 * is the current card or in front of the current card, the current card
423 * will be updated (to current card - 1).
424 * @param {!Node} card A card to be removed.
426 removeCard: function(card) {
427 assert(card instanceof Node, '|card| isn\'t a Node');
428 this.removeCardAtIndex(this.cards_.indexOf(card));
432 * Removes a card by index from the card slider. If the card to be removed
433 * is the current card or in front of the current card, the current card
434 * will be updated (to current card - 1).
435 * @param {number} index The index of the tile that should be removed.
437 removeCardAtIndex: function(index) {
438 this.assertValidIndex_(index);
439 var removed = this.cards_.splice(index, 1).pop();
441 if (this.cards_.length == 0)
442 this.currentCard_ = -1;
443 else if (index < this.currentCard_)
444 this.selectCard(this.currentCard_ - 1, false, true);
446 this.fireRemovedEvent_(removed, index);
450 * Dispatches a cardSlider:card_removed event so interested subscribers know
451 * when a card was removed from this card slider.
452 * @param {Node} card The recently removed card.
453 * @param {number} index The index of the card before it was removed.
456 fireRemovedEvent_: function(card, index) {
457 var e = document.createEvent('Event');
458 e.initEvent('cardSlider:card_removed', true, true);
459 e.removedCard = card;
460 e.removedIndex = index;
461 this.container_.dispatchEvent(e);
465 * This re-syncs the -webkit-transform that's used to position the frame in
466 * the likely event it needs to be updated by a card being inserted or
467 * removed in the flow.
469 repositionFrame: function() {
470 this.transformToCurrentCard_();
474 * Checks the the given |index| exists in this.cards_.
475 * @param {number} index An index to check.
478 assertValidIndex_: function(index) {
479 assert(index >= 0 && index < this.cards_.length);
483 * Selects a new card, ensuring that it is a valid index, transforming the
484 * view and possibly calling the change card callback.
485 * @param {number} newCardIndex Index of card to show.
486 * @param {boolean=} opt_animate If true will animate transition from
487 * current position to new position.
488 * @param {boolean=} opt_dontNotify If true, don't tell subscribers that
489 * we've changed cards.
490 * @param {boolean=} opt_forceChange If true, ignore if the card already
493 selectCard: function(newCardIndex,
497 this.assertValidIndex_(newCardIndex);
499 var previousCard = this.currentCardValue;
501 !this.cards_[newCardIndex].classList.contains('selected-card');
503 if (typeof opt_forceChange != 'undefined' && opt_forceChange)
504 isChangingCard = true;
506 if (isChangingCard) {
507 this.currentCard_ = newCardIndex;
508 this.updateSelectedCardAttributes_();
511 var willTransitionHappen = this.transformToCurrentCard_(opt_animate);
513 if (isChangingCard && !opt_dontNotify) {
514 var event = document.createEvent('Event');
515 event.initEvent('cardSlider:card_changed', true, true);
516 event.cardSlider = this;
517 event.wasAnimated = !!opt_animate;
518 this.container_.dispatchEvent(event);
520 // We also dispatch an event on the cards themselves.
522 cr.dispatchSimpleEvent(previousCard, 'carddeselected',
525 cr.dispatchSimpleEvent(this.currentCardValue, 'cardselected',
529 // If we're not changing, animated, or transitioning, fire a
530 // cardSlider:card_change_ended event right away.
531 if ((!isChangingCard || !opt_animate || !willTransitionHappen) &&
533 this.fireChangeEndedEvent_(false);
538 * Selects a card from the stack. Passes through to selectCard.
539 * @param {Node} newCard The card that should be selected.
540 * @param {boolean=} opt_animate Whether to animate.
542 selectCardByValue: function(newCard, opt_animate) {
543 var i = this.cards_.indexOf(newCard);
545 this.selectCard(i, opt_animate);
549 * Centers the view on the card denoted by this.currentCard. Can either
550 * animate to that card or snap to it.
551 * @param {boolean=} opt_animate If true will animate transition from
552 * current position to new position.
553 * @return {boolean} Whether or not a transformation was necessary.
556 transformToCurrentCard_: function(opt_animate) {
557 var prevLeft = this.currentLeft_;
558 this.currentLeft_ = -this.cardWidth_ *
559 (isRTL() ? this.cards_.length - this.currentCard - 1 :
562 // If there's no change, return something to let the caller know there
563 // won't be a transition occuring.
564 if (prevLeft == this.currentLeft_ && this.deltaX_ == 0)
567 // Animate to the current card, which will either transition if the
568 // current card is new, or reset the existing card if we didn't drag
569 // enough to change cards.
572 transition = '-webkit-transform ' + CardSlider.TRANSITION_TIME_ +
575 this.container_.style.WebkitTransition = transition;
576 this.translateTo_(this.currentLeft_);
582 * Moves the view to the specified position.
583 * @param {number} x Horizontal position to move to.
586 translateTo_: function(x) {
587 // We use a webkitTransform to slide because this is GPU accelerated on
588 // Chrome and iOS. Once Chrome does GPU acceleration on the position
589 // fixed-layout elements we could simply set the element's position to
590 // fixed and modify 'left' instead.
591 this.deltaX_ = x - this.currentLeft_;
592 this.container_.style.WebkitTransform = 'translate3d(' + x + 'px, 0, 0)';
595 /* Touch ******************************************************************/
598 * Clear any transition that is in progress and enable dragging for the
600 * @param {!Event} e The TouchHandler event.
603 onTouchStart_: function(e) {
604 e = /** @type {!cr.ui.TouchHandler.Event} */(e);
605 this.container_.style.WebkitTransition = '';
610 * Tell the TouchHandler that dragging is acceptable when the user begins by
611 * scrolling horizontally and there is more than one card to slide.
612 * @param {!Event} e The TouchHandler event.
615 onDragStart_: function(e) {
616 e = /** @type {!cr.ui.TouchHandler.Event} */(e);
617 e.enableDrag = this.cardCount > 1 && Math.abs(e.dragDeltaX) >
618 Math.abs(e.dragDeltaY);
622 * On each drag move event reposition the container appropriately so the
623 * cards look like they are sliding.
624 * @param {!Event} e The TouchHandler event.
627 onDragMove_: function(e) {
628 e = /** @type {!cr.ui.TouchHandler.Event} */(e);
629 var deltaX = e.dragDeltaX;
630 // If dragging beyond the first or last card then apply a backoff so the
631 // dragging feels stickier than usual.
632 if (!this.currentCard && deltaX > 0 ||
633 this.currentCard == (this.cards_.length - 1) && deltaX < 0) {
636 this.translateTo_(this.currentLeft_ + deltaX);
640 * On drag end events we may want to transition to another card, depending
641 * on the ending position of the drag and the velocity of the drag.
642 * @param {!Event} e The TouchHandler event.
645 onDragEnd_: function(e) {
646 e = /** @type {!cr.ui.TouchHandler.Event} */(e);
647 var deltaX = e.dragDeltaX;
648 var velocity = this.touchHandler_.getEndVelocity().x;
649 var newX = this.currentLeft_ + deltaX;
650 var newCardIndex = Math.round(-newX / this.cardWidth_);
652 if (newCardIndex == this.currentCard && Math.abs(velocity) >
653 CardSlider.TRANSITION_VELOCITY_THRESHOLD_) {
654 // The drag wasn't far enough to change cards but the velocity was
655 // high enough to transition anyways. If the velocity is to the left
656 // (negative) then the user wishes to go right (card + 1).
657 newCardIndex += velocity > 0 ? -1 : 1;
659 // Ensure that the new card index is valid. The new card index could be
660 // invalid if a swipe suggests scrolling off the end of the list of
662 if (newCardIndex < 0)
664 else if (newCardIndex >= this.cardCount)
665 newCardIndex = this.cardCount - 1;
666 this.selectCard(newCardIndex, /* animate */ true);
670 * Cancel any current touch/slide as if we saw a touch end
672 cancelTouch: function() {
673 // Stop listening to any current touch
674 this.touchHandler_.cancelTouch();
676 // Ensure we're at a card bounary
677 this.transformToCurrentCard_(true);
682 CardSlider: CardSlider