Skip to content

feat(progress-spinner): switch to css-based animation #6551

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 5, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/demo-app/progress-spinner/progress-spinner-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ <h1>Determinate</h1>

<div class="demo-progress-spinner">
<mat-progress-spinner [mode]="isDeterminate ? 'determinate' : 'indeterminate'"
[value]="progressValue" color="primary" [strokeWidth]="1"></mat-progress-spinner>
[value]="progressValue" color="primary" [strokeWidth]="1" [diameter]="32"></mat-progress-spinner>
<mat-progress-spinner [mode]="isDeterminate ? 'determinate' : 'indeterminate'"
[value]="progressValue" color="accent"></mat-progress-spinner>
[value]="progressValue" color="accent" [diameter]="50"></mat-progress-spinner>
</div>

<h1>Indeterminate</h1>
Expand Down
6 changes: 3 additions & 3 deletions src/lib/progress-spinner/_progress-spinner-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
$warn: map-get($theme, warn);

.mat-progress-spinner, .mat-spinner {
path {
circle {
stroke: mat-color($primary);
}

&.mat-accent path {
&.mat-accent circle {
stroke: mat-color($accent);
}

&.mat-warn path {
&.mat-warn circle {
stroke: mat-color($warn);
}
}
Expand Down
21 changes: 8 additions & 13 deletions src/lib/progress-spinner/progress-spinner-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,23 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {NgModule} from '@angular/core';
import {PlatformModule} from '@angular/cdk/platform';
import {MatCommonModule} from '@angular/material/core';
import {
MatProgressSpinner,
MatSpinner,
MatProgressSpinnerCssMatStyler,
} from './progress-spinner';

import {MatProgressSpinner, MatSpinner} from './progress-spinner';

@NgModule({
imports: [MatCommonModule],
imports: [MatCommonModule, PlatformModule],
exports: [
MatProgressSpinner,
MatSpinner,
MatCommonModule,
MatProgressSpinnerCssMatStyler
MatCommonModule
],
declarations: [
MatProgressSpinner,
MatSpinner,
MatProgressSpinnerCssMatStyler
MatSpinner
],
})
export class MatProgressSpinnerModule {}
class MatProgressSpinnerModule {}

export {MatProgressSpinnerModule};
21 changes: 17 additions & 4 deletions src/lib/progress-spinner/progress-spinner.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@
element containing the SVG. `focusable="false"` prevents IE from allowing the user to
tab into the SVG element.
-->
<svg viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid meet"
focusable="false">
<path #path [style.strokeWidth]="strokeWidth"></path>

<svg
[style.width.px]="_elementSize"
[style.height.px]="_elementSize"
[attr.viewBox]="_viewBox"
preserveAspectRatio="xMidYMid meet"
focusable="false">

<circle
cx="50%"
cy="50%"
[attr.r]="_circleRadius"
[style.animation-name]="'mat-progress-spinner-stroke-rotate-' + diameter"
[style.stroke-dashoffset.px]="_strokeDashOffset"
[style.stroke-dasharray.px]="_strokeCircumference"
[style.transform.rotate]="'360deg'"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this? It doesn't look like it does anything.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know, I thought that was from your commit, but now I have no idea. Maybe it's a weird rebase error? Don't remember adding it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I haven't pulled in your changes into my local branch yet. I tried checking it out and it's not in there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must have been testing something at some point? I'll remove it, in any case.

[style.stroke-width.px]="strokeWidth"></circle>
</svg>
119 changes: 78 additions & 41 deletions src/lib/progress-spinner/progress-spinner.scss
Original file line number Diff line number Diff line change
@@ -1,51 +1,54 @@
@import '../core/style/variables';


// Animation Durations
$mat-progress-spinner-duration: 5250ms !default;
$mat-progress-spinner-constant-rotate-duration: $mat-progress-spinner-duration * 0.55 !default;
$mat-progress-spinner-sporadic-rotate-duration: $mat-progress-spinner-duration !default;

// Component sizing
$mat-progress-spinner-stroke-width: 10px !default;
// Height and weight of the viewport for mat-progress-spinner.
$mat-progress-spinner-viewport-size: 100px !default;
// Animation config
$mat-progress-spinner-stroke-rotate-fallback-duration: 10 * 1000ms !default;
$mat-progress-spinner-stroke-rotate-fallback-ease: cubic-bezier(0.87, 0.03, 0.33, 1) !default;

$_mat-progress-spinner-default-radius: 45px;
$_mat-progress-spinner-default-circumference: $pi * $_mat-progress-spinner-default-radius * 2;

.mat-progress-spinner {
display: block;
// Height and width are provided for mat-progress-spinner to act as a default.
// The height and width are expected to be overwritten by application css.
height: $mat-progress-spinner-viewport-size;
width: $mat-progress-spinner-viewport-size;
overflow: hidden;

// 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. Additionally all SVG sizes and locations are in reference to
// this viewBox.
position: relative;

svg {
height: 100%;
width: 100%;
position: absolute;
transform: translate(-50%, -50%) rotate(-90deg);
top: 50%;
left: 50%;
transform-origin: center;
overflow: visible;
}


path {
circle {
fill: transparent;
transform-origin: center;
transition: stroke-dashoffset 225ms linear;
}

&.mat-progress-spinner-indeterminate-animation[mode='indeterminate'] {
animation: mat-progress-spinner-linear-rotate $swift-ease-in-out-duration * 4
linear infinite;

transition: stroke $swift-ease-in-duration $ease-in-out-curve-function;
circle {
transition-property: stroke;
// Note: we multiply the duration by 8, because the animation is spread out in 8 stages.
animation-duration: $swift-ease-in-out-duration * 8;
animation-timing-function: $ease-in-out-curve-function;
animation-iteration-count: infinite;
}
}

&.mat-progress-spinner-indeterminate-fallback-animation[mode='indeterminate'] {
animation: mat-progress-spinner-stroke-rotate-fallback
$mat-progress-spinner-stroke-rotate-fallback-duration
$mat-progress-spinner-stroke-rotate-fallback-ease
infinite;

&[mode='indeterminate'] svg {
animation-duration: $mat-progress-spinner-sporadic-rotate-duration,
$mat-progress-spinner-constant-rotate-duration;
animation-name: mat-progress-spinner-sporadic-rotate,
mat-progress-spinner-linear-rotate;
animation-timing-function: $ease-in-out-curve-function,
linear;
animation-iteration-count: infinite;
transition: none;
circle {
transition-property: stroke;
}
}
}

Expand All @@ -55,13 +58,47 @@ $mat-progress-spinner-viewport-size: 100px !default;
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes mat-progress-spinner-sporadic-rotate {
12.5% { transform: rotate( 135deg); }
25% { transform: rotate( 270deg); }
37.5% { transform: rotate( 405deg); }
50% { transform: rotate( 540deg); }
62.5% { transform: rotate( 675deg); }
75% { transform: rotate( 810deg); }
87.5% { transform: rotate( 945deg); }
100% { transform: rotate(1080deg); }

@at-root {
$start: (1 - 0.05) * $_mat-progress-spinner-default-circumference; // start the animation at 5%
$end: (1 - 0.8) * $_mat-progress-spinner-default-circumference; // end the animation at 80%
$fallback-iterations: 4;

@keyframes mat-progress-spinner-stroke-rotate-100 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep this one around, considering that the keyframes are being generated at runtime?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't generate a style tag at runtime for the default diameter - only for custom diameters (the set starts with 100 included).

/*
stylelint-disable declaration-block-single-line-max-declarations,
declaration-block-semicolon-space-after
*/
0% { stroke-dashoffset: $start; transform: rotate(0); }
12.5% { stroke-dashoffset: $end; transform: rotate(0); }
12.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(72.5deg); }
25% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(72.5deg); }

25.1% { stroke-dashoffset: $start; transform: rotate(270deg); }
37.5% { stroke-dashoffset: $end; transform: rotate(270deg); }
37.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(161.5deg); }
50% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(161.5deg); }

50.01% { stroke-dashoffset: $start; transform: rotate(180deg); }
62.5% { stroke-dashoffset: $end; transform: rotate(180deg); }
62.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(251.5deg); }
75% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(251.5deg); }

75.01% { stroke-dashoffset: $start; transform: rotate(90deg); }
87.5% { stroke-dashoffset: $end; transform: rotate(90deg); }
87.51% { stroke-dashoffset: $end; transform: rotateX(180deg) rotate(341.5deg); }
100% { stroke-dashoffset: $start; transform: rotateX(180deg) rotate(341.5deg); }
// stylelint-enable
}

