Skip to content

Commit 0883fb2

Browse files
crisbetohansl
authored andcommitted
perf(progress-circle): improved rendering performance (#1635)
Currently the progress circle hits ZoneJS on every animation frame, due to `requestAnimationFrame`. This change: * Wraps the `requestAnimationFrame` calls in `runOutsideAngular` in order to avoid hitting the change detection on every frame. * Switches from using an attribute binding to manipulating the `path` node directly. Referencing #1570, #1511.
1 parent 96d196a commit 0883fb2

File tree

2 files changed

+45
-33
lines changed

2 files changed

+45
-33
lines changed

src/lib/progress-circle/progress-circle.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
-->
66
<svg viewBox="0 0 100 100"
77
preserveAspectRatio="xMidYMid meet">
8-
<path [attr.d]="currentPath"></path>
8+
<path></path>
99
</svg>

src/lib/progress-circle/progress-circle.ts

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
ChangeDetectorRef,
77
ChangeDetectionStrategy,
88
OnDestroy,
9-
Input
9+
Input,
10+
ElementRef,
11+
NgZone
1012
} from '@angular/core';
1113

1214
// TODO(josephperrott): Benchpress tests.
@@ -21,7 +23,8 @@ const DURATION_DETERMINATE = 225;
2123
const startIndeterminate = 3;
2224
/** End animation value of the indeterminate animation */
2325
const endIndeterminate = 80;
24-
26+
/* Maximum angle for the arc. The angle can't be exactly 360, because the arc becomes hidden. */
27+
const MAX_ANGLE = 359.99 / 100;
2528

2629
export type ProgressCircleMode = 'determinate' | 'indeterminate';
2730

