Skip to content

Commit 283306a

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 372436c commit 283306a

File tree

6 files changed

+167
-410
lines changed

6 files changed

+167
-410
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/index.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,15 @@
88

99
import {NgModule} from '@angular/core';
1010
import {MdCommonModule} from '../core';
11-
import {
12-
MdProgressSpinner,
13-
MdSpinner,
14-
MdProgressSpinnerCssMatStyler,
15-
} from './progress-spinner';
11+
import {PlatformModule} from '@angular/cdk/platform';
12+
import {MdProgressSpinner, MdSpinner} from './progress-spinner';
1613

1714

1815
@NgModule({
19-
imports: [MdCommonModule],
20-
exports: [
21-
MdProgressSpinner,
22-
MdSpinner,
23-
MdCommonModule,
24-
MdProgressSpinnerCssMatStyler
25-
],
26-
declarations: [
27-
MdProgressSpinner,
28-
MdSpinner,
29-
MdProgressSpinnerCssMatStyler
30-
],
16+
imports: [MdCommonModule, PlatformModule],
17+
exports: [MdProgressSpinner, MdSpinner, MdCommonModule],
18+
declarations: [MdProgressSpinner, MdSpinner],
3119
})
32-
class MdProgressSpinnerModule {}
20+
export class MdProgressSpinnerModule {}
3321

34-
export {MdProgressSpinnerModule};
3522
export * from './progress-spinner';

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: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,60 @@
11
@import '../core/style/variables';
22

33

4-
// Animation Durations
4+
// Animation config
55
$mat-progress-spinner-duration: 5250ms !default;
6+
$mat-progress-spinner-stroke-rotate-fallback-duration: 10 * 1000ms !default;
7+
$mat-progress-spinner-stroke-rotate-fallback-ease: cubic-bezier(0.87, 0.03, 0.33, 1) !default;
68
$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;
9+
$mat-progress-spinner-stroke-rotate-duration: 667ms !default;
810

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;
11+
$_mat-progress-spinner-radius: 45px;
12+
$_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2;
1313

1414

15-
:host {
15+
.mat-progress-spinner {
1616
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.
17+
position: relative;
18+
2619
svg {
27-
height: 100%;
28-
width: 100%;
20+
position: absolute;
21+
transform: translate(-50%, -50%) rotate(-90deg);
22+
top: 50%;
23+
left: 50%;
2924
transform-origin: center;
25+
overflow: visible;
3026
}
3127

32-
33-
path {
28+
circle {
3429
fill: transparent;
30+
stroke-dasharray: $_mat-progress-spinner-circumference;
31+
stroke-dashoffset: $_mat-progress-spinner-circumference;
32+
transform-origin: center;
33+
transition: stroke-dashoffset 225ms linear;
34+
}
3535

36-
transition: stroke $swift-ease-in-duration $ease-in-out-curve-function;
36+
&.mat-progress-spinner-indeterminate-fallback-animation[mode='indeterminate'] {
37+
animation: mat-progress-spinner-stroke-rotate-fallback
38+
$mat-progress-spinner-stroke-rotate-fallback-duration
39+
$mat-progress-spinner-stroke-rotate-fallback-ease
40+
infinite;
41+
42+
circle {
43+
stroke-dashoffset: (1 - 0.8) * $_mat-progress-spinner-circumference;
44+
transition-property: stroke;
45+
}
3746
}
3847

48+
&.mat-progress-spinner-indeterminate-animation[mode='indeterminate'] {
49+
animation: mat-progress-spinner-linear-rotate $mat-progress-spinner-constant-rotate-duration
50+
linear infinite;
3951

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;
52+
circle {
53+
// Note: we multiply the duration by 8, because the animation is spread out in 8 stages.
54+
animation: mat-progress-spinner-stroke-rotate
55+
$mat-progress-spinner-stroke-rotate-duration * 8 $ease-in-out-curve-function infinite;
56+
transition-property: stroke;
57+
}
4958
}
5059
}
5160

@@ -55,13 +64,44 @@ $mat-progress-spinner-viewport-size: 100px !default;
5564
0% { transform: rotate(0deg); }
5665
100% { transform: rotate(360deg); }
5766
}
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); }
67+
68+
@at-root {
69+
$start: (1 - 0.05) * $_mat-progress-spinner-circumference; // start the animation at 5%
70+
$end: (1 - 0.8) * $_mat-progress-spinner-circumference; // end the animation at 80%
71+
$fallback-iterations: 4;
72+
73+
@keyframes mat-progress-spinner-stroke-rotate {
74+
/*
75+
stylelint-disable declaration-block-single-line-max-declarations,
76+
declaration-block-semicolon-space-after
77+
*/
78+
0% { stroke-dashoffset: $start; transform: rotate(0); }
79+
12.5% { stroke-dashoffset: $end; transform: rotate(0); }
80+
12.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(72.5deg); }
81+
25% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(72.5deg); }
82+
83+
25.1% { stroke-dashoffset: $start; transform: rotate(270deg); }
84+
37.5% { stroke-dashoffset: $end; transform: rotate(270deg); }
85+
37.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(161.5deg); }
86+
50% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(161.5deg); }
87+
88+
50.01% { stroke-dashoffset: $start; transform: rotate(180deg); }
89+
62.5% { stroke-dashoffset: $end; transform: rotate(180deg); }
90+
62.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(251.5deg); }
91+
75% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(251.5deg); }
92+
93+
75.01% { stroke-dashoffset: $start; transform: rotate(90deg); }
94+
87.5% { stroke-dashoffset: $end; transform: rotate(90deg); }
95+
87.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(341.5deg); }
96+
100% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(341.5deg); }
97+
// stylelint-enable
98+
}
99+
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)