Skip to content

Commit 630dfad

Browse files
crisbetokara
authored andcommitted
feat(progress-spinner): switch to css-based animation (#6551)
1 parent c2a9516 commit 630dfad

File tree

8 files changed

+272
-390
lines changed

8 files changed

+272
-390
lines changed

src/demo-app/progress-spinner/progress-spinner-demo.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ <h1>Determinate</h1>
99

1010
<div class="demo-progress-spinner">
1111
<mat-progress-spinner [mode]="isDeterminate ? 'determinate' : 'indeterminate'"
12-
[value]="progressValue" color="primary" [strokeWidth]="1"></mat-progress-spinner>
12+
[value]="progressValue" color="primary" [strokeWidth]="1" [diameter]="32"></mat-progress-spinner>
1313
<mat-progress-spinner [mode]="isDeterminate ? 'determinate' : 'indeterminate'"
14-
[value]="progressValue" color="accent"></mat-progress-spinner>
14+
[value]="progressValue" color="accent" [diameter]="50"></mat-progress-spinner>
1515
</div>
1616

1717
<h1>Indeterminate</h1>

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/progress-spinner-module.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,23 @@
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';
9+
import {PlatformModule} from '@angular/cdk/platform';
1010
import {MatCommonModule} from '@angular/material/core';
11-
import {
12-
MatProgressSpinner,
13-
MatSpinner,
14-
MatProgressSpinnerCssMatStyler,
15-
} from './progress-spinner';
16-
11+
import {MatProgressSpinner, MatSpinner} from './progress-spinner';
1712

1813
@NgModule({
19-
imports: [MatCommonModule],
14+
imports: [MatCommonModule, PlatformModule],
2015
exports: [
2116
MatProgressSpinner,
2217
MatSpinner,
23-
MatCommonModule,
24-
MatProgressSpinnerCssMatStyler
18+
MatCommonModule
2519
],
2620
declarations: [
2721
MatProgressSpinner,
28-
MatSpinner,
29-
MatProgressSpinnerCssMatStyler
22+
MatSpinner
3023
],
3124
})
32-
export class MatProgressSpinnerModule {}
25+
class MatProgressSpinnerModule {}
26+
27+
export {MatProgressSpinnerModule};

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,21 @@
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+
8+
<svg
9+
[style.width.px]="_elementSize"
10+
[style.height.px]="_elementSize"
11+
[attr.viewBox]="_viewBox"
12+
preserveAspectRatio="xMidYMid meet"
13+
focusable="false">
14+
15+
<circle
16+
cx="50%"
17+
cy="50%"
18+
[attr.r]="_circleRadius"
19+
[style.animation-name]="'mat-progress-spinner-stroke-rotate-' + diameter"
20+
[style.stroke-dashoffset.px]="_strokeDashOffset"
21+
[style.stroke-dasharray.px]="_strokeCircumference"
22+
[style.transform.rotate]="'360deg'"
23+
[style.stroke-width.px]="strokeWidth"></circle>
1124
</svg>
Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,54 @@
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;
8-
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;
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;
137

8+
$_mat-progress-spinner-default-radius: 45px;
9+
$_mat-progress-spinner-default-circumference: $pi * $_mat-progress-spinner-default-radius * 2;
1410

1511
.mat-progress-spinner {
1612
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.
13+
position: relative;
14+
2615
svg {
27-
height: 100%;
28-
width: 100%;
16+
position: absolute;
17+
transform: translate(-50%, -50%) rotate(-90deg);
18+
top: 50%;
19+
left: 50%;
2920
transform-origin: center;
21+
overflow: visible;
3022
}
3123

32-
33-
path {
24+
circle {
3425
fill: transparent;
26+
transform-origin: center;
27+
transition: stroke-dashoffset 225ms linear;
28+
}
29+
30+
&.mat-progress-spinner-indeterminate-animation[mode='indeterminate'] {
31+
animation: mat-progress-spinner-linear-rotate $swift-ease-in-out-duration * 4
32+
linear infinite;
3533

36-
transition: stroke $swift-ease-in-duration $ease-in-out-curve-function;
34+
circle {
35+
transition-property: stroke;
36+
// Note: we multiply the duration by 8, because the animation is spread out in 8 stages.
37+
animation-duration: $swift-ease-in-out-duration * 8;
38+
animation-timing-function: $ease-in-out-curve-function;
39+
animation-iteration-count: infinite;
40+
}
3741
}
3842

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

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;
49+
circle {
50+
transition-property: stroke;
51+
}
4952
}
5053
}
5154

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

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

Lines changed: 42 additions & 59 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 {MatProgressSpinnerModule} from './index';
5-
import {PROGRESS_SPINNER_STROKE_WIDTH} from './progress-spinner';
65

76

87
describe('MatProgressSpinner', () => {
@@ -16,13 +15,10 @@ describe('MatProgressSpinner', () => {
1615
ProgressSpinnerWithValueAndBoundMode,
1716
ProgressSpinnerWithColor,
1817
ProgressSpinnerCustomStrokeWidth,
19-
IndeterminateProgressSpinnerWithNgIf,
20-
SpinnerWithNgIf,
21-
SpinnerWithColor
18+
ProgressSpinnerCustomDiameter,
19+
SpinnerWithColor,
2220
],
23-
});
24-
25-
TestBed.compileComponents();
21+
}).compileComponents();
2622
}));
2723

