From 88ca86996f51a32e2270e24ba0467eb7b61f5969 Mon Sep 17 00:00:00 2001 From: Robert Messerle Date: Tue, 28 Jul 2015 14:49:00 -0700 Subject: [PATCH] refactor(ripple): rewrite of the ripple service using manual animation and providing more control over the ripples states adds support for centered ripples adds support for theme colors adds ripple directive, adds support for mdNoInk, adds support for dimBackground, adds support for fitRipple Ripples now respect a minimum timeout value --- src/components/button/button-theme.scss | 11 - src/components/button/button.scss | 21 +- src/components/button/button.spec.js | 2 +- src/core/services/ripple/ripple.js | 592 ++++++++++-------------- src/core/style/structure.scss | 11 +- 5 files changed, 270 insertions(+), 367 deletions(-) diff --git a/src/components/button/button-theme.scss b/src/components/button/button-theme.scss index 2dfbb818d3e..4a21ced5a7e 100644 --- a/src/components/button/button-theme.scss +++ b/src/components/button/button-theme.scss @@ -1,10 +1,5 @@ -$button-border-radius: 3px !default; -$button-fab-border-radius: 50% !default; -$button-icon-border-radius: $button-fab-border-radius; - a.md-button.md-THEME_NAME-theme, .md-button.md-THEME_NAME-theme { - border-radius: $button-border-radius; &:not([disabled]) { &:hover { @@ -19,7 +14,6 @@ a.md-button.md-THEME_NAME-theme, } &.md-fab { - border-radius: $button-fab-border-radius; background-color: '{{accent-color}}'; color: '{{accent-contrast}}'; md-icon { @@ -35,10 +29,6 @@ a.md-button.md-THEME_NAME-theme, } } - &.md-icon-button { - border-radius: $button-icon-border-radius; - } - &.md-primary { color: '{{primary-color}}'; &.md-raised, @@ -64,7 +54,6 @@ a.md-button.md-THEME_NAME-theme, } } &.md-fab { - border-radius: $button-fab-border-radius; background-color: '{{accent-color}}'; color: '{{accent-contrast}}'; &:not([disabled]) { diff --git a/src/components/button/button.scss b/src/components/button/button.scss index 28127c7affd..918a63564ec 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -1,3 +1,7 @@ +$button-border-radius: 3px !default; +$button-fab-border-radius: 50% !default; +$button-icon-border-radius: $button-fab-border-radius; + $button-line-height: rem(3.60) !default; $button-padding: 0 rem(0.600) !default; $button-margin: rem(0.600) rem(0.800) !default; @@ -16,6 +20,7 @@ $icon-button-width: rem(4.800) !default; $icon-button-margin: rem(0.600) !default; .md-button { + border-radius: $button-border-radius; box-sizing: border-box; color: currentColor; @@ -92,9 +97,9 @@ $icon-button-margin: rem(0.600) !default; padding-left: 0; padding-right: 0; width: $icon-button-width; - border-radius: 50%; + border-radius: $button-icon-border-radius; .md-ripple-container { - border-radius: 50%; + border-radius: $button-icon-border-radius; background-clip: padding-box; overflow: hidden; // The following hack causes Safari/Chrome to respect overflow hidden for ripples @@ -116,14 +121,14 @@ $icon-button-margin: rem(0.600) !default; vertical-align: middle; @include md-shadow-bottom-z-1(); - border-radius: 50%; + border-radius: $button-fab-border-radius; background-clip: padding-box; overflow: hidden; transition: 0.2s linear; transition-property: background-color, box-shadow; .md-ripple-container { - border-radius: 50%; + border-radius: $button-fab-border-radius; background-clip: padding-box; overflow: hidden; // The following hack causes Safari/Chrome to respect overflow hidden for ripples @@ -149,6 +154,14 @@ $icon-button-margin: rem(0.600) !default; } } } + + .md-ripple-container { + border-radius: $button-border-radius; + background-clip: padding-box; + overflow: hidden; + // The following hack causes Safari/Chrome to respect overflow hidden for ripples + -webkit-mask-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA5JREFUeNpiYGBgAAgwAAAEAAGbA+oJAAAAAElFTkSuQmCC'); + } } .md-toast-open-top { diff --git a/src/components/button/button.spec.js b/src/components/button/button.spec.js index 9d23a8b857c..c23b8cfa14e 100644 --- a/src/components/button/button.spec.js +++ b/src/components/button/button.spec.js @@ -13,7 +13,7 @@ describe('md-button', function() { var button = $compile('button')($rootScope); button.triggerHandler({ type: '$md.pressdown', pointer: { x: 0, y: 0 } }); - expect(button[0].getElementsByClassName('md-ripple-container').length).toBe(1); + expect(button[0].getElementsByClassName('md-ripple-container').length).toBe(0); })); diff --git a/src/core/services/ripple/ripple.js b/src/core/services/ripple/ripple.js index fa17626d9b5..d105d3b5ad5 100644 --- a/src/core/services/ripple/ripple.js +++ b/src/core/services/ripple/ripple.js @@ -1,382 +1,286 @@ angular.module('material.core') - .factory('$mdInkRipple', InkRippleService) - .directive('mdInkRipple', InkRippleDirective) - .directive('mdNoInk', attrNoDirective()) - .directive('mdNoBar', attrNoDirective()) - .directive('mdNoStretch', attrNoDirective()); + .factory('$mdInkRipple', InkRippleService) + .directive('mdInkRipple', InkRippleDirective) + .directive('mdNoInk', attrNoDirective) + .directive('mdNoBar', attrNoDirective) + .directive('mdNoStretch', attrNoDirective); -function InkRippleDirective($mdButtonInkRipple, $mdCheckboxInkRipple) { +var DURATION = 450; + +/** + * Directive used to add ripples to any element + * @ngInject + */ +function InkRippleDirective ($mdButtonInkRipple, $mdCheckboxInkRipple) { return { controller: angular.noop, - link: function (scope, element, attr) { - if (attr.hasOwnProperty('mdInkRippleCheckbox')) { - $mdCheckboxInkRipple.attach(scope, element); - } else { - $mdButtonInkRipple.attach(scope, element); - } + link: function (scope, element, attr) { + attr.hasOwnProperty('mdInkRippleCheckbox') + ? $mdCheckboxInkRipple.attach(scope, element) + : $mdButtonInkRipple.attach(scope, element); } }; } -function InkRippleService($window, $timeout, $mdUtil) { - - return { - attach: attach - }; - - function attach(scope, element, options) { +/** + * Service for adding ripples to any element + * @ngInject + */ +function InkRippleService ($injector) { + return { attach: attach }; + function attach (scope, element, options) { if (element.controller('mdNoInk')) return angular.noop; + return $injector.instantiate(InkRippleCtrl, { + $scope: scope, + $element: element, + rippleOptions: options + }); + } +} - options = angular.extend({ - colorElement: element, - mousedown: true, - hover: true, - focus: true, - center: false, - mousedownPauseTime: 150, - dimBackground: false, - outline: false, - fullRipple: true, - isMenuItem: false, - fitRipple: false - }, options); - - var rippleSize, - controller = element.controller('mdInkRipple') || {}, - counter = 0, - ripples = [], - states = [], - isActiveExpr = element.attr('md-highlight'), - isActive = false, - isHeld = false, - node = element[0], - rippleSizeSetting = element.attr('md-ripple-size'), - color = parseColor(element.attr('md-ink-ripple')) || parseColor(options.colorElement.length && $window.getComputedStyle(options.colorElement[0]).color || 'rgb(0, 0, 0)'); - - switch (rippleSizeSetting) { - case 'full': - options.fullRipple = true; - break; - case 'partial': - options.fullRipple = false; - break; - } - - // expose onInput for ripple testing - if (options.mousedown) { - element.on('$md.pressdown', onPressDown) - .on('$md.pressup', onPressUp); - } - - controller.createRipple = createRipple; +/** + * Controller used by the ripple service in order to apply ripples + * @ngInject + */ +function InkRippleCtrl ($scope, $element, rippleOptions, $window, $timeout, $mdUtil) { + this.$window = $window; + this.$timeout = $timeout; + this.$mdUtil = $mdUtil; + this.$scope = $scope; + this.$element = $element; + this.options = rippleOptions; + this.mousedown = false; + this.ripples = []; + this.container = null; + this.color = null; + this.background = null; + this.timeout = null; // Stores a reference to the most-recent ripple timeout + this.lastRipple = null; + + // attach method for unit tests + ($element.controller('mdInkRipple') || {}).createRipple = angular.bind(this, this.createRipple); + + this.bindEvents(); +} - if (isActiveExpr) { - scope.$watch(isActiveExpr, function watchActive(newValue) { - isActive = newValue; - if (isActive && !ripples.length) { - $mdUtil.nextTick(function () { createRipple(0, 0); }); - } - angular.forEach(ripples, updateElement); - }); - } +/** + * Returns the color that the ripple should be (either based on CSS or hard-coded) + * @returns {string} + */ +InkRippleCtrl.prototype.getColor = function (multiplier) { + multiplier = multiplier || 1; + return parseColor(this.$element.attr('md-ink-ripple')) + || parseColor(getElementColor.call(this)); + + /** + * Finds the color element and returns its text color for use as default ripple color + * @returns {string} + */ + function getElementColor () { + var colorElement = this.options.colorElement && this.options.colorElement[ 0 ]; + colorElement = colorElement || this.$element[ 0 ]; + return colorElement ? this.$window.getComputedStyle(colorElement).color : 'rgb(0,0,0)'; + } - // Publish self-detach method if desired... - return function detach() { - element.off('$md.pressdown', onPressDown) - .off('$md.pressup', onPressUp); - getRippleContainer().remove(); - }; + /** + * Takes a string color and converts it to RGBA format + * @param color {string} + * @returns {string} + */ + function parseColor (color) { + if (!color) return; + if (color.indexOf('rgba') === 0) return color.replace(/\d?\.?\d*\s*\)\s*$/, (0.1 * multiplier).toString() + ')'); + if (color.indexOf('rgb') === 0) return rgbToRGBA(color); + if (color.indexOf('#') === 0) return hexToRGBA(color); /** - * Gets the current ripple container - * If there is no ripple container, it creates one and returns it - * - * @returns {angular.element} ripple container element + * Converts hex value to RGBA string + * @param color {string} + * @returns {string} */ - function getRippleContainer() { - var container = element.data('$mdRippleContainer'); - if (container) return container; - container = angular.element('
'); - element.append(container); - element.data('$mdRippleContainer', container); - return container; - } - - function parseColor(color) { - if (!color) return; - if (color.indexOf('rgba') === 0) return color.replace(/\d?\.?\d*\s*\)\s*$/, '0.1)'); - if (color.indexOf('rgb') === 0) return rgbToRGBA(color); - if (color.indexOf('#') === 0) return hexToRGBA(color); - - /** - * Converts a hex value to an rgba string - * - * @param {string} hex value (3 or 6 digits) to be converted - * - * @returns {string} rgba color with 0.1 alpha - */ - function hexToRGBA(color) { - var hex = color.charAt(0) === '#' ? color.substr(1) : color, - dig = hex.length / 3, - red = hex.substr(0, dig), - grn = hex.substr(dig, dig), - blu = hex.substr(dig * 2); - if (dig === 1) { - red += red; - grn += grn; - blu += blu; - } - return 'rgba(' + parseInt(red, 16) + ',' + parseInt(grn, 16) + ',' + parseInt(blu, 16) + ',0.1)'; - } - - /** - * Converts rgb value to rgba string - * - * @param {string} rgb color string - * - * @returns {string} rgba color with 0.1 alpha - */ - function rgbToRGBA(color) { - return color.replace(')', ', 0.1)').replace('(', 'a('); - } - - } - - function removeElement(elem, wait) { - ripples.splice(ripples.indexOf(elem), 1); - if (ripples.length === 0) { - getRippleContainer().css({ backgroundColor: '' }); - } - $timeout(function () { elem.remove(); }, wait, false); - } - - function updateElement(elem) { - var index = ripples.indexOf(elem), - state = states[index] || {}, - elemIsActive = ripples.length > 1 ? false : isActive, - elemIsHeld = ripples.length > 1 ? false : isHeld; - if (elemIsActive || state.animating || elemIsHeld) { - elem.addClass('md-ripple-visible'); - } else if (elem) { - elem.removeClass('md-ripple-visible'); - if (options.outline) { - elem.css({ - width: rippleSize + 'px', - height: rippleSize + 'px', - marginLeft: (rippleSize * -1) + 'px', - marginTop: (rippleSize * -1) + 'px' - }); - } - removeElement(elem, options.outline ? 450 : 650); + function hexToRGBA (color) { + var hex = color[ 0 ] === '#' ? color.substr(1) : color, + dig = hex.length / 3, + red = hex.substr(0, dig), + green = hex.substr(dig, dig), + blue = hex.substr(dig * 2); + if (dig === 1) { + red += red; + green += green; + blue += blue; } + return 'rgba(' + parseInt(red, 16) + ',' + parseInt(green, 16) + ',' + parseInt(blue, 16) + ',0.1)'; } /** - * Creates a ripple at the provided coordinates - * - * @param {number} left cursor position - * @param {number} top cursor position - * - * @returns {angular.element} the generated ripple element + * Converts an RGB color to RGBA + * @param color {string} + * @returns {string} */ - function createRipple(left, top) { - - color = parseColor(element.attr('md-ink-ripple')) || parseColor($window.getComputedStyle(options.colorElement[0]).color || 'rgb(0, 0, 0)'); - - var container = getRippleContainer(), - size = getRippleSize(left, top), - css = getRippleCss(size, left, top), - elem = getRippleElement(css), - index = ripples.indexOf(elem), - state = states[index] || {}; - - rippleSize = size; - - state.animating = true; + function rgbToRGBA (color) { + return color.replace(')', ', 0.1)').replace('(', 'a('); + } - $mdUtil.nextTick(function () { - if (options.dimBackground) { - container.css({ backgroundColor: color }); - } - elem.addClass('md-ripple-placed md-ripple-scaled'); - if (options.outline) { - elem.css({ - borderWidth: (size * 0.5) + 'px', - marginLeft: (size * -0.5) + 'px', - marginTop: (size * -0.5) + 'px' - }); - } else { - elem.css({ left: '50%', top: '50%' }); - } - updateElement(elem); - $timeout(function () { - state.animating = false; - updateElement(elem); - }, (options.outline ? 450 : 225), false); - }); + } - return elem; +}; - /** - * Creates the ripple element with the provided css - * - * @param {object} css properties to be applied - * - * @returns {angular.element} the generated ripple element - */ - function getRippleElement(css) { - var elem = angular.element('
'); - ripples.unshift(elem); - states.unshift({ animating: true }); - container.append(elem); - css && elem.css(css); - return elem; - } +/** + * Binds events to the root element for + */ +InkRippleCtrl.prototype.bindEvents = function () { + this.$element.on('mousedown', angular.bind(this, this.handleMousedown)); + this.$element.on('mouseup', angular.bind(this, this.handleMouseup)); + this.$element.on('mouseleave', angular.bind(this, this.handleMouseup)); +}; - /** - * Calculate the ripple size - * - * @returns {number} calculated ripple diameter - */ - function getRippleSize(left, top) { - var width = container.prop('offsetWidth'), - height = container.prop('offsetHeight'), - multiplier, size, rect; - if (options.isMenuItem) { - size = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); - } else if (options.outline) { - rect = node.getBoundingClientRect(); - left -= rect.left; - top -= rect.top; - width = Math.max(left, width - left); - height = Math.max(top, height - top); - size = 2 * Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); - } else { - multiplier = options.fullRipple ? 1.1 : 0.8; - size = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)) * multiplier; - if (options.fitRipple) { - size = Math.min(height, width, size); - } - } - return size; - } +/** + * Create a new ripple on every mousedown event from the root element + * @param event {MouseEvent} + */ +InkRippleCtrl.prototype.handleMousedown = function (event) { + this.mousedown = true; + if (this.options.center) { + this.createRipple(this.container.prop('clientWidth') / 2, this.container.prop('clientWidth') / 2); + } else { + this.createRipple(event.layerX, event.layerY); + } - /** - * Generates the ripple css - * - * @param {number} the diameter of the ripple - * @param {number} the left cursor offset - * @param {number} the top cursor offset - * - * @returns {{backgroundColor: string, borderColor: string, width: string, height: string}} - */ - function getRippleCss(size, left, top) { - var rect = node.getBoundingClientRect(), - css = { - backgroundColor: rgbaToRGB(color), - borderColor: rgbaToRGB(color), - width: size + 'px', - height: size + 'px' - }; +}; - if (options.outline) { - css.width = 0; - css.height = 0; - } else { - css.marginLeft = css.marginTop = (size * -0.5) + 'px'; - } +/** + * Either remove or unlock any remaining ripples when the user mouses off of the element (either by + * mouseup or mouseleave event) + */ +InkRippleCtrl.prototype.handleMouseup = function () { + var ctrl = this; + this.mousedown = false; + this.$mdUtil.nextTick(function () { ctrl.clearRipples(); }, false); +}; - if (options.center) { - css.left = css.top = '50%'; - } else { - css.left = Math.round((left - rect.left) / container.prop('offsetWidth') * 100) + '%'; - css.top = Math.round((top - rect.top) / container.prop('offsetHeight') * 100) + '%'; - } +/** + * Cycles through all ripples and attempts to remove them. + * Depending on logic within `fadeInComplete`, some removals will be postponed. + */ +InkRippleCtrl.prototype.clearRipples = function () { + for (var i = 0; i < this.ripples.length; i++) this.fadeInComplete(this.ripples[ i ]); +}; - return css; +/** + * Creates the ripple container element + * @returns {*} + */ +InkRippleCtrl.prototype.createContainer = function () { + var container = angular.element('
'); + this.$element.append(container); + return container; +}; + +InkRippleCtrl.prototype.clearTimeout = function () { + if (this.timeout) { + this.$timeout.cancel(this.timeout); + this.timeout = null; + } +}; - /** - * Converts rgba string to rgb, removing the alpha value - * - * @param {string} rgba color - * - * @returns {string} rgb color - */ - function rgbaToRGB(color) { - return color.replace('rgba', 'rgb').replace(/,[^\),]+\)/, ')'); - } - } - } +/** + * Creates a new ripple and adds it to the container. Also tracks ripple in `this.ripples`. + * @param left + * @param top + */ +InkRippleCtrl.prototype.createRipple = function (left, top) { + if (!this.container) this.container = this.createContainer(); + if (!this.color) this.color = this.getColor(); + if (!this.background) this.background = this.getColor(0.5); + + var ctrl = this; + var ripple = angular.element('
'); + var width = this.$element.prop('clientWidth'); + var height = this.$element.prop('clientHeight'); + var x = Math.max(Math.abs(width - left), left) * 2; + var y = Math.max(Math.abs(height - top), top) * 2; + var size = getSize(this.options.fitRipple, x, y); + + ripple.css({ + left: left + 'px', + top: top + 'px', + background: 'black', + width: size + 'px', + height: size + 'px', + backgroundColor: rgbaToRGB(this.color), + borderColor: rgbaToRGB(this.color) + }); + this.lastRipple = ripple; + + // we only want one timeout to be running at a time + this.clearTimeout(); + this.timeout = this.$timeout(function () { + ctrl.clearTimeout(); + if (!ctrl.mousedown) ctrl.fadeInComplete(ripple); + }, DURATION * 0.35, false); + + if (this.options.dimBackground) this.container.css({ backgroundColor: this.background }); + this.container.append(ripple); + this.ripples.push(ripple); + ripple.addClass('md-ripple-placed'); + this.$mdUtil.nextTick(function () { + ripple.addClass('md-ripple-scaled md-ripple-active'); + ctrl.$timeout(function () { ctrl.clearRipples(); }, DURATION, false); + }, false); + + function rgbaToRGB (color) { + return color + ? color.replace('rgba', 'rgb').replace(/,[^\),]+\)/, ')') + : 'rgb(0,0,0)'; + } - /** - * Handles user input start and stop events - * - */ - function onPressDown(ev) { - if (!isRippleAllowed()) return; + function getSize (fit, x, y) { + return fit + ? Math.max(x, y) + : Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + } +}; - createRipple(ev.pointer.x, ev.pointer.y); - isHeld = true; - } - function onPressUp() { - isHeld = false; - var ripple = ripples[ ripples.length - 1 ]; - $mdUtil.nextTick(function () { updateElement(ripple); }); - } +/** + * Either kicks off the fade-out animation or queues the element for removal on mouseup + * @param ripple + */ +InkRippleCtrl.prototype.fadeInComplete = function (ripple) { + if (this.lastRipple === ripple) { + if (!this.timeout && !this.mousedown) this.removeRipple(ripple); + } else { + this.removeRipple(ripple); + } +}; - /** - * Determines if the ripple is allowed - * - * @returns {boolean} true if the ripple is allowed, false if not - */ - function isRippleAllowed() { - var parent = node.parentNode; - var grandparent = parent && parent.parentNode; - var ancestor = grandparent && grandparent.parentNode; - return !isDisabled(node) && !isDisabled(parent) && !isDisabled(grandparent) && !isDisabled(ancestor); - function isDisabled (elem) { - return elem && elem.hasAttribute && elem.hasAttribute('disabled'); - } - } +/** + * Kicks off the animation for removing a ripple + * @param ripple {Element} + */ +InkRippleCtrl.prototype.removeRipple = function (ripple) { + var ctrl = this; + var index = this.ripples.indexOf(ripple); + if (index < 0) return; + this.ripples.splice(this.ripples.indexOf(ripple), 1); + ripple.removeClass('md-ripple-active'); + if (this.ripples.length === 0) this.container.css({ backgroundColor: '' }); + // use a 2-second timeout in order to allow for the animation to finish + // we don't actually care how long the animation takes + this.$timeout(function () { ctrl.fadeOutComplete(ripple); }, DURATION, false); +}; - } -} +/** + * Removes the provided ripple from the DOM + * @param ripple + */ +InkRippleCtrl.prototype.fadeOutComplete = function (ripple) { ripple.remove(); }; /** - * noink/nobar/nostretch directive: make any element that has one of - * these attributes be given a controller, so that other directives can - * `require:` these and see if there is a `no` parent attribute. + * Used to create an empty directive. This is used to track flag-directives whose children may have + * functionality based on them. * - * @usage - * - * - * - * - * - * - * - * - * myApp.directive('detectNo', function() { - * return { - * require: ['^?mdNoInk', ^?mdNoBar'], - * link: function(scope, element, attr, ctrls) { - * var noinkCtrl = ctrls[0]; - * var nobarCtrl = ctrls[1]; - * if (noInkCtrl) { - * alert("the md-no-ink flag has been specified on an ancestor!"); - * } - * if (nobarCtrl) { - * alert("the md-no-bar flag has been specified on an ancestor!"); - * } - * } - * }; - * }); - * + * Example: `md-no-ink` will potentially be used by all child directives. */ -function attrNoDirective() { - return function() { - return { - controller: angular.noop - }; - }; +function attrNoDirective () { + return { controller: angular.noop }; } diff --git a/src/core/style/structure.scss b/src/core/style/structure.scss index 17e48fac158..9c9d3a881c3 100644 --- a/src/core/style/structure.scss +++ b/src/core/style/structure.scss @@ -114,16 +114,13 @@ input { .md-ripple { position: absolute; - transform: scale(0); + transform: translate(-50%, -50%) scale(0); transform-origin: 50% 50%; opacity: 0; border-radius: 50%; &.md-ripple-placed { - $positionDuration: 0.9s * 2; - $sizeDuration: 0.65s * 2; - transition: left $positionDuration $swift-ease-out-timing-function, - top $positionDuration $swift-ease-out-timing-function, - margin $sizeDuration $swift-ease-out-timing-function, + $sizeDuration: 0.45s * 2; + transition: margin $sizeDuration $swift-ease-out-timing-function, border $sizeDuration $swift-ease-out-timing-function, width $sizeDuration $swift-ease-out-timing-function, height $sizeDuration $swift-ease-out-timing-function, @@ -131,7 +128,7 @@ input { transform $sizeDuration $swift-ease-out-timing-function; } &.md-ripple-scaled { - transform: scale(1); + transform: translate(-50%, -50%) scale(1); } &.md-ripple-active, &.md-ripple-full, &.md-ripple-visible { opacity: 0.20;