// For IE11 and Edge, we fall back to simply rotating the spinner because
// animating stroke-dashoffset is not supported. The fallback uses multiple
// iterations to vary where the spin "lands".
@keyframes mat-progress-spinner-stroke-rotate-fallback {
@for $i from 0 through $fallback-iterations {
$percent: 100 / $fallback-iterations * $i;
$offset: 360 / $fallback-iterations;
#{$percent}% { transform: rotate(#{$i * (360 * 3 + $offset)}deg); }
}
}
}
101 changes: 42 additions & 59 deletions src/lib/progress-spinner/progress-spinner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {TestBed, async} from '@angular/core/testing';
import {Component} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MatProgressSpinnerModule} from './index';
import {PROGRESS_SPINNER_STROKE_WIDTH} from './progress-spinner';


describe('MatProgressSpinner', () => {
Expand All @@ -16,13 +15,10 @@ describe('MatProgressSpinner', () => {
ProgressSpinnerWithValueAndBoundMode,
ProgressSpinnerWithColor,
ProgressSpinnerCustomStrokeWidth,
IndeterminateProgressSpinnerWithNgIf,
SpinnerWithNgIf,
SpinnerWithColor
ProgressSpinnerCustomDiameter,
SpinnerWithColor,
],
});

TestBed.compileComponents();
}).compileComponents();
}));

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

