Skip to content

feat(snack bar): Add enter and exit animations. #1320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 18, 2016
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 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';
};
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
4 changes: 3 additions & 1 deletion src/lib/snack-bar/snack-bar-container.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add comment explaining what the initial transform is for.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
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.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<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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

next() in addition to complete()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

});
}
}

Expand Down
51 changes: 45 additions & 6 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 @@ -102,7 +102,6 @@ describe('MdSnackBar', () => {
expect(messageElement.tagName).toBe('SPAN', 'Expected snack bar message element to be <span>');
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');
});
Expand All @@ -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', () => {
Expand All @@ -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'.`);
});
});
});

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


Expand All @@ -45,14 +41,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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add comment like

// If there is already a snackbar open, dismiss it and open a new one 
// only after the close animation is complete.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

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 +94,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