diff --git a/src/lib/core/animation/animation.ts b/src/lib/core/animation/animation.ts new file mode 100644 index 000000000000..f54cf037638c --- /dev/null +++ b/src/lib/core/animation/animation.ts @@ -0,0 +1,13 @@ +export class AnimationCurves { + static STANDARD_CURVE = 'cubic-bezier(0.4,0.0,0.2,1)'; + static DECELERATION_CURVE = 'cubic-bezier(0.0,0.0,0.2,1)'; + static ACCELERATION_CURVE = 'cubic-bezier(0.4,0.0,1,1)'; + static SHARP_CURVE = 'cubic-bezier(0.4,0.0,0.6,1)'; +}; + + +export class AnimationDurations { + static COMPLEX = '375ms'; + static ENTERING = '225ms'; + static EXITING = '195ms'; + }; diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index acca0d89007a..ec467525210b 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -79,6 +79,9 @@ export {ComponentType} from './overlay/generic-component-type'; // Keybindings export * from './keyboard/keycodes'; +// Animation +export * from './animation/animation'; + @NgModule({ imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule], diff --git a/src/lib/snack-bar/snack-bar-container.scss b/src/lib/snack-bar/snack-bar-container.scss index 48a12d1fc237..aa9f1d69ebe0 100644 --- a/src/lib/snack-bar/snack-bar-container.scss +++ b/src/lib/snack-bar/snack-bar-container.scss @@ -10,11 +10,13 @@ $md-snack-bar-max-width: 568px !default; @include md-elevation(24); background: #323232; border-radius: 2px; + box-sizing: content-box; display: block; height: $md-snack-bar-height; max-width: $md-snack-bar-max-width; min-width: $md-snack-bar-min-width; overflow: hidden; padding: $md-snack-bar-padding; - box-sizing: content-box; + // Initial transformation is applied to start snack bar out of view. + transform: translateY(100%); } diff --git a/src/lib/snack-bar/snack-bar-container.ts b/src/lib/snack-bar/snack-bar-container.ts index 3ef2d71fc592..805cad03b2b9 100644 --- a/src/lib/snack-bar/snack-bar-container.ts +++ b/src/lib/snack-bar/snack-bar-container.ts @@ -1,18 +1,32 @@ import { Component, ComponentRef, - ViewChild + ViewChild, + trigger, + state, + style, + transition, + animate, + AnimationTransitionEvent, + NgZone } from '@angular/core'; import { BasePortalHost, ComponentPortal, TemplatePortal, - PortalHostDirective + PortalHostDirective, + AnimationCurves, + AnimationDurations, } from '../core'; import {MdSnackBarConfig} from './snack-bar-config'; import {MdSnackBarContentAlreadyAttached} from './snack-bar-errors'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; + +export type SnackBarState = 'initial' | 'visible' | 'complete' | 'void'; + /** * Internal component that wraps user-provided snack bar content. */ @@ -22,17 +36,40 @@ import {MdSnackBarContentAlreadyAttached} from './snack-bar-errors'; templateUrl: 'snack-bar-container.html', styleUrls: ['snack-bar-container.css'], host: { - 'role': 'alert' - } + 'role': 'alert', + '[@state]': 'animationState', + '(@state.done)': 'markAsExited($event)' + }, + animations: [ + trigger('state', [ + state('initial', style({transform: 'translateY(100%)'})), + state('visible', style({transform: 'translateY(0%)'})), + state('complete', style({transform: 'translateY(100%)'})), + transition('visible => complete', + animate(`${AnimationDurations.EXITING} ${AnimationCurves.DECELERATION_CURVE}`)), + transition('initial => visible, void => visible', + animate(`${AnimationDurations.ENTERING} ${AnimationCurves.ACCELERATION_CURVE}`)), + ]) + ], }) export class MdSnackBarContainer extends BasePortalHost { /** The portal host inside of this container into which the snack bar content will be loaded. */ @ViewChild(PortalHostDirective) _portalHost: PortalHostDirective; + /** Subject for notifying that the snack bar has exited from view. */ + private _onExit: Subject = new Subject(); + + /** The state of the snack bar animations. */ + animationState: SnackBarState = 'initial'; + /** The snack bar configuration. */ snackBarConfig: MdSnackBarConfig; - /** Attach a portal as content to this snack bar container. */ + constructor(private _ngZone: NgZone) { + super(); + } + + /** Attach a component portal as content to this snack bar container. */ attachComponentPortal(portal: ComponentPortal): ComponentRef { if (this._portalHost.hasAttached()) { throw new MdSnackBarContentAlreadyAttached(); @@ -41,7 +78,30 @@ export class MdSnackBarContainer extends BasePortalHost { return this._portalHost.attachComponentPortal(portal); } + /** Attach a template portal as content to this snack bar container. */ attachTemplatePortal(portal: TemplatePortal): Map { throw Error('Not yet implemented'); } + + /** Begin animation of the snack bar exiting from view. */ + exit(): Observable { + this.animationState = 'complete'; + return this._onExit.asObservable(); + } + + /** Mark snack bar as exited from the view. */ + markAsExited(event: AnimationTransitionEvent) { + if (event.fromState === 'visible' && + (event.toState === 'void' || event.toState === 'complete')) { + this._ngZone.run(() => { + this._onExit.next(); + this._onExit.complete(); + }); + } + } + + /** Begin animation of snack bar entrance into view. */ + enter(): void { + this.animationState = 'visible'; + } } diff --git a/src/lib/snack-bar/snack-bar-ref.ts b/src/lib/snack-bar/snack-bar-ref.ts index 10453bdf772d..5b088df78c4f 100644 --- a/src/lib/snack-bar/snack-bar-ref.ts +++ b/src/lib/snack-bar/snack-bar-ref.ts @@ -1,6 +1,7 @@ import {OverlayRef} from '../core'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; +import {MdSnackBarContainer} from './snack-bar-container'; // TODO(josephperrott): Implement onAction observable. @@ -12,19 +13,28 @@ export class MdSnackBarRef { /** The instance of the component making up the content of the snack bar. */ readonly instance: T; + /** The instance of the component making up the content of the snack bar. */ + readonly containerInstance: MdSnackBarContainer; + /** Subject for notifying the user that the snack bar has closed. */ private _afterClosed: Subject = new Subject(); - constructor(instance: T, private _overlayRef: OverlayRef) { + constructor(instance: T, + containerInstance: MdSnackBarContainer, + private _overlayRef: OverlayRef) { // Sets the readonly instance of the snack bar content component. this.instance = instance; + this.containerInstance = containerInstance; } /** Dismisses the snack bar. */ dismiss(): void { if (!this._afterClosed.closed) { - this._overlayRef.dispose(); - this._afterClosed.complete(); + this.containerInstance.exit().subscribe(() => { + this._overlayRef.dispose(); + this._afterClosed.next(); + this._afterClosed.complete(); + }); } } diff --git a/src/lib/snack-bar/snack-bar.spec.ts b/src/lib/snack-bar/snack-bar.spec.ts index 574346af4470..65a9a11ef18e 100644 --- a/src/lib/snack-bar/snack-bar.spec.ts +++ b/src/lib/snack-bar/snack-bar.spec.ts @@ -16,6 +16,7 @@ import {OverlayContainer} from '../core'; import {MdSnackBarConfig} from './snack-bar-config'; import {SimpleSnackBar} from './simple-snack-bar'; +// TODO(josephperrott): Update tests to mock waiting for time to complete for animations. describe('MdSnackBar', () => { let snackBar: MdSnackBar; @@ -56,7 +57,6 @@ describe('MdSnackBar', () => { snackBar.open(simpleMessage, simpleActionLabel, config); let containerElement = overlayContainerElement.querySelector('snack-bar-container'); - expect(containerElement.getAttribute('role')) .toBe('alert', 'Expected snack bar container to have role="alert"'); }); @@ -102,7 +102,6 @@ describe('MdSnackBar', () => { expect(messageElement.tagName).toBe('SPAN', 'Expected snack bar message element to be '); expect(messageElement.textContent) .toBe(simpleMessage, `Expected the snack bar message to be '${simpleMessage}''`); - expect(overlayContainerElement.querySelector('button.md-simple-snackbar-action')) .toBeNull('Expected the query selection for action label to be null'); }); @@ -120,10 +119,11 @@ describe('MdSnackBar', () => { .toBeGreaterThan(0, 'Expected overlay container element to have at least one child'); snackBarRef.dismiss(); - - expect(dismissed).toBeTruthy('Expected the snack bar to be dismissed'); - expect(overlayContainerElement.childElementCount) - .toBe(0, 'Expected the overlay container element to have no child elements'); + snackBarRef.afterDismissed().subscribe(null, null, () => { + expect(dismissed).toBeTruthy('Expected the snack bar to be dismissed'); + expect(overlayContainerElement.childElementCount) + .toBe(0, 'Expected the overlay container element to have no child elements'); + }); }); it('should open a custom component', () => { @@ -136,7 +136,46 @@ describe('MdSnackBar', () => { expect(overlayContainerElement.textContent) .toBe('Burritos are on the way.', `Expected the overlay text content to be 'Burritos are on the way'`); + }); + + it('should set the animation state to visible on entry', () => { + let config = new MdSnackBarConfig(testViewContainerRef); + let snackBarRef = snackBar.open(simpleMessage, null, config); + viewContainerFixture.detectChanges(); + expect(snackBarRef.containerInstance.animationState) + .toBe('visible', `Expected the animation state would be 'visible'.`); + }); + + it('should set the animation state to complete on exit', () => { + let config = new MdSnackBarConfig(testViewContainerRef); + let snackBarRef = snackBar.open(simpleMessage, null, config); + snackBarRef.dismiss(); + + viewContainerFixture.detectChanges(); + expect(snackBarRef.containerInstance.animationState) + .toBe('complete', `Expected the animation state would be 'complete'.`); + }); + + it(`should set the old snack bar animation state to complete and the new snack bar animation + state to visible on entry of new snack bar`, () => { + let config = new MdSnackBarConfig(testViewContainerRef); + let snackBarRef = snackBar.open(simpleMessage, null, config); + + viewContainerFixture.detectChanges(); + expect(snackBarRef.containerInstance.animationState) + .toBe('visible', `Expected the animation state would be 'visible'.`); + + let config2 = new MdSnackBarConfig(testViewContainerRef); + let snackBarRef2 = snackBar.open(simpleMessage, null, config2); + + viewContainerFixture.detectChanges(); + snackBarRef.afterDismissed().subscribe(null, null, () => { + expect(snackBarRef.containerInstance.animationState) + .toBe('complete', `Expected the animation state would be 'complete'.`); + expect(snackBarRef2.containerInstance.animationState) + .toBe('visible', `Expected the animation state would be 'visible'.`); + }); }); }); diff --git a/src/lib/snack-bar/snack-bar.ts b/src/lib/snack-bar/snack-bar.ts index 15f90f83df53..e2f2c5003c4a 100644 --- a/src/lib/snack-bar/snack-bar.ts +++ b/src/lib/snack-bar/snack-bar.ts @@ -21,10 +21,6 @@ import {MdSnackBarRef} from './snack-bar-ref'; import {MdSnackBarContainer} from './snack-bar-container'; import {SimpleSnackBar} from './simple-snack-bar'; -export {MdSnackBarRef} from './snack-bar-ref'; -export {MdSnackBarConfig} from './snack-bar-config'; - -// TODO(josephperrott): Animate entrance and exit of snack bars. // TODO(josephperrott): Automate dismiss after timeout. @@ -45,14 +41,24 @@ export class MdSnackBar { */ openFromComponent(component: ComponentType, config: MdSnackBarConfig): MdSnackBarRef { - if (this._snackBarRef) { - this._snackBarRef.dismiss(); - } let overlayRef = this._createOverlay(); let snackBarContainer = this._attachSnackBarContainer(overlayRef, config); let mdSnackBarRef = this._attachSnackbarContent(component, snackBarContainer, overlayRef); + + // If a snack bar is already in view, dismiss it and enter the new snack bar after exit + // animation is complete. + if (this._snackBarRef) { + this._snackBarRef.afterDismissed().subscribe(() => { + mdSnackBarRef.containerInstance.enter(); + }); + this._snackBarRef.dismiss(); + // If no snack bar is in view, enter the new snack bar. + } else { + mdSnackBarRef.containerInstance.enter(); + } this._live.announce(config.announcementMessage, config.politeness); - return mdSnackBarRef; + this._snackBarRef = mdSnackBarRef; + return this._snackBarRef; } /** @@ -88,10 +94,7 @@ export class MdSnackBar { overlayRef: OverlayRef): MdSnackBarRef { let portal = new ComponentPortal(component); let contentRef = container.attachComponentPortal(portal); - let snackBarRef = > new MdSnackBarRef(contentRef.instance, overlayRef); - - this._snackBarRef = snackBarRef; - return snackBarRef; + return new MdSnackBarRef(contentRef.instance, container, overlayRef); } /**