it('should clean up the indeterminate animation when the element is destroyed', () => {
let fixture = TestBed.createComponent(IndeterminateProgressSpinnerWithNgIf);
fixture.detectChanges();

let progressElement = fixture.debugElement.query(By.css('mat-progress-spinner'));
expect(progressElement.componentInstance.interdeterminateInterval).toBeTruthy();

fixture.componentInstance.isHidden = true;
fixture.detectChanges();
expect(progressElement.componentInstance.interdeterminateInterval).toBeFalsy();
});
it('should allow a custom diameter', () => {
const fixture = TestBed.createComponent(ProgressSpinnerCustomDiameter);
const spinner = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement;
const svgElement = fixture.nativeElement.querySelector('svg');

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

let progressElement = fixture.debugElement.query(By.css('mat-spinner'));
expect(parseInt(spinner.style.width))
.toBe(32, 'Expected the custom diameter to be applied to the host element width.');
expect(parseInt(spinner.style.height))
.toBe(32, 'Expected the custom diameter to be applied to the host element height.');
expect(parseInt(svgElement.style.width))
.toBe(32, 'Expected the custom diameter to be applied to the svg element width.');
expect(parseInt(svgElement.style.height))
.toBe(32, 'Expected the custom diameter to be applied to the svg element height.');
expect(svgElement.getAttribute('viewBox'))
.toBe('0 0 32 32', 'Expected the custom diameter to be applied to the svg viewBox.');
});

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

fixture.componentInstance.isHidden = true;
fixture.componentInstance.strokeWidth = 40;
fixture.detectChanges();

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

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

fixture.componentInstance.strokeWidth = 40;
fixture.detectChanges();

expect(parseInt(pathElement.style.strokeWidth))
.toBe(PROGRESS_SPINNER_STROKE_WIDTH, 'Expected the default stroke-width to be applied.');
expect(element.style.width).toBe('130px');
expect(element.style.height).toBe('130px');
});

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

fixture.componentInstance.strokeWidth = 40;
fixture.componentInstance.strokeWidth = 5;
fixture.detectChanges();

expect(parseInt(pathElement.style.strokeWidth))
.toBe(40, 'Expected the custom stroke width to be applied to the path element.');
expect(element.style.width).toBe('100px');
expect(element.style.height).toBe('100px');
});

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

it('should re-render the circle when switching from indeterminate to determinate mode', () => {
let fixture = TestBed.createComponent(ProgressSpinnerWithValueAndBoundMode);
let progressElement = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement;

fixture.componentInstance.mode = 'indeterminate';
fixture.detectChanges();

let path = progressElement.querySelector('path');
let oldDimesions = path.getAttribute('d');

fixture.componentInstance.mode = 'determinate';
fixture.detectChanges();

expect(path.getAttribute('d')).not
.toBe(oldDimesions, 'Expected circle dimensions to have changed.');
});

it('should remove the underlying SVG element from the tab order explicitly', () => {
const fixture = TestBed.createComponent(BasicProgressSpinner);

Expand All @@ -197,19 +182,17 @@ class ProgressSpinnerCustomStrokeWidth {
strokeWidth: number;
}

@Component({template: '<mat-progress-spinner [diameter]="diameter"></mat-progress-spinner>'})
class ProgressSpinnerCustomDiameter {
diameter: number;
}

@Component({template: '<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>'})
class IndeterminateProgressSpinner { }

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

@Component({template: `
<mat-progress-spinner mode="indeterminate" *ngIf="!isHidden"></mat-progress-spinner>`})
class IndeterminateProgressSpinnerWithNgIf { isHidden = false; }

@Component({template: `<mat-spinner *ngIf="!isHidden"></mat-spinner>`})
class SpinnerWithNgIf { isHidden = false; }

@Component({template: `<mat-spinner [color]="color"></mat-spinner>`})
class SpinnerWithColor { color: string = 'primary'; }

Expand Down
Loading