Skip to content

Commit f7f4b07

Browse files
committed
feat(progress-spinner): switch to css-based animation
Reworks the progress spinner to use a CSS-based indeterminate animation. For IE and Edge where we can't pull of the animation reliably, it falls back to a non-spec animation.
1 parent 3571f68 commit f7f4b07

File tree

7 files changed

+163
-409
lines changed

7 files changed

+163
-409
lines changed

src/lib/progress-spinner/_progress-spinner-theme.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
$warn: map-get($theme, warn);
99

1010
.mat-progress-spinner, .mat-spinner {
11-
path {
11+
circle {
1212
stroke: mat-color($primary);
1313
}
1414

15-
&.mat-accent path {
15+
&.mat-accent circle {
1616
stroke: mat-color($accent);
1717
}
1818

19-
&.mat-warn path {
19+
&.mat-warn circle {
2020
stroke: mat-color($warn);
2121
}
2222
}

src/lib/progress-spinner/mat-exports.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@
99
import {
1010
MdProgressSpinner,
1111
MdProgressSpinnerBase,
12-
MdProgressSpinnerCssMatStyler,
1312
MdSpinner,
1413
} from './progress-spinner';
1514
import {MdProgressSpinnerModule} from './progress-spinner-module';
1615

1716

1817
export {MdProgressSpinner as MatProgressSpinner};
1918
export {MdProgressSpinnerBase as MatProgressSpinnerBase};
20-
export {MdProgressSpinnerCssMatStyler as MatProgressSpinnerCssMatStyler};
2119
export {MdProgressSpinnerModule as MatProgressSpinnerModule};
2220
export {MdSpinner as MatSpinner};

src/lib/progress-spinner/progress-spinner-module.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,16 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
98
import {NgModule} from '@angular/core';
109
import {MdCommonModule} from '@angular/material/core';
11-
import {
12-
MdProgressSpinner,
13-
MdSpinner,
14-
MdProgressSpinnerCssMatStyler,
15-
} from './progress-spinner';
10+
import {MdProgressSpinner, MdSpinner} from './progress-spinner';
1611

1712

1813
@NgModule({
1914
imports: [MdCommonModule],
20-
exports: [
21-
MdProgressSpinner,
22-
MdSpinner,
23-
MdCommonModule,
24-
MdProgressSpinnerCssMatStyler
25-
],
26-
declarations: [
27-
MdProgressSpinner,
28-
MdSpinner,
29-
MdProgressSpinnerCssMatStyler
30-
],
15+
exports: [MdProgressSpinner, MdSpinner, MdCommonModule],
16+
declarations: [MdProgressSpinner, MdSpinner],
3117
})
32-
export class MdProgressSpinnerModule {}
18+
class MdProgressSpinnerModule {}
19+
20+
export {MdProgressSpinnerModule};

src/lib/progress-spinner/progress-spinner.html

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@
44
element containing the SVG. `focusable="false"` prevents IE from allowing the user to
55
tab into the SVG element.
66
-->
7-
<svg viewBox="0 0 100 100"
8-
preserveAspectRatio="xMidYMid meet"
9-
focusable="false">
10-
<path #path [style.strokeWidth]="strokeWidth"></path>
7+
<svg
8+
width="100"
9+
height="100"
10+
viewBox="0 0 100 100"
11+
preserveAspectRatio="xMidYMid meet"
12+
focusable="false">
13+
14+
<circle
15+
cx="50%"
16+
cy="50%"
17+
[attr.r]="_circleRadius"
18+
[style.stroke-dashoffset.px]="_getStrokeDashOffset()"
19+
[style.stroke-width.px]="strokeWidth"></circle>
1120
</svg>
Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,57 @@
11
@import '../core/style/variables';
22

33

4-
// Animation Durations
5-
$mat-progress-spinner-duration: 5250ms !default;
6-
$mat-progress-spinner-constant-rotate-duration: $mat-progress-spinner-duration * 0.55 !default;
7-
$mat-progress-spinner-sporadic-rotate-duration: $mat-progress-spinner-duration !default;
4+
// Animation config
5+
$mat-progress-spinner-stroke-rotate-fallback-duration: 10 * 1000ms !default;
6+
$mat-progress-spinner-stroke-rotate-fallback-ease: cubic-bezier(0.87, 0.03, 0.33, 1) !default;
87

9-
// Component sizing
10-
$mat-progress-spinner-stroke-width: 10px !default;
11-
// Height and weight of the viewport for mat-progress-spinner.
12-
$mat-progress-spinner-viewport-size: 100px !default;
8+
$_mat-progress-spinner-radius: 45px;
9+
$_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2;
1310

1411

1512
.mat-progress-spinner {
1613
display: block;
17-
// Height and width are provided for mat-progress-spinner to act as a default.
18-
// The height and width are expected to be overwritten by application css.
19-
height: $mat-progress-spinner-viewport-size;
20-
width: $mat-progress-spinner-viewport-size;
21-
overflow: hidden;
22-
23-
// SVG's viewBox is defined as 0 0 100 100, this means that all SVG children will placed
24-
// based on a 100px by 100px box. Additionally all SVG sizes and locations are in reference to
25-
// this viewBox.
14+
position: relative;
15+
2616
svg {
27-
height: 100%;
28-
width: 100%;
17+
position: absolute;
18+
transform: translate(-50%, -50%) rotate(-90deg);
19+
top: 50%;
20+
left: 50%;
2921
transform-origin: center;
22+
overflow: visible;
3023
}
3124

32-
33-
path {
25+
circle {
3426
fill: transparent;
27+
stroke-dasharray: $_mat-progress-spinner-circumference;
28+
stroke-dashoffset: $_mat-progress-spinner-circumference;
29+
transform-origin: center;
30+
transition: stroke-dashoffset 225ms linear;
31+
}
3532

36-
transition: stroke $swift-ease-in-duration $ease-in-out-curve-function;
33+
&.mat-progress-spinner-indeterminate-animation[mode='indeterminate'] {
34+
animation: mat-progress-spinner-linear-rotate $swift-ease-in-out-duration * 4
35+
linear infinite;
36+
37+
circle {
38+
// Note: we multiply the duration by 8, because the animation is spread out in 8 stages.
39+
animation: mat-progress-spinner-stroke-rotate $swift-ease-in-out-duration * 8
40+
$ease-in-out-curve-function infinite;
41+
transition-property: stroke;
42+
}
3743
}
3844

45+
&.mat-progress-spinner-indeterminate-fallback-animation[mode='indeterminate'] {
46+
animation: mat-progress-spinner-stroke-rotate-fallback
47+
$mat-progress-spinner-stroke-rotate-fallback-duration
48+
$mat-progress-spinner-stroke-rotate-fallback-ease
49+
infinite;
3950

40-
&[mode='indeterminate'] svg {
41-
animation-duration: $mat-progress-spinner-sporadic-rotate-duration,
42-
$mat-progress-spinner-constant-rotate-duration;
43-
animation-name: mat-progress-spinner-sporadic-rotate,
44-
mat-progress-spinner-linear-rotate;
45-
animation-timing-function: $ease-in-out-curve-function,
46-
linear;
47-
animation-iteration-count: infinite;
48-
transition: none;
51+
circle {
52+
stroke-dashoffset: (1 - 0.8) * $_mat-progress-spinner-circumference;
53+
transition-property: stroke;
54+
}
4955
}
5056
}
5157

@@ -55,13 +61,47 @@ $mat-progress-spinner-viewport-size: 100px !default;
5561
0% { transform: rotate(0deg); }
5662
100% { transform: rotate(360deg); }
5763
}
58-
@keyframes mat-progress-spinner-sporadic-rotate {
59-
12.5% { transform: rotate( 135deg); }
60-
25% { transform: rotate( 270deg); }
61-
37.5% { transform: rotate( 405deg); }
62-
50% { transform: rotate( 540deg); }
63-
62.5% { transform: rotate( 675deg); }
64-
75% { transform: rotate( 810deg); }
65-
87.5% { transform: rotate( 945deg); }
66-
100% { transform: rotate(1080deg); }
64+
65+
@at-root {
66+
$start: (1 - 0.05) * $_mat-progress-spinner-circumference; // start the animation at 5%
67+
$end: (1 - 0.8) * $_mat-progress-spinner-circumference; // end the animation at 80%
68+
$fallback-iterations: 4;
69+
70+
@keyframes mat-progress-spinner-stroke-rotate {
71+
/*
72+
stylelint-disable declaration-block-single-line-max-declarations,
73+
declaration-block-semicolon-space-after
74+
*/
75+
0% { stroke-dashoffset: $start; transform: rotate(0); }
76+
12.5% { stroke-dashoffset: $end; transform: rotate(0); }
77+
12.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(72.5deg); }
78+
25% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(72.5deg); }
79+
80+
25.1% { stroke-dashoffset: $start; transform: rotate(270deg); }
81+
37.5% { stroke-dashoffset: $end; transform: rotate(270deg); }
82+
37.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(161.5deg); }
83+
50% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(161.5deg); }
84+
85+
50.01% { stroke-dashoffset: $start; transform: rotate(180deg); }
86+
62.5% { stroke-dashoffset: $end; transform: rotate(180deg); }
87+
62.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(251.5deg); }
88+
75% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(251.5deg); }
89+
90+
75.01% { stroke-dashoffset: $start; transform: rotate(90deg); }
91+
87.5% { stroke-dashoffset: $end; transform: rotate(90deg); }
92+
87.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(341.5deg); }
93+
100% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(341.5deg); }
94+
// stylelint-enable
95+
}
96+
97+
// For IE11 and Edge, we fall back to simply rotating the spinner because
98+
// animating stroke-dashoffset is not supported. The fallback uses multiple
99+
// iterations to vary where the spin "lands".
100+
@keyframes mat-progress-spinner-stroke-rotate-fallback {
101+
@for $i from 0 through $fallback-iterations {
102+
$percent: 100 / $fallback-iterations * $i;
103+
$offset: 360 / $fallback-iterations;
104+
#{$percent}% { transform: rotate(#{$i * (360 * 3 + $offset)}deg); }
105+
}
106+
}
67107
}