@@ -51,6 +54,9 @@ export class MdProgressCircle implements OnDestroy {
5154
/** The id of the indeterminate interval. */
5255
private _interdeterminateInterval: number;
5356

57+
/** The SVG <path> node that is used to draw the circle. */
58+
private _path: SVGPathElement;
59+
5460
/**
5561
* Values for aria max and min are only defined as numbers when in a determinate mode. We do this
5662
* because voiceover does not report the progress indicator as indeterminate if the aria min
@@ -74,20 +80,6 @@ export class MdProgressCircle implements OnDestroy {
7480
this._interdeterminateInterval = interval;
7581
}
7682

77-
/** The current path value, representing the progress circle. */
78-
private _currentPath: string;
79-
80-
/** TODO: internal */
81-
get currentPath() {
82-
return this._currentPath;
83-
}
84-
set currentPath(path: string) {
85-
this._currentPath = path;
86-
// Mark for check as our ChangeDetectionStrategy is OnPush, when changes come from within the
87-
// component, change detection must be called for.
88-
this._changeDetectorRef.markForCheck();
89-
}
90-
9183
/** Clean up any animations that were running. */
9284
ngOnDestroy() {
9385
this._cleanupIndeterminateAnimation();
@@ -136,8 +128,11 @@ export class MdProgressCircle implements OnDestroy {
136128
}
137129
private _mode: ProgressCircleMode = 'determinate';
138130

139-
constructor(private _changeDetectorRef: ChangeDetectorRef) {
140-
}
131+
constructor(
132+
private _changeDetectorRef: ChangeDetectorRef,
133+
private _ngZone: NgZone,
134+
private _elementRef: ElementRef
135+
) {}
141136

142137

143138
/**
@@ -152,29 +147,33 @@ export class MdProgressCircle implements OnDestroy {
152147
*/
153148
private _animateCircle(animateFrom: number, animateTo: number, ease: EasingFn,
154149
duration: number, rotation: number) {
150+
155151
let id = ++this._lastAnimationId;
156152
let startTime = Date.now();
157153
let changeInValue = animateTo - animateFrom;
158154

159155
// No need to animate it if the values are the same
160156
if (animateTo === animateFrom) {
161-
this.currentPath = getSvgArc(animateTo, rotation);
157+
this._renderArc(animateTo, rotation);
162158
} else {
163159
let animation = () => {
164160
let elapsedTime = Math.max(0, Math.min(Date.now() - startTime, duration));
165161

166-
this.currentPath = getSvgArc(
162+
this._renderArc(
167163
ease(elapsedTime, animateFrom, changeInValue, duration),
168164
rotation
169165
);
170166

171167
// Prevent overlapping animations by checking if a new animation has been called for and
172-
// if the animation has lasted long than the animation duration.
168+
// if the animation has lasted longer than the animation duration.
173169
if (id === this._lastAnimationId && elapsedTime < duration) {
174170
requestAnimationFrame(animation);
175171
}
176172
};
177-
requestAnimationFrame(animation);
173+
174+
// Run the animation outside of Angular's zone, in order to avoid
175+
// hitting ZoneJS and change detection on each frame.
176+
this._ngZone.runOutsideAngular(animation);
178177
}
179178
}
180179

@@ -197,9 +196,10 @@ export class MdProgressCircle implements OnDestroy {
197196
};
198197

199198
if (!this.interdeterminateInterval) {
200-
this.interdeterminateInterval = setInterval(
201-
animate, duration + 50, 0, false);
202-
animate();
199+
this._ngZone.runOutsideAngular(() => {
200+
this.interdeterminateInterval = setInterval(animate, duration + 50, 0, false);
201+
animate();
202+
});
203203
}
204204
}
205205

@@ -210,6 +210,21 @@ export class MdProgressCircle implements OnDestroy {
210210
private _cleanupIndeterminateAnimation() {
211211
this.interdeterminateInterval = null;
212212
}
213+
214+
/**
215+
* Renders the arc onto the SVG element. Proxies `getArc` while setting the proper
216+
* DOM attribute on the `<path>`.
217+
*/
218+
private _renderArc(currentValue: number, rotation: number) {
219+
// Caches the path reference so it doesn't have to be looked up every time.
220+
let path = this._path = this._path || this._elementRef.nativeElement.querySelector('path');
221+
222+
// Ensure that the path was found. This may not be the case if the
223+
// animation function fires too early.
224+
if (path) {
225+
path.setAttribute('d', getSvgArc(currentValue, rotation));
226+
}
227+
}
213228
}
214229

215230

@@ -230,8 +245,8 @@ export class MdProgressCircle implements OnDestroy {
230245
styleUrls: ['progress-circle.css'],
231246
})
232247
export class MdSpinner extends MdProgressCircle {
233-
constructor(changeDetectorRef: ChangeDetectorRef) {
234-
super(changeDetectorRef);
248+
constructor(changeDetectorRef: ChangeDetectorRef, elementRef: ElementRef, ngZone: NgZone) {
249+
super(changeDetectorRef, ngZone, elementRef);
235250
this.mode = 'indeterminate';
236251
}
237252
}
@@ -277,7 +292,6 @@ function materialEase(currentTime: number, startValue: number,
277292
let timeQuad = Math.pow(time, 4);
278293
let timeQuint = Math.pow(time, 5);
279294
return startValue + changeInValue * ((6 * timeQuint) + (-15 * timeQuad) + (10 * timeCubed));
280-
281295
}
282296

283297

@@ -292,14 +306,12 @@ function materialEase(currentTime: number, startValue: number,
292306
* percentage value provided.
293307
*/
294308
function getSvgArc(currentValue: number, rotation: number) {
295-
// The angle can't be exactly 360, because the arc becomes hidden.
296-
let maximumAngle = 359.99 / 100;
297309
let startPoint = rotation || 0;
298310
let radius = 50;
299311
let pathRadius = 40;
300312

301-
let startAngle = startPoint * maximumAngle;
302-
let endAngle = currentValue * maximumAngle;
313+
let startAngle = startPoint * MAX_ANGLE;
314+
let endAngle = currentValue * MAX_ANGLE;
303315
let start = polarToCartesian(radius, pathRadius, startAngle);
304316
let end = polarToCartesian(radius, pathRadius, endAngle + startAngle);
305317
let arcSweep = endAngle < 0 ? 0 : 1;

0 commit comments

Comments
 (0)