Home | History | Annotate | Download | only in ntp4
      1 // Copyright (c) 2011 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.
      4 
      5 /**
      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.
     18  *
     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.
     22  */
     23 
     24 
     25 // Use an anonymous function to enable strict mode just for this file (which
     26 // will be concatenated with other files when embedded in Chrome
     27 var CardSlider = (function() {
     28   'use strict';
     29 
     30   /**
     31    * @constructor
     32    * @param {!Element} frame The bounding rectangle that cards are visible in.
     33    * @param {!Element} container The surrounding element that will have event
     34    *     listeners attached to it.
     35    * @param {!Array.<!Element>} cards The individual viewable cards.
     36    * @param {number} currentCard The index of the card that is currently
     37    *     visible.
     38    * @param {number} cardWidth The width of each card should have.
     39    */
     40   function CardSlider(frame, container, cards, currentCard, cardWidth) {
     41     /**
     42      * @type {!Element}
     43      * @private
     44      */
     45     this.frame_ = frame;
     46 
     47     /**
     48      * @type {!Element}
     49      * @private
     50      */
     51     this.container_ = container;
     52 
     53     /**
     54      * @type {!Array.<!Element>}
     55      * @private
     56      */
     57     this.cards_ = cards;
     58 
     59     /**
     60      * @type {number}
     61      * @private
     62      */
     63     this.currentCard_ = currentCard;
     64 
     65     /**
     66      * @type {number}
     67      * @private
     68      */
     69     this.cardWidth_ = cardWidth;
     70 
     71     /**
     72      * @type {!TouchHandler}
     73      * @private
     74      */
     75     this.touchHandler_ = new TouchHandler(this.container_);
     76   }
     77 
     78 
     79   /**
     80    * Events fired by the slider.
     81    * Events are fired at the container.
     82    */
     83   CardSlider.EventType = {
     84     // Fired when the user slides to another card.
     85     CARD_CHANGED: 'cardSlider:card_changed'
     86   };
     87 
     88 
     89   /**
     90    * The time to transition between cards when animating. Measured in ms.
     91    * @type {number}
     92    * @private
     93    * @const
     94    */
     95   CardSlider.TRANSITION_TIME_ = 200;
     96 
     97 
     98   /**
     99    * The minimum velocity required to transition cards if they did not drag past
    100    * the halfway point between cards. Measured in pixels / ms.
    101    * @type {number}
    102    * @private
    103    * @const
    104    */
    105   CardSlider.TRANSITION_VELOCITY_THRESHOLD_ = 0.2;
    106 
    107 
    108   CardSlider.prototype = {
    109     /**
    110      * The current left offset of the container relative to the frame.
    111      * @type {number}
    112      * @private
    113      */
    114     currentLeft_: 0,
    115 
    116     /**
    117      * Initialize all elements and event handlers. Must call after construction
    118      * and before usage.
    119      */
    120     initialize: function() {
    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.');
    128       for (var i = 0, card; card = this.cards_[i]; i++) {
    129         assert(view.getComputedStyle(card).position == 'static',
    130             'Cards should be position static.');
    131       }
    132 
    133       this.updateCardWidths_();
    134       this.transformToCurrentCard_();
    135 
    136       this.mouseWheelScrollAmount_ = 0;
    137       this.scrollClearTimeout_ = null;
    138       this.container_.addEventListener('mousewheel',
    139                                        this.onMouseWheel_.bind(this));
    140       this.container_.addEventListener(TouchHandler.EventType.TOUCH_START,
    141                                        this.onTouchStart_.bind(this));
    142       this.container_.addEventListener(TouchHandler.EventType.DRAG_START,
    143                                        this.onDragStart_.bind(this));
    144       this.container_.addEventListener(TouchHandler.EventType.DRAG_MOVE,
    145                                        this.onDragMove_.bind(this));
    146       this.container_.addEventListener(TouchHandler.EventType.DRAG_END,
    147                                        this.onDragEnd_.bind(this));
    148 
    149       this.touchHandler_.enable(/* opt_capture */ false);
    150     },
    151 
    152     /**
    153      * Use in cases where the width of the frame has changed in order to update
    154      * the width of cards. For example should be used when orientation changes
    155      * in full width sliders.
    156      * @param {number} newCardWidth Width all cards should have, in pixels.
    157      */
    158     resize: function(newCardWidth) {
    159       if (newCardWidth != this.cardWidth_) {
    160         this.cardWidth_ = newCardWidth;
    161 
    162         this.updateCardWidths_();
    163 
    164         // Must upate the transform on the container to show the correct card.
    165         this.transformToCurrentCard_();
    166       }
    167     },
    168 
    169     /**
    170      * Sets the cards used. Can be called more than once to switch card sets.
    171      * @param {!Array.<!Element>} cards The individual viewable cards.
    172      * @param {number} index Index of the card to in the new set of cards to
    173      *     navigate to.
    174      */
    175     setCards: function(cards, index) {
    176       assert(index >= 0 && index < cards.length,
    177           'Invalid index in CardSlider#setCards');
    178       this.cards_ = cards;
    179 
    180       this.updateCardWidths_();
    181 
    182       // Jump to the given card index.
    183       this.selectCard(index);
    184     },
    185 
    186     /**
    187      * Updates the width of each card.
    188      * @private
    189      */
    190     updateCardWidths_: function() {
    191       for (var i = 0, card; card = this.cards_[i]; i++)
    192         card.style.width = this.cardWidth_ + 'px';
    193     },
    194 
    195     /**
    196      * Returns the index of the current card.
    197      * @return {number} index of the current card.
    198      */
    199     get currentCard() {
    200       return this.currentCard_;
    201     },
    202 
    203     /**
    204      * Handle horizontal scrolls to flip between pages.
    205      * @private
    206      */
    207     onMouseWheel_: function(e) {
    208       if (e.wheelDeltaX == 0)
    209         return;
    210 
    211       var scrollAmountPerPage = -120;
    212       this.mouseWheelScrollAmount_ += e.wheelDeltaX;
    213       if (Math.abs(this.mouseWheelScrollAmount_) >= -scrollAmountPerPage) {
    214         var pagesToScroll = this.mouseWheelScrollAmount_ / scrollAmountPerPage;
    215         pagesToScroll =
    216             (pagesToScroll > 0 ? Math.floor : Math.ceil)(pagesToScroll);
    217         var newCardIndex = this.currentCard + pagesToScroll;
    218         newCardIndex = Math.min(this.cards_.length,
    219                                 Math.max(0, newCardIndex));
    220         this.selectCard(newCardIndex, true);
    221         this.mouseWheelScrollAmount_ -= pagesToScroll * scrollAmountPerPage;
    222       }
    223 
    224       // We got a mouse wheel event, so cancel any pending scroll wheel timeout.
    225       if (this.scrollClearTimeout_ != null)
    226         clearTimeout(this.scrollClearTimeout_);
    227       // If we didn't use up all the scroll, hold onto it for a little bit, but
    228       // drop it after a delay.
    229       if (this.mouseWheelScrollAmount_ != 0) {
    230         this.scrollClearTimeout_ =
    231             setTimeout(this.clearMouseWheelScroll_.bind(this), 500);
    232       }
    233     },
    234 
    235     /**
    236      * Resets the amount of horizontal scroll we've seen to 0. See
    237      * onMouseWheel_.
    238      * @private
    239      */
    240     clearMouseWheelScroll_: function() {
    241       this.mouseWheelScrollAmount_ = 0;
    242     },
    243 
    244     /**
    245      * Clear any transition that is in progress and enable dragging for the
    246      * touch.
    247      * @param {!TouchHandler.Event} e The TouchHandler event.
    248      * @private
    249      */
    250     onTouchStart_: function(e) {
    251       this.container_.style.WebkitTransition = '';
    252       e.enableDrag = true;
    253     },
    254 
    255     /**
    256      * Tell the TouchHandler that dragging is acceptable when the user begins by
    257      * scrolling horizontally.
    258      * @param {!TouchHandler.Event} e The TouchHandler event.
    259      * @private
    260      */
    261     onDragStart_: function(e) {
    262       e.enableDrag = Math.abs(e.dragDeltaX) > Math.abs(e.dragDeltaY);
    263     },
    264 
    265     /**
    266      * On each drag move event reposition the container appropriately so the
    267      * cards look like they are sliding.
    268      * @param {!TouchHandler.Event} e The TouchHandler event.
    269      * @private
    270      */
    271     onDragMove_: function(e) {
    272       var deltaX = e.dragDeltaX;
    273       // If dragging beyond the first or last card then apply a backoff so the
    274       // dragging feels stickier than usual.
    275       if (!this.currentCard && deltaX > 0 ||
    276           this.currentCard == (this.cards_.length - 1) && deltaX < 0) {
    277         deltaX /= 2;
    278       }
    279       this.translateTo_(this.currentLeft_ + deltaX);
    280     },
    281 
    282     /**
    283      * Moves the view to the specified position.
    284      * @param {number} x Horizontal position to move to.
    285      * @private
    286      */
    287     translateTo_: function(x) {
    288       // We use a webkitTransform to slide because this is GPU accelerated on
    289       // Chrome and iOS.  Once Chrome does GPU acceleration on the position
    290       // fixed-layout elements we could simply set the element's position to
    291       // fixed and modify 'left' instead.
    292       this.container_.style.WebkitTransform = 'translate3d(' + x + 'px, 0, 0)';
    293     },
    294 
    295     /**
    296      * On drag end events we may want to transition to another card, depending
    297      * on the ending position of the drag and the velocity of the drag.
    298      * @param {!TouchHandler.Event} e The TouchHandler event.
    299      * @private
    300      */
    301     onDragEnd_: function(e) {
    302       var deltaX = e.dragDeltaX;
    303       var velocity = this.touchHandler_.getEndVelocity().x;
    304       var newX = this.currentLeft_ + deltaX;
    305       var newCardIndex = Math.round(-newX / this.cardWidth_);
    306 
    307       if (newCardIndex == this.currentCard && Math.abs(velocity) >
    308           CardSlider.TRANSITION_VELOCITY_THRESHOLD_) {
    309         // If the drag wasn't far enough to change cards but the velocity was
    310         // high enough to transition anyways. If the velocity is to the left
    311         // (negative) then the user wishes to go right (card +1).
    312         newCardIndex += velocity > 0 ? -1 : 1;
    313       }
    314 
    315       this.selectCard(newCardIndex, /* animate */ true);
    316     },
    317 
    318     /**
    319      * Cancel any current touch/slide as if we saw a touch end
    320      */
    321     cancelTouch: function() {
    322       // Stop listening to any current touch
    323       this.touchHandler_.cancelTouch();
    324 
    325       // Ensure we're at a card bounary
    326       this.transformToCurrentCard_(true);
    327     },
    328 
    329     /**
    330      * Selects a new card, ensuring that it is a valid index, transforming the
    331      * view and possibly calling the change card callback.
    332      * @param {number} newCardIndex Index of card to show.
    333      * @param {boolean=} opt_animate If true will animate transition from
    334      *     current position to new position.
    335      */
    336     selectCard: function(newCardIndex, opt_animate) {
    337       var isChangingCard = newCardIndex >= 0 &&
    338           newCardIndex < this.cards_.length &&
    339           newCardIndex != this.currentCard;
    340       if (isChangingCard) {
    341         // If we have a new card index and it is valid then update the left
    342         // position and current card index.
    343         this.currentCard_ = newCardIndex;
    344       }
    345 
    346       this.transformToCurrentCard_(opt_animate);
    347 
    348       if (isChangingCard) {
    349         var event = document.createEvent('Event');
    350         event.initEvent(CardSlider.EventType.CARD_CHANGED, true, true);
    351         event.cardSlider = this;
    352         this.container_.dispatchEvent(event);
    353       }
    354     },
    355 
    356     /**
    357      * Centers the view on the card denoted by this.currentCard. Can either
    358      * animate to that card or snap to it.
    359      * @param {boolean=} opt_animate If true will animate transition from
    360      *     current position to new position.
    361      * @private
    362      */
    363     transformToCurrentCard_: function(opt_animate) {
    364       this.currentLeft_ = -this.currentCard * this.cardWidth_;
    365 
    366       // Animate to the current card, which will either transition if the
    367       // current card is new, or reset the existing card if we didn't drag
    368       // enough to change cards.
    369       var transition = '';
    370       if (opt_animate) {
    371         transition = '-webkit-transform ' + CardSlider.TRANSITION_TIME_ +
    372                      'ms ease-in-out';
    373       }
    374       this.container_.style.WebkitTransition = transition;
    375       this.translateTo_(this.currentLeft_);
    376     }
    377   };
    378 
    379   return CardSlider;
    380 })();
    381