From 282c87f478ec20cac92d39b5cfb893e390933eea Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 19 Aug 2017 19:12:24 +0200 Subject: [PATCH 1/2] 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. --- .../_progress-spinner-theme.scss | 6 +- .../progress-spinner-module.ts | 24 +- .../progress-spinner/progress-spinner.html | 17 +- .../progress-spinner/progress-spinner.scss | 120 ++++--- .../progress-spinner/progress-spinner.spec.ts | 83 ++--- src/lib/progress-spinner/progress-spinner.ts | 324 +++--------------- 6 files changed, 164 insertions(+), 410 deletions(-) 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..4c6a51fbaf3e 100644 --- a/src/lib/progress-spinner/progress-spinner-module.ts +++ b/src/lib/progress-spinner/progress-spinner-module.ts @@ -5,28 +5,16 @@ * 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 {MatCommonModule} from '@angular/material/core'; -import { - MatProgressSpinner, - MatSpinner, - MatProgressSpinnerCssMatStyler, -} from './progress-spinner'; +import {MatProgressSpinner, MatSpinner} from './progress-spinner'; @NgModule({ imports: [MatCommonModule], - exports: [ - MatProgressSpinner, - MatSpinner, - MatCommonModule, - MatProgressSpinnerCssMatStyler - ], - declarations: [ - MatProgressSpinner, - MatSpinner, - MatProgressSpinnerCssMatStyler - ], + exports: [MatProgressSpinner, MatSpinner, MatCommonModule], + declarations: [MatProgressSpinner, 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..047be3b3ba67 100644 --- a/src/lib/progress-spinner/progress-spinner.html +++ b/src/lib/progress-spinner/progress-spinner.html @@ -4,8 +4,17 @@ 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..ce9fa5462232 100644 --- a/src/lib/progress-spinner/progress-spinner.scss +++ b/src/lib/progress-spinner/progress-spinner.scss @@ -1,51 +1,57 @@ @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; +// 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; -// 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; +$_mat-progress-spinner-radius: 45px; +$_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-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; + stroke-dasharray: $_mat-progress-spinner-circumference; + stroke-dashoffset: $_mat-progress-spinner-circumference; + transform-origin: center; + transition: stroke-dashoffset 225ms linear; + } - transition: stroke $swift-ease-in-duration $ease-in-out-curve-function; + &.mat-progress-spinner-indeterminate-animation[mode='indeterminate'] { + animation: mat-progress-spinner-linear-rotate $swift-ease-in-out-duration * 4 + linear infinite; + + circle { + // Note: we multiply the duration by 8, because the animation is spread out in 8 stages. + animation: mat-progress-spinner-stroke-rotate $swift-ease-in-out-duration * 8 + $ease-in-out-curve-function infinite; + transition-property: stroke; + } } + &.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 { + stroke-dashoffset: (1 - 0.8) * $_mat-progress-spinner-circumference; + transition-property: stroke; + } } } @@ -55,13 +61,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-circumference; // start the animation at 5% + $end: (1 - 0.8) * $_mat-progress-spinner-circumference; // end the animation at 80% + $fallback-iterations: 4; + + @keyframes mat-progress-spinner-stroke-rotate { + /* + 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..fa6464d7fe8a 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,9 @@ describe('MatProgressSpinner', () => { ProgressSpinnerWithValueAndBoundMode, ProgressSpinnerWithColor, ProgressSpinnerCustomStrokeWidth, - IndeterminateProgressSpinnerWithNgIf, - SpinnerWithNgIf, - SpinnerWithColor + SpinnerWithColor, ], - }); - - TestBed.compileComponents(); + }).compileComponents(); })); it('should apply a mode of "determinate" if no mode is provided.', () => { @@ -84,51 +79,37 @@ 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 clean up the animation when a spinner is destroyed', () => { - let fixture = TestBed.createComponent(SpinnerWithNgIf); - fixture.detectChanges(); - - let progressElement = fixture.debugElement.query(By.css('mat-spinner')); - - 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 +142,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); @@ -203,13 +167,6 @@ 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..d7c46f4c8804 100644 --- a/src/lib/progress-spinner/progress-spinner.ts +++ b/src/lib/progress-spinner/progress-spinner.ts @@ -9,54 +9,20 @@ import { Component, ChangeDetectionStrategy, - OnDestroy, Input, ElementRef, - NgZone, Renderer2, - Directive, - ViewChild, + SimpleChanges, + OnChanges, ViewEncapsulation, } from '@angular/core'; import {CanColor, mixinColor} from '@angular/material/core'; +import {Platform} from '@angular/cdk/platform'; - -// 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. +// Boilerplate for applying mixins to MdProgressSpinner. /** @docs-private */ export class MatProgressSpinnerBase { constructor(public _renderer: Renderer2, public _elementRef: ElementRef) {} @@ -73,8 +39,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 +53,55 @@ 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; - - /** The SVG node that is used to draw the circle. */ - @ViewChild('path') private _path: ElementRef; - - private _mode: ProgressSpinnerMode = 'determinate'; +export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements CanColor, OnChanges { private _value: number; + private readonly _baseSize = 100; + private readonly _baseStrokeWidth = 10; - /** Stroke width of the progress spinner. By default uses 10px as stroke width. */ - @Input() strokeWidth: number = PROGRESS_SPINNER_STROKE_WIDTH; + _elementSize = this._baseSize; + _circleRadius = 45; - /** - * 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; - } + /** Stroke width of the progress spinner. */ + @Input() strokeWidth: number = 10; - get _ariaValueMax() { - return this.mode == 'determinate' ? 100 : null; - } - - /** @docs-private */ - get interdeterminateInterval() { - return this._interdeterminateInterval; - } - /** @docs-private */ - set interdeterminateInterval(interval: number | null) { - if (this._interdeterminateInterval) { - clearInterval(this._interdeterminateInterval); - } + /** Mode of the progress circle */ + @Input() mode: ProgressSpinnerMode = 'determinate'; - this._interdeterminateInterval = interval; - } - - /** - * Clean up any animations that were running. - */ - ngOnDestroy() { - this._cleanupIndeterminateAnimation(); - } - - /** 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; - } - set value(v: number) { - if (v != null && this.mode == 'determinate') { - let newValue = clamp(v); - this._animateCircle(this.value || 0, newValue); - this._value = newValue; - } + return this.mode === 'determinate' ? this._value : 0; } - - /** - * 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; + set value(newValue: number) { + if (newValue != null && this.mode === 'determinate') { + this._value = Math.max(0, Math.min(100, newValue)); } } - constructor(renderer: Renderer2, - elementRef: ElementRef, - private _ngZone: NgZone) { + constructor(renderer: Renderer2, elementRef: ElementRef, platform: Platform) { super(renderer, elementRef); - } - - - /** - * 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) { - - let id = ++this._lastAnimationId; - let startTime = Date.now(); - let changeInValue = animateTo - animateFrom; - // 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; + // On IE and Edge we can't animate the `stroke-dashoffset` + // reliably so we fall back to a non-spec animation. + const animationClass = (platform.EDGE || platform.TRIDENT) ? + 'mat-progress-spinner-indeterminate-fallback-animation' : + 'mat-progress-spinner-indeterminate-animation'; - this._renderArc( - ease(elapsedTime, animateFrom, changeInValue, duration), - rotation - ); - - // 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); - } - }; - - // Run the animation outside of Angular's zone, in order to avoid - // hitting ZoneJS and change detection on each frame. - this._ngZone.runOutsideAngular(animation); - } + renderer.addClass(elementRef.nativeElement, animationClass); } - - /** - * 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; - }; - - if (!this.interdeterminateInterval) { - this._ngZone.runOutsideAngular(() => { - this.interdeterminateInterval = setInterval(animate, duration + 50, 0, false); - animate(); - }); + ngOnChanges(changes: SimpleChanges) { + if (changes.strokeWidth) { + this._elementSize = this._baseSize + Math.max(this.strokeWidth - this._baseStrokeWidth, 0); } } - - /** - * Removes interval, ending the animation. - */ - private _cleanupIndeterminateAnimation() { - this.interdeterminateInterval = null; - } - - /** - * 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); + _getStrokeDashOffset() { + if (this.mode === 'determinate') { + return 2 * Math.PI * this._circleRadius * (100 - this._value) / 100; } + + return null; } } @@ -285,6 +119,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 +130,8 @@ 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) { + super(renderer, elementRef, platform); 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}`; -} From 0504e4194199c00cb5be15262104fda99600028f Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Fri, 22 Sep 2017 12:14:18 -0700 Subject: [PATCH 2/2] feat(progress-spinner): add support for custom diameters --- .../progress-spinner-demo.html | 4 +- .../progress-spinner-module.ts | 15 ++- .../progress-spinner/progress-spinner.html | 12 +- .../progress-spinner/progress-spinner.scss | 21 ++-- .../progress-spinner/progress-spinner.spec.ts | 26 ++++ src/lib/progress-spinner/progress-spinner.ts | 118 +++++++++++++++--- .../kitchen-sink/kitchen-sink.html | 4 + 7 files changed, 164 insertions(+), 36 deletions(-) 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-module.ts b/src/lib/progress-spinner/progress-spinner-module.ts index 4c6a51fbaf3e..abbef18f3c5f 100644 --- a/src/lib/progress-spinner/progress-spinner-module.ts +++ b/src/lib/progress-spinner/progress-spinner-module.ts @@ -6,14 +6,21 @@ * 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} from './progress-spinner'; - @NgModule({ - imports: [MatCommonModule], - exports: [MatProgressSpinner, MatSpinner, MatCommonModule], - declarations: [MatProgressSpinner, MatSpinner], + imports: [MatCommonModule, PlatformModule], + exports: [ + MatProgressSpinner, + MatSpinner, + MatCommonModule + ], + declarations: [ + MatProgressSpinner, + MatSpinner + ], }) class MatProgressSpinnerModule {} diff --git a/src/lib/progress-spinner/progress-spinner.html b/src/lib/progress-spinner/progress-spinner.html index 047be3b3ba67..237f087fcb07 100644 --- a/src/lib/progress-spinner/progress-spinner.html +++ b/src/lib/progress-spinner/progress-spinner.html @@ -4,10 +4,11 @@ element containing the SVG. `focusable="false"` prevents IE from allowing the user to tab into the SVG element. --> + @@ -15,6 +16,9 @@ cx="50%" cy="50%" [attr.r]="_circleRadius" - [style.stroke-dashoffset.px]="_getStrokeDashOffset()" + [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"> diff --git a/src/lib/progress-spinner/progress-spinner.scss b/src/lib/progress-spinner/progress-spinner.scss index ce9fa5462232..9406f80d2848 100644 --- a/src/lib/progress-spinner/progress-spinner.scss +++ b/src/lib/progress-spinner/progress-spinner.scss @@ -5,9 +5,8 @@ $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-radius: 45px; -$_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2; - +$_mat-progress-spinner-default-radius: 45px; +$_mat-progress-spinner-default-circumference: $pi * $_mat-progress-spinner-default-radius * 2; .mat-progress-spinner { display: block; @@ -24,8 +23,6 @@ $_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2; circle { fill: transparent; - stroke-dasharray: $_mat-progress-spinner-circumference; - stroke-dashoffset: $_mat-progress-spinner-circumference; transform-origin: center; transition: stroke-dashoffset 225ms linear; } @@ -35,10 +32,11 @@ $_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2; linear infinite; circle { - // Note: we multiply the duration by 8, because the animation is spread out in 8 stages. - animation: mat-progress-spinner-stroke-rotate $swift-ease-in-out-duration * 8 - $ease-in-out-curve-function infinite; 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; } } @@ -49,7 +47,6 @@ $_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2; infinite; circle { - stroke-dashoffset: (1 - 0.8) * $_mat-progress-spinner-circumference; transition-property: stroke; } } @@ -63,11 +60,11 @@ $_mat-progress-spinner-circumference: $pi * $_mat-progress-spinner-radius * 2; } @at-root { - $start: (1 - 0.05) * $_mat-progress-spinner-circumference; // start the animation at 5% - $end: (1 - 0.8) * $_mat-progress-spinner-circumference; // end the animation at 80% + $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 { + @keyframes mat-progress-spinner-stroke-rotate-100 { /* stylelint-disable declaration-block-single-line-max-declarations, declaration-block-semicolon-space-after diff --git a/src/lib/progress-spinner/progress-spinner.spec.ts b/src/lib/progress-spinner/progress-spinner.spec.ts index fa6464d7fe8a..4fc66ebf738b 100644 --- a/src/lib/progress-spinner/progress-spinner.spec.ts +++ b/src/lib/progress-spinner/progress-spinner.spec.ts @@ -15,6 +15,7 @@ describe('MatProgressSpinner', () => { ProgressSpinnerWithValueAndBoundMode, ProgressSpinnerWithColor, ProgressSpinnerCustomStrokeWidth, + ProgressSpinnerCustomDiameter, SpinnerWithColor, ], }).compileComponents(); @@ -79,6 +80,26 @@ describe('MatProgressSpinner', () => { expect(progressComponent.value).toBe(0); }); + 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'); + + fixture.componentInstance.diameter = 32; + fixture.detectChanges(); + + 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.'); + }); + it('should allow a custom stroke width', () => { const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth); const circleElement = fixture.nativeElement.querySelector('circle'); @@ -161,6 +182,11 @@ class ProgressSpinnerCustomStrokeWidth { strokeWidth: number; } +@Component({template: ''}) +class ProgressSpinnerCustomDiameter { + diameter: number; +} + @Component({template: ''}) class IndeterminateProgressSpinner { } diff --git a/src/lib/progress-spinner/progress-spinner.ts b/src/lib/progress-spinner/progress-spinner.ts index d7c46f4c8804..0be373bf8a59 100644 --- a/src/lib/progress-spinner/progress-spinner.ts +++ b/src/lib/progress-spinner/progress-spinner.ts @@ -15,20 +15,47 @@ import { 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'; /** Possible mode for a progress spinner. */ export type ProgressSpinnerMode = 'determinate' | 'indeterminate'; -// Boilerplate for applying mixins to MdProgressSpinner. +// Boilerplate for applying mixins to MatProgressSpinner. /** @docs-private */ export class MatProgressSpinnerBase { constructor(public _renderer: Renderer2, public _elementRef: ElementRef) {} } 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. */ @@ -53,13 +80,30 @@ export const _MatProgressSpinnerMixinBase = mixinColor(MatProgressSpinnerBase, ' encapsulation: ViewEncapsulation.None, preserveWhitespaces: false, }) -export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements CanColor, OnChanges { +export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements CanColor, + OnChanges { + private _value: number; private readonly _baseSize = 100; private readonly _baseStrokeWidth = 10; + private _fallbackAnimation = false; + /** The width and height of the host element. Will grow with stroke width. **/ _elementSize = this._baseSize; - _circleRadius = 45; + + /** Tracks diameters of existing instances to de-dupe generated styles (default d = 100) */ + static diameters = new Set([100]); + + /** The diameter of the progress spinner (will set width and height of svg). */ + @Input() + get diameter(): number { + return this._diameter; + } + + set diameter(size: number) { + this._setDiameterAndInitStyles(size); + } + _diameter = this._baseSize; /** Stroke width of the progress spinner. */ @Input() strokeWidth: number = 10; @@ -78,31 +122,76 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements } } - constructor(renderer: Renderer2, elementRef: ElementRef, platform: Platform) { - super(renderer, elementRef); + constructor(public _renderer: Renderer2, public _elementRef: ElementRef, + platform: Platform, @Optional() @Inject(DOCUMENT) private _document: any) { + super(_renderer, _elementRef); - // On IE and Edge we can't animate the `stroke-dashoffset` + this._fallbackAnimation = platform.EDGE || platform.TRIDENT; + + // On IE and Edge, we can't animate the `stroke-dashoffset` // reliably so we fall back to a non-spec animation. - const animationClass = (platform.EDGE || platform.TRIDENT) ? + const animationClass = this._fallbackAnimation ? 'mat-progress-spinner-indeterminate-fallback-animation' : 'mat-progress-spinner-indeterminate-animation'; - renderer.addClass(elementRef.nativeElement, animationClass); + _renderer.addClass(_elementRef.nativeElement, animationClass); } ngOnChanges(changes: SimpleChanges) { - if (changes.strokeWidth) { - this._elementSize = this._baseSize + Math.max(this.strokeWidth - this._baseStrokeWidth, 0); + if (changes.strokeWidth || changes.diameter) { + this._elementSize = + this._diameter + Math.max(this.strokeWidth - this._baseStrokeWidth, 0); } } - _getStrokeDashOffset() { + /** The radius of the spinner, adjusted for stroke width. */ + get _circleRadius() { + return (this.diameter - this._baseStrokeWidth) / 2; + } + + /** The view box of the spinner's svg element. */ + get _viewBox() { + return `0 0 ${this._elementSize} ${this._elementSize}`; + } + + /** 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 2 * Math.PI * this._circleRadius * (100 - this._value) / 100; + return this._strokeCircumference * (100 - this._value) / 100; } return null; } + + /** 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(); + } + } + + /** 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); + } + + /** 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}`); + } } @@ -130,8 +219,9 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements preserveWhitespaces: false, }) export class MatSpinner extends MatProgressSpinner { - constructor(renderer: Renderer2, elementRef: ElementRef, platform: Platform) { - super(renderer, elementRef, platform); + constructor(renderer: Renderer2, elementRef: ElementRef, platform: Platform, + @Optional() @Inject(DOCUMENT) document: any) { + super(renderer, elementRef, platform, document); this.mode = 'indeterminate'; } } 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