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