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.
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_ = 0;
67 this.cardWidth_ = cardWidth;
70 * @type {!cr.ui.TouchHandler}
73 this.touchHandler_ = new cr.ui.TouchHandler(this.container_);
77 * Events fired by the slider.
78 * Events are fired at the container.
80 CardSlider.EventType = {
81 // Fired when the user slides to another card.
82 CARD_CHANGED: 'cardSlider:card_changed'
87 * The time to transition between cards when animating. Measured in ms.
92 CardSlider.TRANSITION_TIME_ = 200;
96 * The minimum velocity required to transition cards if they did not drag past
97 * the halfway point between cards. Measured in pixels / ms.
102 CardSlider.TRANSITION_VELOCITY_THRESHOLD_ = 0.2;
105 CardSlider.prototype = {
107 * The current left offset of the container relative to the frame.
114 * Initialize all elements and event handlers. Must call after construction
117 initialize: function() {
118 var view = this.container_.ownerDocument.defaultView;
119 assert(view.getComputedStyle(this.container_).display == '-webkit-box',
120 'Container should be display -webkit-box.');
121 assert(view.getComputedStyle(this.frame_).overflow == 'hidden',
122 'Frame should be overflow hidden.');
123 assert(view.getComputedStyle(this.container_).position == 'static',
124 'Container should be position static.');
126 this.updateCardWidths_();
128 this.mouseWheelScrollAmount_ = 0;
129 this.mouseWheelCardSelected_ = false;
130 this.mouseWheelIsContinuous_ = false;
131 this.scrollClearTimeout_ = null;
132 this.frame_.addEventListener('mousewheel',
133 this.onMouseWheel_.bind(this));
135 if (document.documentElement.getAttribute('touchui')) {
136 var TouchHandler = cr.ui.TouchHandler;
137 this.container_.addEventListener(TouchHandler.EventType.TOUCH_START,
138 this.onTouchStart_.bind(this));
139 this.container_.addEventListener(TouchHandler.EventType.DRAG_START,
140 this.onDragStart_.bind(this));
141 this.container_.addEventListener(TouchHandler.EventType.DRAG_MOVE,
142 this.onDragMove_.bind(this));
143 this.container_.addEventListener(TouchHandler.EventType.DRAG_END,
144 this.onDragEnd_.bind(this));
146 this.touchHandler_.enable(/* opt_capture */ false);
151 * Use in cases where the width of the frame has changed in order to update
152 * the width of cards. For example should be used when orientation changes
153 * in full width sliders.
154 * @param {number} newCardWidth Width all cards should have, in pixels.
156 resize: function(newCardWidth) {
157 if (newCardWidth != this.cardWidth_) {
158 this.cardWidth_ = newCardWidth;
160 this.updateCardWidths_();
162 // Must upate the transform on the container to show the correct card.
163 this.transformToCurrentCard_();
168 * Sets the cards used. Can be called more than once to switch card sets.
169 * @param {!Array.<!Element>} cards The individual viewable cards.
170 * @param {number} index Index of the card to in the new set of cards to
173 setCards: function(cards, index) {
174 assert(index >= 0 && index < cards.length,
175 'Invalid index in CardSlider#setCards');
178 this.updateCardWidths_();
180 // Jump to the given card index.
181 this.selectCard(index);
185 * Updates the width of each card.
188 updateCardWidths_: function() {
189 for (var i = 0, card; card = this.cards_[i]; i++)
190 card.style.width = this.cardWidth_ + 'px';
194 * Returns the index of the current card.
195 * @return {number} index of the current card.
198 return this.currentCard_;
202 * Allows setting the current card index.
203 * @param {number} index A new index to set the current index to.
204 * @return {number} The new index after having been set.
206 set currentCard(index) {
207 return (this.currentCard_ = index);
211 * Returns the number of cards.
212 * @return {number} number of cards.
215 return this.cards_.length;
219 * Returns the current card itself.
220 * @return {!Element} the currently shown card.
222 get currentCardValue() {
223 return this.cards_[this.currentCard_];
227 * Handle horizontal scrolls to flip between pages.
230 onMouseWheel_: function(e) {
231 if (e.wheelDeltaX == 0)
234 // Prevent OS X 10.7+ history swiping on the NTP.
237 // Continuous devices such as an Apple Touchpad or Apple MagicMouse will
238 // send arbitrary delta values. Conversly, standard mousewheels will
239 // send delta values in increments of 120. (There is of course a small
240 // chance we mistake a continuous device for a non-continuous device.
241 // Unfortunately there isn't a better way to do this until real touch
242 // events are available to desktop clients.)
243 var DISCRETE_DELTA = 120;
244 if (e.wheelDeltaX % DISCRETE_DELTA)
245 this.mouseWheelIsContinuous_ = true;
247 if (this.mouseWheelIsContinuous_) {
248 // For continuous devices, detect a page swipe when the accumulated
249 // delta matches a pre-defined threshhold. After changing the page,
250 // ignore wheel events for a short time before repeating this process.
251 if (this.mouseWheelCardSelected_) return;
252 this.mouseWheelScrollAmount_ += e.wheelDeltaX;
253 if (Math.abs(this.mouseWheelScrollAmount_) >= 600) {
254 var pagesToScroll = this.mouseWheelScrollAmount_ > 0 ? 1 : -1;
257 var newCardIndex = this.currentCard + pagesToScroll;
258 newCardIndex = Math.min(this.cards_.length - 1,
259 Math.max(0, newCardIndex));
260 this.selectCard(newCardIndex, true);
261 this.mouseWheelCardSelected_ = true;
264 // For discrete devices, consider each wheel tick a page change.
265 var pagesToScroll = e.wheelDeltaX / DISCRETE_DELTA;
268 var newCardIndex = this.currentCard + pagesToScroll;
269 newCardIndex = Math.min(this.cards_.length - 1,
270 Math.max(0, newCardIndex));
271 this.selectCard(newCardIndex, true);
274 // We got a mouse wheel event, so cancel any pending scroll wheel timeout.
275 if (this.scrollClearTimeout_ != null)
276 clearTimeout(this.scrollClearTimeout_);
277 // If we didn't use up all the scroll, hold onto it for a little bit, but
278 // drop it after a delay.
279 if (this.mouseWheelScrollAmount_ != 0) {
280 this.scrollClearTimeout_ =
281 setTimeout(this.clearMouseWheelScroll_.bind(this), 500);
286 * Resets the amount of horizontal scroll we've seen to 0. See
290 clearMouseWheelScroll_: function() {
291 this.mouseWheelScrollAmount_ = 0;
292 this.mouseWheelCardSelected_ = false;
296 * Selects a new card, ensuring that it is a valid index, transforming the
297 * view and possibly calling the change card callback.
298 * @param {number} newCardIndex Index of card to show.
299 * @param {boolean=} opt_animate If true will animate transition from
300 * current position to new position.
302 selectCard: function(newCardIndex, opt_animate) {
303 var previousCard = this.currentCardValue;
306 !this.cards_[newCardIndex].classList.contains('selected-card');
308 if (isChangingCard) {
309 previousCard.classList.remove('selected-card');
310 // If we have a new card index and it is valid then update the left
311 // position and current card index.
312 this.currentCard_ = newCardIndex;
313 this.currentCardValue.classList.add('selected-card');
316 this.transformToCurrentCard_(opt_animate);
318 if (isChangingCard) {
319 var event = document.createEvent('Event');
320 event.initEvent(CardSlider.EventType.CARD_CHANGED, true, true);
321 event.cardSlider = this;
322 this.container_.dispatchEvent(event);
324 // We also dispatch an event on the cards themselves.
326 cr.dispatchSimpleEvent(previousCard, 'carddeselected',
329 cr.dispatchSimpleEvent(this.currentCardValue, 'cardselected',
335 * Selects a card from the stack. Passes through to selectCard.
336 * @param {Node} newCard The card that should be selected.
337 * @param {boolean=} opt_animate Whether to animate.
339 selectCardByValue: function(newCard, opt_animate) {
340 var i = this.cards_.indexOf(newCard);
342 this.selectCard(i, opt_animate);
346 * Centers the view on the card denoted by this.currentCard. Can either
347 * animate to that card or snap to it.
348 * @param {boolean=} opt_animate If true will animate transition from
349 * current position to new position.
352 transformToCurrentCard_: function(opt_animate) {
353 this.currentLeft_ = -this.cardWidth_ *
354 (isRTL() ? this.cards_.length - this.currentCard - 1 :
357 // Animate to the current card, which will either transition if the
358 // current card is new, or reset the existing card if we didn't drag
359 // enough to change cards.
362 transition = '-webkit-transform ' + CardSlider.TRANSITION_TIME_ +
365 this.container_.style.WebkitTransition = transition;
366 this.translateTo_(this.currentLeft_);
370 * Moves the view to the specified position.
371 * @param {number} x Horizontal position to move to.
374 translateTo_: function(x) {
375 // We use a webkitTransform to slide because this is GPU accelerated on
376 // Chrome and iOS. Once Chrome does GPU acceleration on the position
377 // fixed-layout elements we could simply set the element's position to
378 // fixed and modify 'left' instead.
379 this.container_.style.WebkitTransform = 'translate3d(' + x + 'px, 0, 0)';
382 /* Touch ******************************************************************/
385 * Clear any transition that is in progress and enable dragging for the
387 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
390 onTouchStart_: function(e) {
391 this.container_.style.WebkitTransition = '';
396 * Tell the TouchHandler that dragging is acceptable when the user begins by
397 * scrolling horizontally.
398 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
401 onDragStart_: function(e) {
402 e.enableDrag = Math.abs(e.dragDeltaX) > Math.abs(e.dragDeltaY);
406 * On each drag move event reposition the container appropriately so the
407 * cards look like they are sliding.
408 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
411 onDragMove_: function(e) {
412 var deltaX = e.dragDeltaX;
413 // If dragging beyond the first or last card then apply a backoff so the
414 // dragging feels stickier than usual.
415 if (!this.currentCard && deltaX > 0 ||
416 this.currentCard == (this.cards_.length - 1) && deltaX < 0) {
419 this.translateTo_(this.currentLeft_ + deltaX);
423 * On drag end events we may want to transition to another card, depending
424 * on the ending position of the drag and the velocity of the drag.
425 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
428 onDragEnd_: function(e) {
429 var deltaX = e.dragDeltaX;
430 var velocity = this.touchHandler_.getEndVelocity().x;
431 var newX = this.currentLeft_ + deltaX;
432 var newCardIndex = Math.round(-newX / this.cardWidth_);
434 if (newCardIndex == this.currentCard && Math.abs(velocity) >
435 CardSlider.TRANSITION_VELOCITY_THRESHOLD_) {
436 // If the drag wasn't far enough to change cards but the velocity was
437 // high enough to transition anyways. If the velocity is to the left
438 // (negative) then the user wishes to go right (card +1).
439 newCardIndex += velocity > 0 ? -1 : 1;
442 this.selectCard(newCardIndex, /* animate */ true);
446 * Cancel any current touch/slide as if we saw a touch end
448 cancelTouch: function() {
449 // Stop listening to any current touch
450 this.touchHandler_.cancelTouch();
452 // Ensure we're at a card bounary
453 this.transformToCurrentCard_(true);
458 CardSlider: CardSlider