Skip to content

Commit

Permalink
feat(progress-spinner): switch to css-based animation (angular#6551)
Browse files Browse the repository at this point in the history
  • Loading branch information
crisbeto authored and kara committed Oct 5, 2017
1 parent c2a9516 commit 630dfad
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 390 deletions.
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'"
[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 {
/*
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

0 comments on commit 630dfad

Please sign in to comment.