diff --git a/src/components/progress-circle/progress-circle.html b/src/components/progress-circle/progress-circle.html index f5903f239593..6c5b5be3fada 100644 --- a/src/components/progress-circle/progress-circle.html +++ b/src/components/progress-circle/progress-circle.html @@ -5,6 +5,5 @@ --> - + diff --git a/src/components/progress-circle/progress-circle.scss b/src/components/progress-circle/progress-circle.scss index f51756defd72..b27d3a1bc29b 100644 --- a/src/components/progress-circle/progress-circle.scss +++ b/src/components/progress-circle/progress-circle.scss @@ -4,14 +4,11 @@ /* Animation Durations */ $md-progress-circle-duration : 5.25s !default; -$md-progress-circle-value-change-duration : $md-progress-circle-duration * 0.25 !default; $md-progress-circle-constant-rotate-duration : $md-progress-circle-duration * 0.55 !default; $md-progress-circle-sporadic-rotate-duration : $md-progress-circle-duration !default; /** Component sizing */ $md-progress-circle-stroke-width: 10px !default; -$md-progress-circle-radius: 40px !default; -$md-progress-circle-circumference: $pi * $md-progress-circle-radius * 2 !default; // Height and weight of the viewport for md-progress-circle. $md-progress-circle-viewport-size : 100px !default; @@ -24,53 +21,33 @@ $md-progress-circle-viewport-size : 100px !default; width: $md-progress-circle-viewport-size; /** SVG's viewBox is defined as 0 0 100 100, this means that all SVG children will placed - based on a 100px by 100px box. - - The SVG and Circle dimensions/location: - SVG - Height: 100px - Width: 100px - Circle - Radius: 40px - Circumference: 251.3274px - Center x: 50px - Center y: 50px + based on a 100px by 100px box. Additionally all SVG sizes and locations are in reference to + this viewBox. */ svg { height: 100%; width: 100%; - transform: rotate(-90deg); transform-origin: center; } - circle { + path { fill: transparent; stroke: md-color($md-primary, 600); /** Stroke width of 10px defines stroke as 10% of the viewBox */ stroke-width: $md-progress-circle-stroke-width; - /** SVG circle rotations begin rotated 90deg clockwise from the circle's center top */ - transition: stroke-dashoffset 0.225s linear; - /** The dash array of the circle is defined as the circumference of the circle. */ - stroke-dasharray: $md-progress-circle-circumference; - /** The stroke dashoffset is used to "fill" the circle, 0px represents an full circle, - while the circles full circumference represents an empty circle. */ - stroke-dashoffset: 0px; } - - &[color="accent"] circle { + &[color="accent"] path { stroke: md-color($md-accent, 600); } - &[color="warn"] circle { + &[color="warn"] path { stroke: md-color($md-warn, 600); } - - &[mode="indeterminate"] { animation-duration: $md-progress-circle-sporadic-rotate-duration, $md-progress-circle-constant-rotate-duration; @@ -80,14 +57,6 @@ $md-progress-circle-viewport-size : 100px !default; linear; animation-iteration-count: infinite; transition: none; - - circle { - animation-duration: $md-progress-circle-value-change-duration; - animation-name: md-progress-circle-value-change; - animation-timing-function: $ease-in-out-curve-function; - animation-iteration-count: infinite; - transition: none; - } } } @@ -107,7 +76,3 @@ $md-progress-circle-viewport-size : 100px !default; 87.5% { transform: rotate( 945deg); } 100% { transform: rotate(1080deg); } } -@keyframes md-progress-circle-value-change { - 0% { stroke-dashoffset: 261.3274px; } - 100% { stroke-dashoffset: -241.3274px; } -} diff --git a/src/components/progress-circle/progress-circle.ts b/src/components/progress-circle/progress-circle.ts index 5b0bc877d0f9..0e42d6a3d174 100644 --- a/src/components/progress-circle/progress-circle.ts +++ b/src/components/progress-circle/progress-circle.ts @@ -1,13 +1,30 @@ import { Component, - ChangeDetectionStrategy, HostBinding, + ChangeDetectorRef, + ChangeDetectionStrategy, Input } from 'angular2/core'; - // TODO(josephperrott): Benchpress tests. +/** A single degree in radians. */ +const DEGREE_IN_RADIANS = Math.PI / 180; +/** Duration of the indeterminate animation. */ +const DURATION_INDETERMINATE = 667; +/** Duration of the indeterminate animation. */ +const DURATION_DETERMINATE = 225; +/** Start animation value of the indeterminate animation */ +let startIndeterminate = 3; +/** End animation value of the indeterminate animation */ +let endIndeterminate = 80; + + +export type ProgressCircleMode = 'determinate' | 'indeterminate'; + +type EasingFn = (currentTime: number, startValue: number, + changeInValue: number, duration: number) => number + /** * component. @@ -24,15 +41,43 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class MdProgressCircle { + /** The id of the last requested animation. */ + private _lastAnimationId: number = 0; + + /** The id of the indeterminate interval. */ + private _interdeterminateInterval: number; + + /** The current path value, representing the progres circle. */ + private _currentPath: string; + get currentPath() { + return this._currentPath; + } + set currentPath(path: string) { + this._currentPath = path; + // Mark for check as our ChangeDetectionStrategy is OnPush, when changes come from within the + // component, change detection must be called for. + this._changeDetectorRef.markForCheck(); + } + /** * Value of the progress circle. * * Input:number, defaults to 0. * _value is bound to the host as the attribute aria-valuenow. */ + private _value: number = 0; + @Input() @HostBinding('attr.aria-valuenow') - @Input('value') - _value: number = 0; + get value() { + return this._value; + } + set value(v: number) { + if (v) { + let newValue = clamp(v); + this._animateCircle(this.value, newValue, linearEase, DURATION_DETERMINATE, 0); + this._value = newValue; + } + } /** * Mode of the progress circle @@ -41,48 +86,101 @@ export class MdProgressCircle { * mode is bound to the host as the attribute host. */ @HostBinding('attr.mode') - @Input() mode: 'determinate' | 'indeterminate' = 'determinate'; + @Input() + get mode() { + return this._mode; + } + set mode(m: ProgressCircleMode) { + if (m == 'indeterminate') { + this._startIndeterminateAnimation(); + } else { + this._cleanupIndeterminateAnimation(); + } + this._mode = m; + } + private _mode: ProgressCircleMode = 'determinate'; + + constructor(private _changeDetectorRef: ChangeDetectorRef) { + } /** - * Gets the current stroke dash offset to represent the progress circle. + * Animates the circle from one percentage value to another. * - * The stroke dash offset specifies the distance between dashes in the circle's stroke. - * Setting the offset to a percentage of the total circumference of the circle, fills this - * percentage of the overall circumference of the circle. + * @param animateFrom The percentage of the circle filled starting the animation. + * @param animateTo The percentage of the circle filled ending the animation. + * @param ease The easing function to manage the pace of change in the animation. + * @param duration The length of time to show the animation, in milliseconds. + * @param rotation The starting angle of the circle fill, with 0° represented at the top center + * of the circle. */ - strokeDashOffset() { - // To determine how far the offset should be, we multiple the current percentage by the - // total circumference. + private _animateCircle(animateFrom: number, animateTo: number, ease: EasingFn, + duration: number, rotation: number) { + let id = ++this._lastAnimationId; + let startTime = now(); + let changeInValue = animateTo - animateFrom; - // The total circumference is calculated based on the radius we use, 45. - // PI * 2 * 45 - return 251.3274 * (100 - this._value) / 100; - } + // No need to animate it if the values are the same + if (animateTo === animateFrom) { + this.currentPath = getSvgArc(animateTo, rotation); + } else { + let animation = (currentTime: number) => { + let elapsedTime = Math.max( + 0, Math.min((currentTime || now()) - startTime, duration)); + this.currentPath = getSvgArc( + ease(elapsedTime, animateFrom, changeInValue, duration), + rotation + ); - /** Gets the progress value, returning the clamped value. */ - get value() { - return this._value; + // Prevent overlapping animations by checking if a new animation has been called for and + // if the animation has lasted long than the animation duration. + if (id === this._lastAnimationId && elapsedTime < duration) { + requestAnimationFrame(animation); + } + }; + requestAnimationFrame(animation); + } } - /** Sets the progress value, clamping before setting the internal value. */ - set value(v: number) { - if (v != null) { - this._value = MdProgressCircle.clamp(v); + /** + * Starts the indeterminate animation interval, if it is not already running. + */ + private _startIndeterminateAnimation() { + let rotationStartPoint = 0; + let start = startIndeterminate; + let end = endIndeterminate; + let duration = DURATION_INDETERMINATE; + let animate = () => { + this._animateCircle(start, end, materialEase, duration, rotationStartPoint); + // Prevent rotation from reaching Number.MAX_SAFE_INTEGER. + rotationStartPoint = (rotationStartPoint + end) % 100; + let temp = start; + start = -end; + end = -temp; + }; + + if (!this._interdeterminateInterval) { + this._interdeterminateInterval = setInterval( + animate, duration + 50, 0, false); + animate(); } } - /** Clamps a value to be between 0 and 100. */ - static clamp(v: number) { - return Math.max(0, Math.min(100, v)); + /** + * Removes interval, ending the animation. + */ + private _cleanupIndeterminateAnimation() { + if (this._interdeterminateInterval) { + clearInterval(this._interdeterminateInterval); + this._interdeterminateInterval = null; + } } } - /** * component. * @@ -96,11 +194,99 @@ export class MdProgressCircle { }, templateUrl: './components/progress-circle/progress-circle.html', styleUrls: ['./components/progress-circle/progress-circle.css'], - changeDetection: ChangeDetectionStrategy.OnPush, }) export class MdSpinner extends MdProgressCircle { - constructor() { - super(); + constructor(changeDetectorRef: ChangeDetectorRef) { + super(changeDetectorRef); this.mode = 'indeterminate'; } } + + +/** + * Module functions. + */ + +/** Clamps a value to be between 0 and 100. */ +function clamp(v: number) { + return Math.max(0, Math.min(100, v)); +} + + +/** + * Returns the current timestamp either based on the performance global or a date object. + */ +function now() { + if (typeof performance !== 'undefined') { + return performance.now(); + } + return Date.now(); +} + + +/** + * Converts Polar coordinates to Cartesian. + */ +function polarToCartesian(radius: number, pathRadius: number, angleInDegrees: number) { + let angleInRadians = (angleInDegrees - 90) * DEGREE_IN_RADIANS; + + return (radius + (pathRadius * Math.cos(angleInRadians))) + + ',' + (radius + (pathRadius * Math.sin(angleInRadians))); +} + + +/** + * Easing function for linear animation. + */ +function linearEase(currentTime: number, startValue: number, + changeInValue: number, duration: number) { + return changeInValue * currentTime / duration + startValue; +} + + +/** + * Easing function to match material design indeterminate animation. + */ +function materialEase(currentTime: number, startValue: number, + changeInValue: number, duration: number) { + let time = currentTime / duration; + let timeCubed = Math.pow(time, 3); + let timeQuad = Math.pow(time, 4); + let timeQuint = Math.pow(time, 5); + return startValue + changeInValue * ((6 * timeQuint) + (-15 * timeQuad) + (10 * timeCubed)); + +} + + +/** + * Determines the path value to define the arc. Converting percentage values to to polar + * coordinates on the circle, and then to cartesian coordinates in the viewport. + * + * @param currentValue The current percentage value of the progress circle, the percentage of the + * circle to fill. + * @param rotation The starting point of the circle with 0 being the 0 degree point. + * @return A string for an SVG path representing a circle filled from the starting point to the + * percentage value provided. + */ +function getSvgArc(currentValue: number, rotation: number) { + // The angle can't be exactly 360, because the arc becomes hidden. + let maximumAngle = 359.99 / 100; + let startPoint = rotation || 0; + let radius = 50; + let pathRadius = 40; + + let startAngle = startPoint * maximumAngle; + let endAngle = currentValue * maximumAngle; + let start = polarToCartesian(radius, pathRadius, startAngle); + let end = polarToCartesian(radius, pathRadius, endAngle + startAngle); + let arcSweep = endAngle < 0 ? 0 : 1; + let largeArcFlag: number; + + if (endAngle < 0) { + largeArcFlag = endAngle >= -180 ? 0 : 1; + } else { + largeArcFlag = endAngle <= 180 ? 0 : 1; + } + + return `M${start}A${pathRadius},${pathRadius} 0 ${largeArcFlag},${arcSweep} ${end}`; +}