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}`;
+}