Home | History | Annotate | Download | only in paper-ripple
      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