2824
it('should apply a mode of "determinate" if no mode is provided.', () => {
@@ -84,51 +80,57 @@ describe('MatProgressSpinner', () => {
8480
expect(progressComponent.value).toBe(0);
8581
});
8682

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('mat-progress-spinner'));
92-
expect(progressElement.componentInstance.interdeterminateInterval).toBeTruthy();
93-
94-
fixture.componentInstance.isHidden = true;
95-
fixture.detectChanges();
96-
expect(progressElement.componentInstance.interdeterminateInterval).toBeFalsy();
97-
});
83+
it('should allow a custom diameter', () => {
84+
const fixture = TestBed.createComponent(ProgressSpinnerCustomDiameter);
85+
const spinner = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement;
86+
const svgElement = fixture.nativeElement.querySelector('svg');
9887

99-
it('should clean up the animation when a spinner is destroyed', () => {
100-
let fixture = TestBed.createComponent(SpinnerWithNgIf);
88+
fixture.componentInstance.diameter = 32;
10189
fixture.detectChanges();
10290

103-
let progressElement = fixture.debugElement.query(By.css('mat-spinner'));
91+
expect(parseInt(spinner.style.width))
92+
.toBe(32, 'Expected the custom diameter to be applied to the host element width.');
93+
expect(parseInt(spinner.style.height))
94+
.toBe(32, 'Expected the custom diameter to be applied to the host element height.');
95+
expect(parseInt(svgElement.style.width))
96+
.toBe(32, 'Expected the custom diameter to be applied to the svg element width.');
97+
expect(parseInt(svgElement.style.height))
98+
.toBe(32, 'Expected the custom diameter to be applied to the svg element height.');
99+
expect(svgElement.getAttribute('viewBox'))
100+
.toBe('0 0 32 32', 'Expected the custom diameter to be applied to the svg viewBox.');
101+
});
104102

105-
expect(progressElement.componentInstance.interdeterminateInterval).toBeTruthy();
103+
it('should allow a custom stroke width', () => {
104+
const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth);
105+
const circleElement = fixture.nativeElement.querySelector('circle');
106106

107-
fixture.componentInstance.isHidden = true;
107+
fixture.componentInstance.strokeWidth = 40;
108108
fixture.detectChanges();
109109

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

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

118+
fixture.componentInstance.strokeWidth = 40;
117119
fixture.detectChanges();
118120

119-
expect(parseInt(pathElement.style.strokeWidth))
120-
.toBe(PROGRESS_SPINNER_STROKE_WIDTH, 'Expected the default stroke-width to be applied.');
121+
expect(element.style.width).toBe('130px');
122+
expect(element.style.height).toBe('130px');
121123
});
122124

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

127-
fixture.componentInstance.strokeWidth = 40;
129+
fixture.componentInstance.strokeWidth = 5;
128130
fixture.detectChanges();
129131

130-
expect(parseInt(pathElement.style.strokeWidth))
131-
.toBe(40, 'Expected the custom stroke width to be applied to the path element.');
132+
expect(element.style.width).toBe('100px');
133+
expect(element.style.height).toBe('100px');
132134
});
133135

134136
it('should set the color class on the mat-spinner', () => {
@@ -161,23 +163,6 @@ describe('MatProgressSpinner', () => {
161163
expect(progressElement.nativeElement.classList).not.toContain('mat-primary');
162164
});
163165

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('mat-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-
181166
it('should remove the underlying SVG element from the tab order explicitly', () => {
182167
const fixture = TestBed.createComponent(BasicProgressSpinner);
183168

@@ -197,19 +182,17 @@ class ProgressSpinnerCustomStrokeWidth {
197182
strokeWidth: number;
198183
}
199184

185+
@Component({template: '<mat-progress-spinner [diameter]="diameter"></mat-progress-spinner>'})
186+
class ProgressSpinnerCustomDiameter {
187+
diameter: number;
188+
}
189+
200190
@Component({template: '<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>'})
201191
class IndeterminateProgressSpinner { }
202192

203193
@Component({template: '<mat-progress-spinner value="50" [mode]="mode"></mat-progress-spinner>'})
204194
class ProgressSpinnerWithValueAndBoundMode { mode = 'indeterminate'; }
205195

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

0 commit comments

Comments
 (0)