Skip to content

Commit

Permalink
Add enter and exit animation to MdSnackBar.
Browse files Browse the repository at this point in the history
  • Loading branch information
josephperrott committed Oct 3, 2016
1 parent 5f3a35f commit 37cead1
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 23 deletions.
13 changes: 13 additions & 0 deletions src/lib/core/animation/animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class AnimationCurves {
static get standardCurve(): string { return 'cubic-bezier(0.4,0.0,0.2,1)'; }
static get decelerationCurve(): string { return 'cubic-bezier(0.0,0.0,0.2,1)'; }
static get accelerationCurve(): string { return 'cubic-bezier(0.4,0.0,1,1)'; }
static get sharpCurve(): string { return 'cubic-bezier(0.4,0.0,0.6,1)'; }
};


export class AnimationDurations {
static get complex(): string { return '375ms'; }
static get entering(): string { return '225ms'; }
static get exiting(): string { return '195ms'; }
};
3 changes: 3 additions & 0 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions src/lib/snack-bar/snack-bar-container.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ $md-snack-bar-max-width: 568px !default;
min-width: $md-snack-bar-min-width;
overflow: hidden;
padding: $md-snack-bar-padding;
// Initial transformation is applied to start snack bar out of view.
transform: translateY(100%);
}
70 changes: 65 additions & 5 deletions src/lib/snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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.decelerationCurve}`)),
transition('initial => visible, void => visible',
animate(`${AnimationDurations.entering} ${AnimationCurves.accelerationCurve}`)),
])
],
})
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<any> = 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<T>(portal: ComponentPortal<T>): ComponentRef<T> {
if (this._portalHost.hasAttached()) {
throw new MdSnackBarContentAlreadyAttached();
Expand All @@ -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<string, any> {
throw Error('Not yet implemented');
}

/** Begin animation of the snack bar exiting from view. */
exit(): Observable<void> {
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';
}
}
16 changes: 13 additions & 3 deletions src/lib/snack-bar/snack-bar-ref.ts
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -12,19 +13,28 @@ export class MdSnackBarRef<T> {
/** 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<any> = 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();
});
}
}

Expand Down
50 changes: 45 additions & 5 deletions src/lib/snack-bar/snack-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"');
});
Expand Down Expand Up @@ -120,10 +120,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', () => {
Expand All @@ -136,7 +137,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'.`);
});
});
});

Expand Down
24 changes: 15 additions & 9 deletions src/lib/snack-bar/snack-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ 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.


Expand All @@ -45,14 +44,24 @@ export class MdSnackBar {
*/
openFromComponent<T>(component: ComponentType<T>,
config: MdSnackBarConfig): MdSnackBarRef<T> {
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;
}

/**
Expand Down Expand Up @@ -88,10 +97,7 @@ export class MdSnackBar {
overlayRef: OverlayRef): MdSnackBarRef<T> {
let portal = new ComponentPortal(component);
let contentRef = container.attachComponentPortal(portal);
let snackBarRef = <MdSnackBarRef<T>> new MdSnackBarRef(contentRef.instance, overlayRef);

this._snackBarRef = snackBarRef;
return snackBarRef;
return new MdSnackBarRef(contentRef.instance, container, overlayRef);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/lib/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@
"skipTemplateCodegen": true,
"debug": true
}
}
}

0 comments on commit 37cead1

Please sign in to comment.