src/lib/progress-spinner/progress-spinner.spec.ts

Lines changed: 20 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {TestBed, async} from '@angular/core/testing';
22
import {Component} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {MdProgressSpinnerModule} from './index';
5-
import {PROGRESS_SPINNER_STROKE_WIDTH} from './progress-spinner';
65

76

87
describe('MdProgressSpinner', () => {
@@ -16,13 +15,9 @@ describe('MdProgressSpinner', () => {
1615
ProgressSpinnerWithValueAndBoundMode,
1716
ProgressSpinnerWithColor,
1817
ProgressSpinnerCustomStrokeWidth,
19-
IndeterminateProgressSpinnerWithNgIf,
20-
SpinnerWithNgIf,
21-
SpinnerWithColor
18+
SpinnerWithColor,
2219
],
23-
});
24-
25-
TestBed.compileComponents();
20+
}).compileComponents();
2621
}));
2722

2823
it('should apply a mode of "determinate" if no mode is provided.', () => {
@@ -84,51 +79,37 @@ describe('MdProgressSpinner', () => {
8479
expect(progressComponent.value).toBe(0);
8580
});
8681

87-
it('should clean up the indeterminate animation when the element is destroyed', () => {
88-
let fixture = TestBed.createComponent(IndeterminateProgressSpinnerWithNgIf);
89-
fixture.detectChanges();
90-
91-
let progressElement = fixture.debugElement.query(By.css('md-progress-spinner'));
92-
expect(progressElement.componentInstance.interdeterminateInterval).toBeTruthy();
93-
94-
fixture.componentInstance.isHidden = true;
95-
fixture.detectChanges();
96-
expect(progressElement.componentInstance.interdeterminateInterval).toBeFalsy();
97-
});
98-
99-
it('should clean up the animation when a spinner is destroyed', () => {
100-
let fixture = TestBed.createComponent(SpinnerWithNgIf);
101-
fixture.detectChanges();
102-
103-
let progressElement = fixture.debugElement.query(By.css('md-spinner'));
104-
105-
expect(progressElement.componentInstance.interdeterminateInterval).toBeTruthy();
82+
it('should allow a custom stroke width', () => {
83+
const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth);
84+
const circleElement = fixture.nativeElement.querySelector('circle');
10685

107-
fixture.componentInstance.isHidden = true;
86+
fixture.componentInstance.strokeWidth = 40;
10887
fixture.detectChanges();
10988

110-
expect(progressElement.componentInstance.interdeterminateInterval).toBeFalsy();
89+
expect(parseInt(circleElement.style.strokeWidth))
90+
.toBe(40, 'Expected the custom stroke width to be applied to the circle element.');
11191
});
11292

113-
it('should set a default stroke width', () => {
114-
let fixture = TestBed.createComponent(BasicProgressSpinner);
115-
let pathElement = fixture.nativeElement.querySelector('path');
93+
it('should expand the host element if the stroke width is greater than the default', () => {
94+
const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth);
95+
const element = fixture.debugElement.nativeElement.querySelector('.mat-progress-spinner');
11696

97+
fixture.componentInstance.strokeWidth = 40;
11798
fixture.detectChanges();
11899

119-
expect(parseInt(pathElement.style.strokeWidth))
120-
.toBe(PROGRESS_SPINNER_STROKE_WIDTH, 'Expected the default stroke-width to be applied.');
100+
expect(element.style.width).toBe('130px');
101+
expect(element.style.height).toBe('130px');
121102
});
122103

123-
it('should allow a custom stroke width', () => {
124-
let fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth);
125-
let pathElement = fixture.nativeElement.querySelector('path');
104+
it('should not collapse the host element if the stroke width is less than the default', () => {
105+
const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth);
106+
const element = fixture.debugElement.nativeElement.querySelector('.mat-progress-spinner');
126107

127-
fixture.componentInstance.strokeWidth = 40;
108+
fixture.componentInstance.strokeWidth = 5;
128109
fixture.detectChanges();
129110

130-
expect(parseInt(pathElement.style.strokeWidth))
131-
.toBe(40, 'Expected the custom stroke width to be applied to the path element.');
111+
expect(element.style.width).toBe('100px');
112+
expect(element.style.height).toBe('100px');
132113
});
133114

134115
it('should set the color class on the md-spinner', () => {
@@ -161,23 +142,6 @@ describe('MdProgressSpinner', () => {
161142
expect(progressElement.nativeElement.classList).not.toContain('mat-primary');
162143
});
163144

164-
it('should re-render the circle when switching from indeterminate to determinate mode', () => {
165-
let fixture = TestBed.createComponent(ProgressSpinnerWithValueAndBoundMode);
166-
let progressElement = fixture.debugElement.query(By.css('md-progress-spinner')).nativeElement;
167-
168-
fixture.componentInstance.mode = 'indeterminate';
169-
fixture.detectChanges();
170-
171-
let path = progressElement.querySelector('path');
172-
let oldDimesions = path.getAttribute('d');
173-
174-
fixture.componentInstance.mode = 'determinate';
175-
fixture.detectChanges();
176-
177-
expect(path.getAttribute('d')).not
178-
.toBe(oldDimesions, 'Expected circle dimensions to have changed.');
179-
});
180-
181145
it('should remove the underlying SVG element from the tab order explicitly', () => {
182146
const fixture = TestBed.createComponent(BasicProgressSpinner);
183147

@@ -203,13 +167,6 @@ class IndeterminateProgressSpinner { }
203167
@Component({template: '<md-progress-spinner value="50" [mode]="mode"></md-progress-spinner>'})
204168
class ProgressSpinnerWithValueAndBoundMode { mode = 'indeterminate'; }
205169

206-
@Component({template: `
207-
<md-progress-spinner mode="indeterminate" *ngIf="!isHidden"></md-progress-spinner>`})
208-
class IndeterminateProgressSpinnerWithNgIf { isHidden = false; }
209-
210-
@Component({template: `<md-spinner *ngIf="!isHidden"></md-spinner>`})
211-
class SpinnerWithNgIf { isHidden = false; }
212-
213170
@Component({template: `<md-spinner [color]="color"></md-spinner>`})
214171
class SpinnerWithColor { color: string = 'primary'; }
215172

0 commit comments

Comments
 (0)