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. 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// Use an anonymous function to enable strict mode just for this file (which 25// will be concatenated with other files when embedded in Chrome 26cr.define('cr.ui', function() { 27 'use strict'; 28 29 /** 30 * @constructor 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. 35 */ 36 function CardSlider(frame, container, cardWidth) { 37 /** 38 * @type {!Element} 39 * @private 40 */ 41 this.frame_ = frame; 42 43 /** 44 * @type {!Element} 45 * @private 46 */ 47 this.container_ = container; 48 49 /** 50 * Array of card elements. 51 * @type {!Array.<!Element>} 52 * @private 53 */ 54 this.cards_ = []; 55 56 /** 57 * Index of currently shown card. 58 * @type {number} 59 * @private 60 */ 61 this.currentCard_ = -1; 62 63 /** 64 * @type {number} 65 * @private 66 */ 67 this.cardWidth_ = cardWidth; 68 69 /** 70 * @type {!cr.ui.TouchHandler} 71 * @private 72 */ 73 this.touchHandler_ = new cr.ui.TouchHandler(this.container_); 74 } 75 76 77 /** 78 * The time to transition between cards when animating. Measured in ms. 79 * @type {number} 80 * @private 81 * @const 82 */ 83 CardSlider.TRANSITION_TIME_ = 200; 84 85 86 /** 87 * The minimum velocity required to transition cards if they did not drag past 88 * the halfway point between cards. Measured in pixels / ms. 89 * @type {number} 90 * @private 91 * @const 92 */ 93 CardSlider.TRANSITION_VELOCITY_THRESHOLD_ = 0.2; 94 95 96 CardSlider.prototype = { 97 /** 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. 101 * @type {number} 102 * @private 103 */ 104 currentLeft_: 0, 105 106 /** 107 * Current offset relative to |currentLeft_| due to an active drag 108 * operation. 109 * @type {number} 110 * @private 111 */ 112 deltaX_: 0, 113 114 /** 115 * Initialize all elements and event handlers. Must call after construction 116 * and before usage. 117 * @param {boolean} ignoreMouseWheelEvents If true, horizontal mouse wheel 118 * events will be ignored, rather than flipping between pages. 119 */ 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.'); 128 129 this.updateCardWidths_(); 130 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)); 138 } 139 this.container_.addEventListener( 140 'webkitTransitionEnd', this.onWebkitTransitionEnd_.bind(this)); 141 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)); 155 156 this.touchHandler_.enable(/* opt_capture */ false); 157 }, 158 159 /** 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. 164 */ 165 resize: function(newCardWidth) { 166 if (newCardWidth != this.cardWidth_) { 167 this.cardWidth_ = newCardWidth; 168 169 this.updateCardWidths_(); 170 171 // Must upate the transform on the container to show the correct card. 172 this.transformToCurrentCard_(); 173 } 174 }, 175 176 /** 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 180 * navigate to. 181 */ 182 setCards: function(cards, index) { 183 assert(index >= 0 && index < cards.length, 184 'Invalid index in CardSlider#setCards'); 185 this.cards_ = cards; 186 187 this.updateCardWidths_(); 188 this.updateSelectedCardAttributes_(); 189 190 // Jump to the given card index. 191 this.selectCard(index, false, false, true); 192 }, 193 194 /** 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. 200 * @private 201 */ 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'); 207 } else { 208 this.cards_[i].classList.remove('selected-card'); 209 this.cards_[i].setAttribute('aria-hidden', true); 210 } 211 } 212 }, 213 214 /** 215 * Updates the width of each card. 216 * @private 217 */ 218 updateCardWidths_: function() { 219 for (var i = 0, card; card = this.cards_[i]; i++) 220 card.style.width = this.cardWidth_ + 'px'; 221 }, 222 223 /** 224 * Returns the index of the current card. 225 * @return {number} index of the current card. 226 */ 227 get currentCard() { 228 return this.currentCard_; 229 }, 230 231 /** 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. 235 */ 236 set currentCard(index) { 237 return (this.currentCard_ = index); 238 }, 239 240 /** 241 * Returns the number of cards. 242 * @return {number} number of cards. 243 */ 244 get cardCount() { 245 return this.cards_.length; 246 }, 247 248 /** 249 * Returns the current card itself. 250 * @return {!Element} the currently shown card. 251 */ 252 get currentCardValue() { 253 return this.cards_[this.currentCard_]; 254 }, 255 256 /** 257 * Returns the frame holding the cards. 258 * @return {Element} The frame used to position the cards. 259 */ 260 get frame() { 261 return this.frame_; 262 }, 263 264 /** 265 * Handle horizontal scrolls to flip between pages. 266 * @private 267 */ 268 onMouseWheel_: function(e) { 269 if (e.wheelDeltaX == 0) 270 return; 271 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; 281 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; 290 if (!isRTL()) 291 pagesToScroll *= -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; 297 } 298 } else { 299 // For discrete devices, consider each wheel tick a page change. 300 var pagesToScroll = e.wheelDeltaX / DISCRETE_DELTA; 301 if (!isRTL()) 302 pagesToScroll *= -1; 303 var newCardIndex = this.currentCard + pagesToScroll; 304 newCardIndex = Math.min(this.cards_.length - 1, 305 Math.max(0, newCardIndex)); 306 this.selectCard(newCardIndex, true); 307 } 308 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); 317 } 318 }, 319 320 /** 321 * Resets the amount of horizontal scroll we've seen to 0. See 322 * onMouseWheel_. 323 * @private 324 */ 325 clearMouseWheelScroll_: function() { 326 this.mouseWheelScrollAmount_ = 0; 327 this.mouseWheelCardSelected_ = false; 328 }, 329 330 /** 331 * Handles the ends of -webkit-transitions on -webkit-transform (animated 332 * card switches). 333 * @param {Event} e The webkitTransitionEnd event. 334 * @private 335 */ 336 onWebkitTransitionEnd_: function(e) { 337 // Ignore irrelevant transitions that might bubble up. 338 if (e.target !== this.container_ || 339 e.propertyName != '-webkit-transform') { 340 return; 341 } 342 this.fireChangeEndedEvent_(true); 343 }, 344 345 /** 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. 349 * @private 350 */ 351 fireChangeEndedEvent_: function(wasAnimated) { 352 var e = document.createEvent('Event'); 353 e.initEvent('cardSlider:card_change_ended', true, true); 354 e.cardSlider = this; 355 e.changedTo = this.currentCard_; 356 e.wasAnimated = wasAnimated; 357 this.container_.dispatchEvent(e); 358 }, 359 360 /** 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. 367 */ 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)); 373 374 this.updateSelectedCardAttributes_(); 375 376 if (this.currentCard_ == -1) 377 this.currentCard_ = 0; 378 else if (index <= this.currentCard_) 379 this.selectCard(this.currentCard_ + 1, false, true, true); 380 381 this.fireAddedEvent_(card, index); 382 }, 383 384 /** 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. 387 */ 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); 392 }, 393 394 /** 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. 399 * @private 400 */ 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; 406 e.addedCard = card; 407 this.container_.dispatchEvent(e); 408 }, 409 410 /** 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. 414 */ 415 getCardAtIndex: function(index) { 416 this.assertValidIndex_(index); 417 return this.cards_[index]; 418 }, 419 420 /** 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. 425 */ 426 removeCard: function(card) { 427 assert(card instanceof Node, '|card| isn\'t a Node'); 428 this.removeCardAtIndex(this.cards_.indexOf(card)); 429 }, 430 431 /** 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. 436 */ 437 removeCardAtIndex: function(index) { 438 this.assertValidIndex_(index); 439 var removed = this.cards_.splice(index, 1).pop(); 440 441 if (this.cards_.length == 0) 442 this.currentCard_ = -1; 443 else if (index < this.currentCard_) 444 this.selectCard(this.currentCard_ - 1, false, true); 445 446 this.fireRemovedEvent_(removed, index); 447 }, 448 449 /** 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. 454 * @private 455 */ 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); 462 }, 463 464 /** 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. 468 */ 469 repositionFrame: function() { 470 this.transformToCurrentCard_(); 471 }, 472 473 /** 474 * Checks the the given |index| exists in this.cards_. 475 * @param {number} index An index to check. 476 * @private 477 */ 478 assertValidIndex_: function(index) { 479 assert(index >= 0 && index < this.cards_.length); 480 }, 481 482 /** 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 491 * selected. 492 */ 493 selectCard: function(newCardIndex, 494 opt_animate, 495 opt_dontNotify, 496 opt_forceChange) { 497 this.assertValidIndex_(newCardIndex); 498 499 var previousCard = this.currentCardValue; 500 var isChangingCard = 501 !this.cards_[newCardIndex].classList.contains('selected-card'); 502 503 if (typeof opt_forceChange != 'undefined' && opt_forceChange) 504 isChangingCard = true; 505 506 if (isChangingCard) { 507 this.currentCard_ = newCardIndex; 508 this.updateSelectedCardAttributes_(); 509 } 510 511 var willTransitionHappen = this.transformToCurrentCard_(opt_animate); 512 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); 519 520 // We also dispatch an event on the cards themselves. 521 if (previousCard) { 522 cr.dispatchSimpleEvent(previousCard, 'carddeselected', 523 true, true); 524 } 525 cr.dispatchSimpleEvent(this.currentCardValue, 'cardselected', 526 true, true); 527 } 528 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) && 532 !opt_dontNotify) { 533 this.fireChangeEndedEvent_(false); 534 } 535 }, 536 537 /** 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. 541 */ 542 selectCardByValue: function(newCard, opt_animate) { 543 var i = this.cards_.indexOf(newCard); 544 assert(i != -1); 545 this.selectCard(i, opt_animate); 546 }, 547 548 /** 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. 554 * @private 555 */ 556 transformToCurrentCard_: function(opt_animate) { 557 var prevLeft = this.currentLeft_; 558 this.currentLeft_ = -this.cardWidth_ * 559 (isRTL() ? this.cards_.length - this.currentCard - 1 : 560 this.currentCard); 561 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) 565 return false; 566 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. 570 var transition = ''; 571 if (opt_animate) { 572 transition = '-webkit-transform ' + CardSlider.TRANSITION_TIME_ + 573 'ms ease-in-out'; 574 } 575 this.container_.style.WebkitTransition = transition; 576 this.translateTo_(this.currentLeft_); 577 578 return true; 579 }, 580 581 /** 582 * Moves the view to the specified position. 583 * @param {number} x Horizontal position to move to. 584 * @private 585 */ 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)'; 593 }, 594 595 /* Touch ******************************************************************/ 596 597 /** 598 * Clear any transition that is in progress and enable dragging for the 599 * touch. 600 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. 601 * @private 602 */ 603 onTouchStart_: function(e) { 604 this.container_.style.WebkitTransition = ''; 605 e.enableDrag = true; 606 }, 607 608 /** 609 * Tell the TouchHandler that dragging is acceptable when the user begins by 610 * scrolling horizontally and there is more than one card to slide. 611 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. 612 * @private 613 */ 614 onDragStart_: function(e) { 615 e.enableDrag = this.cardCount > 1 && Math.abs(e.dragDeltaX) > 616 Math.abs(e.dragDeltaY); 617 }, 618 619 /** 620 * On each drag move event reposition the container appropriately so the 621 * cards look like they are sliding. 622 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. 623 * @private 624 */ 625 onDragMove_: function(e) { 626 var deltaX = e.dragDeltaX; 627 // If dragging beyond the first or last card then apply a backoff so the 628 // dragging feels stickier than usual. 629 if (!this.currentCard && deltaX > 0 || 630 this.currentCard == (this.cards_.length - 1) && deltaX < 0) { 631 deltaX /= 2; 632 } 633 this.translateTo_(this.currentLeft_ + deltaX); 634 }, 635 636 /** 637 * On drag end events we may want to transition to another card, depending 638 * on the ending position of the drag and the velocity of the drag. 639 * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event. 640 * @private 641 */ 642 onDragEnd_: function(e) { 643 var deltaX = e.dragDeltaX; 644 var velocity = this.touchHandler_.getEndVelocity().x; 645 var newX = this.currentLeft_ + deltaX; 646 var newCardIndex = Math.round(-newX / this.cardWidth_); 647 648 if (newCardIndex == this.currentCard && Math.abs(velocity) > 649 CardSlider.TRANSITION_VELOCITY_THRESHOLD_) { 650 // The drag wasn't far enough to change cards but the velocity was 651 // high enough to transition anyways. If the velocity is to the left 652 // (negative) then the user wishes to go right (card + 1). 653 newCardIndex += velocity > 0 ? -1 : 1; 654 } 655 // Ensure that the new card index is valid. The new card index could be 656 // invalid if a swipe suggests scrolling off the end of the list of 657 // cards. 658 if (newCardIndex < 0) 659 newCardIndex = 0; 660 else if (newCardIndex >= this.cardCount) 661 newCardIndex = this.cardCount - 1; 662 this.selectCard(newCardIndex, /* animate */ true); 663 }, 664 665 /** 666 * Cancel any current touch/slide as if we saw a touch end 667 */ 668 cancelTouch: function() { 669 // Stop listening to any current touch 670 this.touchHandler_.cancelTouch(); 671 672 // Ensure we're at a card bounary 673 this.transformToCurrentCard_(true); 674 }, 675 }; 676 677 return { 678 CardSlider: CardSlider 679 }; 680}); 681