1 <!-- 2 @license 3 Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 4 This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5 The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6 The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7 Code distributed by Google as part of the polymer project is also 8 subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9 --> 10 11 <link rel="import" href="../polymer/polymer.html"> 12 <link rel="import" href="../iron-a11y-keys-behavior/iron-a11y-keys-behavior.html"> 13 14 <!-- 15 Material design: [Surface reaction](https://www.google.com/design/spec/animation/responsive-interaction.html#responsive-interaction-surface-reaction) 16 17 `paper-ripple` provides a visual effect that other paper elements can 18 use to simulate a rippling effect emanating from the point of contact. The 19 effect can be visualized as a concentric circle with motion. 20 21 Example: 22 23 <div style="position:relative"> 24 <paper-ripple></paper-ripple> 25 </div> 26 27 Note, it's important that the parent container of the ripple be relative position, otherwise 28 the ripple will emanate outside of the desired container. 29 30 `paper-ripple` listens to "mousedown" and "mouseup" events so it would display ripple 31 effect when touches on it. You can also defeat the default behavior and 32 manually route the down and up actions to the ripple element. Note that it is 33 important if you call `downAction()` you will have to make sure to call 34 `upAction()` so that `paper-ripple` would end the animation loop. 35 36 Example: 37 38 <paper-ripple id="ripple" style="pointer-events: none;"></paper-ripple> 39 ... 40 downAction: function(e) { 41 this.$.ripple.downAction({detail: {x: e.x, y: e.y}}); 42 }, 43 upAction: function(e) { 44 this.$.ripple.upAction(); 45 } 46 47 Styling ripple effect: 48 49 Use CSS color property to style the ripple: 50 51 paper-ripple { 52 color: #4285f4; 53 } 54 55 Note that CSS color property is inherited so it is not required to set it on 56 the `paper-ripple` element directly. 57 58 By default, the ripple is centered on the point of contact. Apply the `recenters` 59 attribute to have the ripple grow toward the center of its container. 60 61 <paper-ripple recenters></paper-ripple> 62 63 You can also center the ripple inside its container from the start. 64 65 <paper-ripple center></paper-ripple> 66 67 Apply `circle` class to make the rippling effect within a circle. 68 69 <paper-ripple class="circle"></paper-ripple> 70 71 @group Paper Elements 72 @element paper-ripple 73 @hero hero.svg 74 @demo demo/index.html 75 --> 76 77 <dom-module id="paper-ripple"> 78 79 <template> 80 <style> 81 :host { 82 display: block; 83 position: absolute; 84 border-radius: inherit; 85 overflow: hidden; 86 top: 0; 87 left: 0; 88 right: 0; 89 bottom: 0; 90 91 /* See PolymerElements/paper-behaviors/issues/34. On non-Chrome browsers, 92 * creating a node (with a position:absolute) in the middle of an event 93 * handler "interrupts" that event handler (which happens when the 94 * ripple is created on demand) */ 95 pointer-events: none; 96 } 97 98 :host([animating]) { 99 /* This resolves a rendering issue in Chrome (as of 40) where the 100 ripple is not properly clipped by its parent (which may have 101 rounded corners). See: http://jsbin.com/temexa/4 102 103 Note: We only apply this style conditionally. Otherwise, the browser 104 will create a new compositing layer for every ripple element on the 105 page, and that would be bad. */ 106 -webkit-transform: translate(0, 0); 107 transform: translate3d(0, 0, 0); 108 } 109 110 #background, 111 #waves, 112 .wave-container, 113 .wave { 114 pointer-events: none; 115 position: absolute; 116 top: 0; 117 left: 0; 118 width: 100%; 119 height: 100%; 120 } 121 122 #background, 123 .wave { 124 opacity: 0; 125 } 126 127 #waves, 128 .wave { 129 overflow: hidden; 130 } 131 132 .wave-container, 133 .wave { 134 border-radius: 50%; 135 } 136 137 :host(.circle) #background, 138 :host(.circle) #waves { 139 border-radius: 50%; 140 } 141 142 :host(.circle) .wave-container { 143 overflow: hidden; 144 } 145 </style> 146 147 <div id="background"></div> 148 <div id="waves"></div> 149 </template> 150 </dom-module> 151 <script> 152 (function() { 153 var Utility = { 154 distance: function(x1, y1, x2, y2) { 155 var xDelta = (x1 - x2); 156 var yDelta = (y1 - y2); 157 158 return Math.sqrt(xDelta * xDelta + yDelta * yDelta); 159 }, 160 161 now: window.performance && window.performance.now ? 162 window.performance.now.bind(window.performance) : Date.now 163 }; 164 165 /** 166 * @param {HTMLElement} element 167 * @constructor 168 */ 169 function ElementMetrics(element) { 170 this.element = element; 171 this.width = this.boundingRect.width; 172 this.height = this.boundingRect.height; 173 174 this.size = Math.max(this.width, this.height); 175 } 176 177 ElementMetrics.prototype = { 178 get boundingRect () { 179 return this.element.getBoundingClientRect(); 180 }, 181 182 furthestCornerDistanceFrom: function(x, y) { 183 var topLeft = Utility.distance(x, y, 0, 0); 184 var topRight = Utility.distance(x, y, this.width, 0); 185 var bottomLeft = Utility.distance(x, y, 0, this.height); 186 var bottomRight = Utility.distance(x, y, this.width, this.height); 187 188 return Math.max(topLeft, topRight, bottomLeft, bottomRight); 189 } 190 }; 191 192 /** 193 * @param {HTMLElement} element 194 * @constructor 195 */ 196 function Ripple(element) { 197 this.element = element; 198 this.color = window.getComputedStyle(element).color; 199 200 this.wave = document.createElement('div'); 201 this.waveContainer = document.createElement('div'); 202 this.wave.style.backgroundColor = this.color; 203 this.wave.classList.add('wave'); 204 this.waveContainer.classList.add('wave-container'); 205 Polymer.dom(this.waveContainer).appendChild(this.wave); 206 207 this.resetInteractionState(); 208 } 209 210 Ripple.MAX_RADIUS = 300; 211 212 Ripple.prototype = { 213 get recenters() { 214 return this.element.recenters; 215 }, 216 217 get center() { 218 return this.element.center; 219 }, 220 221 get mouseDownElapsed() { 222 var elapsed; 223 224 if (!this.mouseDownStart) { 225 return 0; 226 } 227 228 elapsed = Utility.now() - this.mouseDownStart; 229 230 if (this.mouseUpStart) { 231 elapsed -= this.mouseUpElapsed; 232 } 233 234 return elapsed; 235 }, 236 237 get mouseUpElapsed() { 238 return this.mouseUpStart ? 239 Utility.now () - this.mouseUpStart : 0; 240 }, 241 242 get mouseDownElapsedSeconds() { 243 return this.mouseDownElapsed / 1000; 244 }, 245 246 get mouseUpElapsedSeconds() { 247 return this.mouseUpElapsed / 1000; 248 }, 249 250 get mouseInteractionSeconds() { 251 return this.mouseDownElapsedSeconds + this.mouseUpElapsedSeconds; 252 }, 253 254 get initialOpacity() { 255 return this.element.initialOpacity; 256 }, 257 258 get opacityDecayVelocity() { 259 return this.element.opacityDecayVelocity; 260 }, 261 262 get radius() { 263 var width2 = this.containerMetrics.width * this.containerMetrics.width; 264 var height2 = this.containerMetrics.height * this.containerMetrics.height; 265 var waveRadius = Math.min( 266 Math.sqrt(width2 + height2), 267 Ripple.MAX_RADIUS 268 ) * 1.1 + 5; 269 270 var duration = 1.1 - 0.2 * (waveRadius / Ripple.MAX_RADIUS); 271 var timeNow = this.mouseInteractionSeconds / duration; 272 var size = waveRadius * (1 - Math.pow(80, -timeNow)); 273 274 return Math.abs(size); 275 }, 276 277 get opacity() { 278 if (!this.mouseUpStart) { 279 return this.initialOpacity; 280 } 281 282 return Math.max( 283 0, 284 this.initialOpacity - this.mouseUpElapsedSeconds * this.opacityDecayVelocity 285 ); 286 }, 287 288 get outerOpacity() { 289 // Linear increase in background opacity, capped at the opacity 290 // of the wavefront (waveOpacity). 291 var outerOpacity = this.mouseUpElapsedSeconds * 0.3; 292 var waveOpacity = this.opacity; 293 294 return Math.max( 295 0, 296 Math.min(outerOpacity, waveOpacity) 297 ); 298 }, 299 300 get isOpacityFullyDecayed() { 301 return this.opacity < 0.01 && 302 this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS); 303 }, 304 305 get isRestingAtMaxRadius() { 306 return this.opacity >= this.initialOpacity && 307 this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS); 308 }, 309 310 get isAnimationComplete() { 311 return this.mouseUpStart ? 312 this.isOpacityFullyDecayed : this.isRestingAtMaxRadius; 313 }, 314 315 get translationFraction() { 316 return Math.min( 317 1, 318 this.radius / this.containerMetrics.size * 2 / Math.sqrt(2) 319 ); 320 }, 321 322 get xNow() { 323 if (this.xEnd) { 324 return this.xStart + this.translationFraction * (this.xEnd - this.xStart); 325 } 326 327 return this.xStart; 328 }, 329 330 get yNow() { 331 if (this.yEnd) { 332 return this.yStart + this.translationFraction * (this.yEnd - this.yStart); 333 } 334 335 return this.yStart; 336 }, 337 338 get isMouseDown() { 339 return this.mouseDownStart && !this.mouseUpStart; 340 }, 341 342 resetInteractionState: function() { 343 this.maxRadius = 0; 344 this.mouseDownStart = 0; 345 this.mouseUpStart = 0; 346 347 this.xStart = 0; 348 this.yStart = 0; 349 this.xEnd = 0; 350 this.yEnd = 0; 351 this.slideDistance = 0; 352 353 this.containerMetrics = new ElementMetrics(this.element); 354 }, 355 356 draw: function() { 357 var scale; 358 var translateString; 359 var dx; 360 var dy; 361 362 this.wave.style.opacity = this.opacity; 363 364 scale = this.radius / (this.containerMetrics.size / 2); 365 dx = this.xNow - (this.containerMetrics.width / 2); 366 dy = this.yNow - (this.containerMetrics.height / 2); 367 368 369 // 2d transform for safari because of border-radius and overflow:hidden clipping bug. 370 // https://bugs.webkit.org/show_bug.cgi?id=98538 371 this.waveContainer.style.webkitTransform = 'translate(' + dx + 'px, ' + dy + 'px)'; 372 this.waveContainer.style.transform = 'translate3d(' + dx + 'px, ' + dy + 'px, 0)'; 373 this.wave.style.webkitTransform = 'scale(' + scale + ',' + scale + ')'; 374 this.wave.style.transform = 'scale3d(' + scale + ',' + scale + ',1)'; 375 }, 376 377 /** @param {Event=} event */ 378 downAction: function(event) { 379 var xCenter = this.containerMetrics.width / 2; 380 var yCenter = this.containerMetrics.height / 2; 381 382 this.resetInteractionState(); 383 this.mouseDownStart = Utility.now(); 384 385 if (this.center) { 386 this.xStart = xCenter; 387 this.yStart = yCenter; 388 this.slideDistance = Utility.distance( 389 this.xStart, this.yStart, this.xEnd, this.yEnd 390 ); 391 } else { 392 this.xStart = event ? 393 event.detail.x - this.containerMetrics.boundingRect.left : 394 this.containerMetrics.width / 2; 395 this.yStart = event ? 396 event.detail.y - this.containerMetrics.boundingRect.top : 397 this.containerMetrics.height / 2; 398 } 399 400 if (this.recenters) { 401 this.xEnd = xCenter; 402 this.yEnd = yCenter; 403 this.slideDistance = Utility.distance( 404 this.xStart, this.yStart, this.xEnd, this.yEnd 405 ); 406 } 407 408 this.maxRadius = this.containerMetrics.furthestCornerDistanceFrom( 409 this.xStart, 410 this.yStart 411 ); 412 413 this.waveContainer.style.top = 414 (this.containerMetrics.height - this.containerMetrics.size) / 2 + 'px'; 415 this.waveContainer.style.left = 416 (this.containerMetrics.width - this.containerMetrics.size) / 2 + 'px'; 417 418 this.waveContainer.style.width = this.containerMetrics.size + 'px'; 419 this.waveContainer.style.height = this.containerMetrics.size + 'px'; 420 }, 421 422 /** @param {Event=} event */ 423 upAction: function(event) { 424 if (!this.isMouseDown) { 425 return; 426 } 427 428 this.mouseUpStart = Utility.now(); 429 }, 430 431 remove: function() { 432 Polymer.dom(this.waveContainer.parentNode).removeChild( 433 this.waveContainer 434 ); 435 } 436 }; 437 438 Polymer({ 439 is: 'paper-ripple', 440 441 behaviors: [ 442 Polymer.IronA11yKeysBehavior 443 ], 444 445 properties: { 446 /** 447 * The initial opacity set on the wave. 448 * 449 * @attribute initialOpacity 450 * @type number 451 * @default 0.25 452 */ 453 initialOpacity: { 454 type: Number, 455 value: 0.25 456 }, 457 458 /** 459 * How fast (opacity per second) the wave fades out. 460 * 461 * @attribute opacityDecayVelocity 462 * @type number 463 * @default 0.8 464 */ 465 opacityDecayVelocity: { 466 type: Number, 467 value: 0.8 468 }, 469 470 /** 471 * If true, ripples will exhibit a gravitational pull towards 472 * the center of their container as they fade away. 473 * 474 * @attribute recenters 475 * @type boolean 476 * @default false 477 */ 478 recenters: { 479 type: Boolean, 480 value: false 481 }, 482 483 /** 484 * If true, ripples will center inside its container 485 * 486 * @attribute recenters 487 * @type boolean 488 * @default false 489 */ 490 center: { 491 type: Boolean, 492 value: false 493 }, 494 495 /** 496 * A list of the visual ripples. 497 * 498 * @attribute ripples 499 * @type Array 500 * @default [] 501 */ 502 ripples: { 503 type: Array, 504 value: function() { 505 return []; 506 } 507 }, 508 509 /** 510 * True when there are visible ripples animating within the 511 * element. 512 */ 513 animating: { 514 type: Boolean, 515 readOnly: true, 516 reflectToAttribute: true, 517 value: false 518 }, 519 520 /** 521 * If true, the ripple will remain in the "down" state until `holdDown` 522 * is set to false again. 523 */ 524 holdDown: { 525 type: Boolean, 526 value: false, 527 observer: '_holdDownChanged' 528 }, 529 530 /** 531 * If true, the ripple will not generate a ripple effect 532 * via pointer interaction. 533 * Calling ripple's imperative api like `simulatedRipple` will 534 * still generate the ripple effect. 535 */ 536 noink: { 537 type: Boolean, 538 value: false 539 }, 540 541 _animating: { 542 type: Boolean 543 }, 544 545 _boundAnimate: { 546 type: Function, 547 value: function() { 548 return this.animate.bind(this); 549 } 550 } 551 }, 552 553 get target () { 554 return this.keyEventTarget; 555 }, 556 557 keyBindings: { 558 'enter:keydown': '_onEnterKeydown', 559 'space:keydown': '_onSpaceKeydown', 560 'space:keyup': '_onSpaceKeyup' 561 }, 562 563 attached: function() { 564 // Set up a11yKeysBehavior to listen to key events on the target, 565 // so that space and enter activate the ripple even if the target doesn't 566 // handle key events. The key handlers deal with `noink` themselves. 567 if (this.parentNode.nodeType == 11) { // DOCUMENT_FRAGMENT_NODE 568 this.keyEventTarget = Polymer.dom(this).getOwnerRoot().host; 569 } else { 570 this.keyEventTarget = this.parentNode; 571 } 572 var keyEventTarget = /** @type {!EventTarget} */ (this.keyEventTarget); 573 this.listen(keyEventTarget, 'up', 'uiUpAction'); 574 this.listen(keyEventTarget, 'down', 'uiDownAction'); 575 }, 576 577 detached: function() { 578 this.unlisten(this.keyEventTarget, 'up', 'uiUpAction'); 579 this.unlisten(this.keyEventTarget, 'down', 'uiDownAction'); 580 this.keyEventTarget = null; 581 }, 582 583 get shouldKeepAnimating () { 584 for (var index = 0; index < this.ripples.length; ++index) { 585 if (!this.ripples[index].isAnimationComplete) { 586 return true; 587 } 588 } 589 590 return false; 591 }, 592 593 simulatedRipple: function() { 594 this.downAction(null); 595 596 // Please see polymer/polymer#1305 597 this.async(function() { 598 this.upAction(); 599 }, 1); 600 }, 601 602 /** 603 * Provokes a ripple down effect via a UI event, 604 * respecting the `noink` property. 605 * @param {Event=} event 606 */ 607 uiDownAction: function(event) { 608 if (!this.noink) { 609 this.downAction(event); 610 } 611 }, 612 613 /** 614 * Provokes a ripple down effect via a UI event, 615 * *not* respecting the `noink` property. 616 * @param {Event=} event 617 */ 618 downAction: function(event) { 619 if (this.holdDown && this.ripples.length > 0) { 620 return; 621 } 622 623 var ripple = this.addRipple(); 624 625 ripple.downAction(event); 626 627 if (!this._animating) { 628 this._animating = true; 629 this.animate(); 630 } 631 }, 632 633 /** 634 * Provokes a ripple up effect via a UI event, 635 * respecting the `noink` property. 636 * @param {Event=} event 637 */ 638 uiUpAction: function(event) { 639 if (!this.noink) { 640 this.upAction(event); 641 } 642 }, 643 644 /** 645 * Provokes a ripple up effect via a UI event, 646 * *not* respecting the `noink` property. 647 * @param {Event=} event 648 */ 649 upAction: function(event) { 650 if (this.holdDown) { 651 return; 652 } 653 654 this.ripples.forEach(function(ripple) { 655 ripple.upAction(event); 656 }); 657 658 this._animating = true; 659 this.animate(); 660 }, 661 662 onAnimationComplete: function() { 663 this._animating = false; 664 this.$.background.style.backgroundColor = null; 665 this.fire('transitionend'); 666 }, 667 668 addRipple: function() { 669 var ripple = new Ripple(this); 670 671 Polymer.dom(this.$.waves).appendChild(ripple.waveContainer); 672 this.$.background.style.backgroundColor = ripple.color; 673 this.ripples.push(ripple); 674 675 this._setAnimating(true); 676 677 return ripple; 678 }, 679 680 removeRipple: function(ripple) { 681 var rippleIndex = this.ripples.indexOf(ripple); 682 683 if (rippleIndex < 0) { 684 return; 685 } 686 687 this.ripples.splice(rippleIndex, 1); 688 689 ripple.remove(); 690 691 if (!this.ripples.length) { 692 this._setAnimating(false); 693 } 694 }, 695 696 animate: function() { 697 if (!this._animating) { 698 return; 699 } 700 var index; 701 var ripple; 702 703 for (index = 0; index < this.ripples.length; ++index) { 704 ripple = this.ripples[index]; 705 706 ripple.draw(); 707 708 this.$.background.style.opacity = ripple.outerOpacity; 709 710 if (ripple.isOpacityFullyDecayed && !ripple.isRestingAtMaxRadius) { 711 this.removeRipple(ripple); 712 } 713 } 714 715 if (!this.shouldKeepAnimating && this.ripples.length === 0) { 716 this.onAnimationComplete(); 717 } else { 718 window.requestAnimationFrame(this._boundAnimate); 719 } 720 }, 721 722 _onEnterKeydown: function() { 723 this.uiDownAction(); 724 this.async(this.uiUpAction, 1); 725 }, 726 727 _onSpaceKeydown: function() { 728 this.uiDownAction(); 729 }, 730 731 _onSpaceKeyup: function() { 732 this.uiUpAction(); 733 }, 734 735 // note: holdDown does not respect noink since it can be a focus based 736 // effect. 737 _holdDownChanged: function(newVal, oldVal) { 738 if (oldVal === undefined) { 739 return; 740 } 741 if (newVal) { 742 this.downAction(); 743 } else { 744 this.upAction(); 745 } 746 } 747 748 /** 749 Fired when the animation finishes. 750 This is useful if you want to wait until 751 the ripple animation finishes to perform some action. 752 753 @event transitionend 754 @param {{node: Object}} detail Contains the animated node. 755 */ 756 }); 757 })(); 758 </script> 759