diff --git a/src/demo-app/progress-spinner/progress-spinner-demo.html b/src/demo-app/progress-spinner/progress-spinner-demo.html index 27a9dfcec7c3..01eeeff43b82 100644 --- a/src/demo-app/progress-spinner/progress-spinner-demo.html +++ b/src/demo-app/progress-spinner/progress-spinner-demo.html @@ -9,9 +9,9 @@

Determinate

+ [value]="progressValue" color="primary" [strokeWidth]="1" [diameter]="32"> + [value]="progressValue" color="accent" [diameter]="50">

Indeterminate

diff --git a/src/lib/progress-spinner/_progress-spinner-theme.scss b/src/lib/progress-spinner/_progress-spinner-theme.scss index a062443d0043..353cb3cf0be7 100644 --- a/src/lib/progress-spinner/_progress-spinner-theme.scss +++ b/src/lib/progress-spinner/_progress-spinner-theme.scss @@ -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); } } diff --git a/src/lib/progress-spinner/progress-spinner-module.ts b/src/lib/progress-spinner/progress-spinner-module.ts index 11e7e841acab..abbef18f3c5f 100644 --- a/src/lib/progress-spinner/progress-spinner-module.ts +++ b/src/lib/progress-spinner/progress-spinner-module.ts @@ -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}; diff --git a/src/lib/progress-spinner/progress-spinner.html b/src/lib/progress-spinner/progress-spinner.html index bb27e80f90b6..237f087fcb07 100644 --- a/src/lib/progress-spinner/progress-spinner.html +++ b/src/lib/progress-spinner/progress-spinner.html @@ -4,8 +4,21 @@ element containing the SVG. `focusable="false"` prevents IE from allowing the user to tab into the SVG element. --> - - + + + + diff --git a/src/lib/progress-spinner/progress-spinner.scss b/src/lib/progress-spinner/progress-spinner.scss index d213c518e15e..9406f80d2848 100644 --- a/src/lib/progress-spinner/progress-spinner.scss +++ b/src/lib/progress-spinner/progress-spinner.scss @@ -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; + } } } @@ -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); } + } + } } diff --git a/src/lib/progress-spinner/progress-spinner.spec.ts b/src/lib/progress-spinner/progress-spinner.spec.ts index 755b499398b1..4fc66ebf738b 100644 --- a/src/lib/progress-spinner/progress-spinner.spec.ts +++ b/src/lib/progress-spinner/progress-spinner.spec.ts @@ -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', () => { @@ -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.', () => { @@ -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', () => { @@ -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); @@ -197,19 +182,17 @@ class ProgressSpinnerCustomStrokeWidth { strokeWidth: number; } +@Component({template: ''}) +class ProgressSpinnerCustomDiameter { + diameter: number; +} + @Component({template: ''}) class IndeterminateProgressSpinner { } @Component({template: ''}) class ProgressSpinnerWithValueAndBoundMode { mode = 'indeterminate'; } -@Component({template: ` - `}) -class IndeterminateProgressSpinnerWithNgIf { isHidden = false; } - -@Component({template: ``}) -class SpinnerWithNgIf { isHidden = false; } - @Component({template: ``}) class SpinnerWithColor { color: string = 'primary'; } diff --git a/src/lib/progress-spinner/progress-spinner.ts b/src/lib/progress-spinner/progress-spinner.ts index 5bf92bee427e..0be373bf8a59 100644 --- a/src/lib/progress-spinner/progress-spinner.ts +++ b/src/lib/progress-spinner/progress-spinner.ts @@ -9,53 +9,22 @@ import { Component, ChangeDetectionStrategy, - OnDestroy, Input, ElementRef, - NgZone, Renderer2, - Directive, - ViewChild, + SimpleChanges, + OnChanges, ViewEncapsulation, + Optional, + Inject, } from '@angular/core'; import {CanColor, mixinColor} from '@angular/material/core'; +import {Platform} from '@angular/cdk/platform'; +import {DOCUMENT} from '@angular/common'; - -// TODO(josephperrott): Benchpress tests. - -/** A single degree in radians. */ -const DEGREE_IN_RADIANS = Math.PI / 180; -/** Duration of the indeterminate animation. */ -const DURATION_INDETERMINATE = 667; -/** Duration of the indeterminate animation. */ -const DURATION_DETERMINATE = 225; -/** Start animation value of the indeterminate animation */ -const startIndeterminate = 3; -/** End animation value of the indeterminate animation */ -const endIndeterminate = 80; -/** Maximum angle for the arc. The angle can't be exactly 360, because the arc becomes hidden. */ -const MAX_ANGLE = 359.99 / 100; -/** Whether the user's browser supports requestAnimationFrame. */ -const HAS_RAF = typeof requestAnimationFrame !== 'undefined'; -/** Default stroke width as a percentage of the viewBox. */ -export const PROGRESS_SPINNER_STROKE_WIDTH = 10; - +/** Possible mode for a progress spinner. */ export type ProgressSpinnerMode = 'determinate' | 'indeterminate'; -type EasingFn = (currentTime: number, startValue: number, - changeInValue: number, duration: number) => number; - - -/** - * Directive whose purpose is to add the mat- CSS styling to this selector. - * @docs-private - */ -@Directive({ - selector: 'mat-progress-spinner', - host: {'class': 'mat-progress-spinner'} -}) -export class MatProgressSpinnerCssMatStyler {} - // Boilerplate for applying mixins to MatProgressSpinner. /** @docs-private */ export class MatProgressSpinnerBase { @@ -63,6 +32,30 @@ export class MatProgressSpinnerBase { } export const _MatProgressSpinnerMixinBase = mixinColor(MatProgressSpinnerBase, 'primary'); +const INDETERMINATE_ANIMATION_TEMPLATE = ` + @keyframes mat-progress-spinner-stroke-rotate-DIAMETER { + 0% { stroke-dashoffset: START_VALUE; transform: rotate(0); } + 12.5% { stroke-dashoffset: END_VALUE; transform: rotate(0); } + 12.51% { stroke-dashoffset: END_VALUE; transform: rotateX(180deg) rotate(72.5deg); } + 25% { stroke-dashoffset: START_VALUE; transform: rotateX(180deg) rotate(72.5deg); } + + 25.1% { stroke-dashoffset: START_VALUE; transform: rotate(270deg); } + 37.5% { stroke-dashoffset: END_VALUE; transform: rotate(270deg); } + 37.51% { stroke-dashoffset: END_VALUE; transform: rotateX(180deg) rotate(161.5deg); } + 50% { stroke-dashoffset: START_VALUE; transform: rotateX(180deg) rotate(161.5deg); } + + 50.01% { stroke-dashoffset: START_VALUE; transform: rotate(180deg); } + 62.5% { stroke-dashoffset: END_VALUE; transform: rotate(180deg); } + 62.51% { stroke-dashoffset: END_VALUE; transform: rotateX(180deg) rotate(251.5deg); } + 75% { stroke-dashoffset: START_VALUE; transform: rotateX(180deg) rotate(251.5deg); } + + 75.01% { stroke-dashoffset: START_VALUE; transform: rotate(90deg); } + 87.5% { stroke-dashoffset: END_VALUE; transform: rotate(90deg); } + 87.51% { stroke-dashoffset: END_VALUE; transform: rotateX(180deg) rotate(341.5deg); } + 100% { stroke-dashoffset: START_VALUE; transform: rotateX(180deg) rotate(341.5deg); } + } +`; + /** * component. */ @@ -73,8 +66,10 @@ export const _MatProgressSpinnerMixinBase = mixinColor(MatProgressSpinnerBase, ' host: { 'role': 'progressbar', 'class': 'mat-progress-spinner', - '[attr.aria-valuemin]': '_ariaValueMin', - '[attr.aria-valuemax]': '_ariaValueMax', + '[style.width.px]': '_elementSize', + '[style.height.px]': '_elementSize', + '[attr.aria-valuemin]': 'mode === "determinate" ? 0 : null', + '[attr.aria-valuemax]': 'mode === "determinate" ? 100 : null', '[attr.aria-valuenow]': 'value', '[attr.mode]': 'mode', }, @@ -85,189 +80,117 @@ export const _MatProgressSpinnerMixinBase = mixinColor(MatProgressSpinnerBase, ' encapsulation: ViewEncapsulation.None, preserveWhitespaces: false, }) -export class MatProgressSpinner extends _MatProgressSpinnerMixinBase - implements OnDestroy, CanColor { - - /** The id of the last requested animation. */ - private _lastAnimationId: number = 0; - - /** The id of the indeterminate interval. */ - private _interdeterminateInterval: number | null; +export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements CanColor, + OnChanges { - /** The SVG node that is used to draw the circle. */ - @ViewChild('path') private _path: ElementRef; - - private _mode: ProgressSpinnerMode = 'determinate'; private _value: number; + private readonly _baseSize = 100; + private readonly _baseStrokeWidth = 10; + private _fallbackAnimation = false; - /** Stroke width of the progress spinner. By default uses 10px as stroke width. */ - @Input() strokeWidth: number = PROGRESS_SPINNER_STROKE_WIDTH; + /** The width and height of the host element. Will grow with stroke width. **/ + _elementSize = this._baseSize; - /** - * Values for aria max and min are only defined as numbers when in a determinate mode. We do this - * because voiceover does not report the progress indicator as indeterminate if the aria min - * and/or max value are number values. - */ - get _ariaValueMin() { - return this.mode == 'determinate' ? 0 : null; - } + /** Tracks diameters of existing instances to de-dupe generated styles (default d = 100) */ + static diameters = new Set([100]); - get _ariaValueMax() { - return this.mode == 'determinate' ? 100 : null; + /** The diameter of the progress spinner (will set width and height of svg). */ + @Input() + get diameter(): number { + return this._diameter; } - /** @docs-private */ - get interdeterminateInterval() { - return this._interdeterminateInterval; + set diameter(size: number) { + this._setDiameterAndInitStyles(size); } - /** @docs-private */ - set interdeterminateInterval(interval: number | null) { - if (this._interdeterminateInterval) { - clearInterval(this._interdeterminateInterval); - } + _diameter = this._baseSize; - this._interdeterminateInterval = interval; - } + /** Stroke width of the progress spinner. */ + @Input() strokeWidth: number = 10; - /** - * Clean up any animations that were running. - */ - ngOnDestroy() { - this._cleanupIndeterminateAnimation(); - } + /** Mode of the progress circle */ + @Input() mode: ProgressSpinnerMode = 'determinate'; - /** Value of the progress circle. It is bound to the host as the attribute aria-valuenow. */ + /** Value of the progress circle. */ @Input() get value() { - if (this.mode == 'determinate') { - return this._value; - } - - return 0; + return this.mode === 'determinate' ? this._value : 0; } - set value(v: number) { - if (v != null && this.mode == 'determinate') { - let newValue = clamp(v); - this._animateCircle(this.value || 0, newValue); - this._value = newValue; + set value(newValue: number) { + if (newValue != null && this.mode === 'determinate') { + this._value = Math.max(0, Math.min(100, newValue)); } } - /** - * Mode of the progress circle - * - * Input must be one of the values from ProgressMode, defaults to 'determinate'. - * mode is bound to the host as the attribute host. - */ - @Input() - get mode() { return this._mode; } - set mode(mode: ProgressSpinnerMode) { - if (mode !== this._mode) { - if (mode === 'indeterminate') { - this._startIndeterminateAnimation(); - } else { - this._cleanupIndeterminateAnimation(); - this._animateCircle(0, this._value); - } - this._mode = mode; - } - } - - constructor(renderer: Renderer2, - elementRef: ElementRef, - private _ngZone: NgZone) { - super(renderer, elementRef); - } + constructor(public _renderer: Renderer2, public _elementRef: ElementRef, + platform: Platform, @Optional() @Inject(DOCUMENT) private _document: any) { + super(_renderer, _elementRef); + this._fallbackAnimation = platform.EDGE || platform.TRIDENT; - /** - * Animates the circle from one percentage value to another. - * - * @param animateFrom The percentage of the circle filled starting the animation. - * @param animateTo The percentage of the circle filled ending the animation. - * @param ease The easing function to manage the pace of change in the animation. - * @param duration The length of time to show the animation, in milliseconds. - * @param rotation The starting angle of the circle fill, with 0° represented at the top center - * of the circle. - */ - private _animateCircle(animateFrom: number, animateTo: number, ease: EasingFn = linearEase, - duration = DURATION_DETERMINATE, rotation = 0) { + // On IE and Edge, we can't animate the `stroke-dashoffset` + // reliably so we fall back to a non-spec animation. + const animationClass = this._fallbackAnimation ? + 'mat-progress-spinner-indeterminate-fallback-animation' : + 'mat-progress-spinner-indeterminate-animation'; - let id = ++this._lastAnimationId; - let startTime = Date.now(); - let changeInValue = animateTo - animateFrom; + _renderer.addClass(_elementRef.nativeElement, animationClass); + } - // No need to animate it if the values are the same - if (animateTo === animateFrom) { - this._renderArc(animateTo, rotation); - } else { - let animation = () => { - // If there is no requestAnimationFrame, skip ahead to the end of the animation. - let elapsedTime = HAS_RAF ? - Math.max(0, Math.min(Date.now() - startTime, duration)) : - duration; + ngOnChanges(changes: SimpleChanges) { + if (changes.strokeWidth || changes.diameter) { + this._elementSize = + this._diameter + Math.max(this.strokeWidth - this._baseStrokeWidth, 0); + } + } - this._renderArc( - ease(elapsedTime, animateFrom, changeInValue, duration), - rotation - ); + /** The radius of the spinner, adjusted for stroke width. */ + get _circleRadius() { + return (this.diameter - this._baseStrokeWidth) / 2; + } - // Prevent overlapping animations by checking if a new animation has been called for and - // if the animation has lasted longer than the animation duration. - if (id === this._lastAnimationId && elapsedTime < duration) { - requestAnimationFrame(animation); - } - }; + /** The view box of the spinner's svg element. */ + get _viewBox() { + return `0 0 ${this._elementSize} ${this._elementSize}`; + } - // Run the animation outside of Angular's zone, in order to avoid - // hitting ZoneJS and change detection on each frame. - this._ngZone.runOutsideAngular(animation); - } + /** The stroke circumference of the svg circle. */ + get _strokeCircumference(): number { + return 2 * Math.PI * this._circleRadius; } + /** The dash offset of the svg circle. */ + get _strokeDashOffset() { + if (this.mode === 'determinate') { + return this._strokeCircumference * (100 - this._value) / 100; + } - /** - * Starts the indeterminate animation interval, if it is not already running. - */ - private _startIndeterminateAnimation() { - let rotationStartPoint = 0; - let start = startIndeterminate; - let end = endIndeterminate; - let duration = DURATION_INDETERMINATE; - let animate = () => { - this._animateCircle(start, end, materialEase, duration, rotationStartPoint); - // Prevent rotation from reaching Number.MAX_SAFE_INTEGER. - rotationStartPoint = (rotationStartPoint + end) % 100; - let temp = start; - start = -end; - end = -temp; - }; + return null; + } - if (!this.interdeterminateInterval) { - this._ngZone.runOutsideAngular(() => { - this.interdeterminateInterval = setInterval(animate, duration + 50, 0, false); - animate(); - }); + /** Sets the diameter and adds diameter-specific styles if necessary. */ + private _setDiameterAndInitStyles(size: number): void { + this._diameter = size; + if (!MatProgressSpinner.diameters.has(this.diameter) && !this._fallbackAnimation) { + this._attachStyleNode(); } } - - /** - * Removes interval, ending the animation. - */ - private _cleanupIndeterminateAnimation() { - this.interdeterminateInterval = null; + /** Dynamically generates a style tag containing the correct animation for this diameter. */ + private _attachStyleNode(): void { + const styleTag = this._renderer.createElement('style'); + styleTag.textContent = this._getAnimationText(); + this._renderer.appendChild(this._document.head, styleTag); + MatProgressSpinner.diameters.add(this.diameter); } - /** - * Renders the arc onto the SVG element. Proxies `getArc` while setting the proper - * DOM attribute on the ``. - */ - private _renderArc(currentValue: number, rotation = 0) { - if (this._path) { - const svgArc = getSvgArc(currentValue, rotation, this.strokeWidth); - this._renderer.setAttribute(this._path.nativeElement, 'd', svgArc); - } + /** Generates animation styles adjusted for the spinner's diameter. */ + private _getAnimationText(): string { + return INDETERMINATE_ANIMATION_TEMPLATE + // Animation should begin at 5% and end at 80% + .replace(/START_VALUE/g, `${0.95 * this._strokeCircumference}`) + .replace(/END_VALUE/g, `${0.2 * this._strokeCircumference}`) + .replace(/DIAMETER/g, `${this.diameter}`); } } @@ -285,6 +208,8 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase 'role': 'progressbar', 'mode': 'indeterminate', 'class': 'mat-spinner mat-progress-spinner', + '[style.width.px]': '_elementSize', + '[style.height.px]': '_elementSize', }, inputs: ['color'], templateUrl: 'progress-spinner.html', @@ -294,84 +219,9 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase preserveWhitespaces: false, }) export class MatSpinner extends MatProgressSpinner { - constructor(elementRef: ElementRef, ngZone: NgZone, renderer: Renderer2) { - super(renderer, elementRef, ngZone); + constructor(renderer: Renderer2, elementRef: ElementRef, platform: Platform, + @Optional() @Inject(DOCUMENT) document: any) { + super(renderer, elementRef, platform, document); this.mode = 'indeterminate'; } } - - -/** - * Module functions. - */ - -/** Clamps a value to be between 0 and 100. */ -function clamp(v: number) { - return Math.max(0, Math.min(100, v)); -} - - -/** - * Converts Polar coordinates to Cartesian. - */ -function polarToCartesian(radius: number, pathRadius: number, angleInDegrees: number) { - let angleInRadians = (angleInDegrees - 90) * DEGREE_IN_RADIANS; - - return (radius + (pathRadius * Math.cos(angleInRadians))) + - ',' + (radius + (pathRadius * Math.sin(angleInRadians))); -} - - -/** - * Easing function for linear animation. - */ -function linearEase(currentTime: number, startValue: number, - changeInValue: number, duration: number) { - return changeInValue * currentTime / duration + startValue; -} - - -/** - * Easing function to match material design indeterminate animation. - */ -function materialEase(currentTime: number, startValue: number, - changeInValue: number, duration: number) { - let time = currentTime / duration; - let timeCubed = Math.pow(time, 3); - let timeQuad = Math.pow(time, 4); - let timeQuint = Math.pow(time, 5); - return startValue + changeInValue * ((6 * timeQuint) + (-15 * timeQuad) + (10 * timeCubed)); -} - - -/** - * Determines the path value to define the arc. Converting percentage values to to polar - * coordinates on the circle, and then to cartesian coordinates in the viewport. - * - * @param currentValue The current percentage value of the progress circle, the percentage of the - * circle to fill. - * @param rotation The starting point of the circle with 0 being the 0 degree point. - * @param strokeWidth Stroke width of the progress spinner arc. - * @return A string for an SVG path representing a circle filled from the starting point to the - * percentage value provided. - */ -function getSvgArc(currentValue: number, rotation: number, strokeWidth: number): string { - let startPoint = rotation || 0; - let radius = 50; - let pathRadius = radius - strokeWidth; - - let startAngle = startPoint * MAX_ANGLE; - let endAngle = currentValue * MAX_ANGLE; - let start = polarToCartesian(radius, pathRadius, startAngle); - let end = polarToCartesian(radius, pathRadius, endAngle + startAngle); - let arcSweep = endAngle < 0 ? 0 : 1; - let largeArcFlag: number; - - if (endAngle < 0) { - largeArcFlag = endAngle >= -180 ? 0 : 1; - } else { - largeArcFlag = endAngle <= 180 ? 0 : 1; - } - - return `M${start}A${pathRadius},${pathRadius} 0 ${largeArcFlag},${arcSweep} ${end}`; -} diff --git a/src/universal-app/kitchen-sink/kitchen-sink.html b/src/universal-app/kitchen-sink/kitchen-sink.html index 9e8b398b2bd9..120ca00242d8 100644 --- a/src/universal-app/kitchen-sink/kitchen-sink.html +++ b/src/universal-app/kitchen-sink/kitchen-sink.html @@ -142,6 +142,10 @@

Progress bar

+

Progress spinner

+ + +

Radio buttons