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
27var 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