Angular 2/4 - Material Design Snackbars multiple message in sequence

Viewed 21041

I have implemented a "snackbar service" that display a snackbar:

snackbar.service.ts

import { Subscription } from 'rxjs/Subscription';
import { Subject } from 'rxjs/Subject';
import { Inject, Injectable, OnDestroy } from '@angular/core';
import { MatSnackBar, MdSnackBarConfig } from '@angular/material/snack-bar';
import { MdSnackBarRef, SimpleSnackBar } from '@angular/material/snack-bar';

export class SnackBarMessage  {
  message: string;
  action: string = null;
  config: MdSnackBarConfig = null;
}

@Injectable()
export class SnackBarService implements OnDestroy
{
    private messageQueue: Subject<SnackBarMessage> = new Subject<SnackBarMessage>();
    private subscription: Subscription;
    private snackBarRef:  MdSnackBarRef<SimpleSnackBar>;


    constructor(public snackBar: MatSnackBar){
        this.subscription = this.messageQueue.subscribe(message => { 
            this.snackBarRef = this.snackBar.open(message.message, message.action, message.config);
        });
    }

    ngOnDestroy() {
        this.subscription.unsubscribe();
    }

    /**
     * Add a message
     * @param message The message to show in the snackbar.
     * @param action The label for the snackbar action.
     * @param config Additional configuration options for the snackbar.
     */
    add(message: string, action?: string, config?: MdSnackBarConfig): void{

        if ( !config ){
            config = new MdSnackBarConfig();
            config.duration = 10000;
        }

        let sbMessage = new SnackBarMessage();
        sbMessage.message = message;
        sbMessage.action = action;
        sbMessage.config = config;

        this.messageQueue.next(sbMessage);
    }
}

I want display multiple snackbars in sequence:

test.component.ts

import { Component } from '@angular/core';
import { SnackBarService } from 'app/core/services/snackbar.service';

@Component({
  selector: 'app-test',
  templateUrl: './test.component.html',
  styleUrls: ['./test.component.scss']
})
export class TestComponent {

   constructor(public snackBarService: SnackBarService) {
     this.snackBarService.add('A');
     this.snackBarService.add('B');
     this.snackBarService.add('C');
   }
}

But all message are displayed at same time (overlapping).

How can I wait for a snackBar afterDismissed for display a new message into messageQueue?

7 Answers

As @Aamir Khan pointed out - using afterDismissed, I have tweaked your code a bit.

  showNext() {
  if (this.msgQueue === 0) {
    return;
  }

  let message = this.msgQueue.shift();
  this.isInstanceVisible = true;
  this.snackBarRef = this.snackBar.open(message.message, message.action, {duration: 2000});
  this.snackBarRef.afterDismissed().subscribe(() => {
    this.isInstanceVisible = false;
    this.showNext();
  });
}

And inside add() added this -

this.msgQueue.push(sbMessage);
if (!this.isInstanceVisible) {
     this.showNext(); 
}

Plunker

Caution - Its kind of a dirty and non standard way, not an ideal user experience (IMO), above code might have some memory leaks and race conditions, due to usage of flags.

You can achieve this by making these simple changes :

this.snackBarService.add(['A','B','C']); // pass messages as array

add(messages: Array<string>, action?: string, config?: MdSnackBarConfig): void{
    if ( !config ){
        config = new MdSnackBarConfig();
        config.duration = 10000;
    }

    let sbMessage = new SnackBarMessage();
    sbMessage.message = message;
    sbMessage.action = action;
    sbMessage.config = config;

    messages.forEach((message,index) => {
        setTimeout((message) => {
            sbMessage.message = message;
            this.messageQueue.next(sbMessage);
        },(config.duration*index);
    })
}

you need to use time out method. hide snackbar in specific time and open another snackbar using timeout function

constructor(public snackBarService: SnackBarService) {
          this.snackBarService.add('A')
          setTimeout(this.snackBarService.add('B'),10000);
          setTimeout(this.snackBarService.add('C'),20000);
       }

Here is @Ankit's Plunker broken out here.

import {Subscription} from 'rxjs';
import {Injectable, OnDestroy} from '@angular/core';
import {MatSnackBar, MatSnackBarConfig} from '@angular/material/snack-bar';
import {MatSnackBarRef, SimpleSnackBar} from '@angular/material/snack-bar';
import {SnackBarMessage} from './snackBarMessage.model';

@Injectable()
export class NotificationService implements OnDestroy {
  private messageQueue: Array<any> = Array<any>();
  private subscription: Subscription;
  private snackBarRef: MatSnackBarRef<SimpleSnackBar>;
  private isInstanceVisible = false;

  constructor(public snackBar: MatSnackBar) {}

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
  /**
   * Add a message
   * @param message The message to show in the snackbar.
   * @param action The label for the snackbar action.
   * @param config Additional configuration options for the snackbar.
   * @param classOverride Adds a css class on the snackbar so you can add color.

   */
  show(
    message: string,
    action?: string,
    config?: MatSnackBarConfig,
    classOverride: string = 'blue-snackbar'
  ): void {
    if (!config) {
      config = new MatSnackBarConfig();
      config.duration = 3000;
      config.verticalPosition = 'bottom';
      config.horizontalPosition = 'end';
      config.panelClass = [classOverride];
    }

    const sbMessage = new SnackBarMessage();
    sbMessage.message = message;
    sbMessage.action = action;
    sbMessage.config = config;

    this.messageQueue.push(sbMessage);

    if (!this.isInstanceVisible) {
      this.showNext();
    }
  }

  private showNext() {
    if (this.messageQueue.length === 0) {
      return;
    }

    const message = this.messageQueue.shift();
    this.isInstanceVisible = true;

    this.snackBarRef = this.snackBar.open(
      message.message,
      message.action,
      message.config
    );

    this.snackBarRef.afterDismissed().subscribe(() => {
      this.isInstanceVisible = false;
      this.showNext();
    });
  }
}

Here is my solution

import {Injectable, NgZone, OnDestroy} from '@angular/core';
import {MatSnackBar, MatSnackBarRef, TextOnlySnackBar} from '@angular/material/snack-bar';
import {BehaviorSubject, EMPTY} from 'rxjs';
import {concatMap} from 'rxjs/operators';


interface ToasterMessage {
  type: ToasterMessageType;
  message: string;
}

@Injectable({
  providedIn: 'root'
})
export class SnackService implements OnDestroy {

  private toastSteam: BehaviorSubject<ToasterMessage | {}> = new BehaviorSubject<ToasterMessage | {}>({});
  private toastStream$ = this.toastSteam.asObservable();

  constructor(private snackBar: MatSnackBar, private zone: NgZone) {
    // for each toast message we map an observable, and we concat to wait for the previous
    // to finish. We can use mergeMap or switchMap if we want other behavior
    this.toastStream$.pipe(
      concatMap((toast: ToasterMessage | {}) => {
        if (!('type' in toast)) {
          return EMPTY;
        }
        return this.handleToastMessage(toast).afterDismissed();
      })
    ).subscribe((_) => {
    });
  }


  openSnackBar(message: string,
               action: string | undefined,
               color: string = 'green-snackbar'): MatSnackBarRef<TextOnlySnackBar> {
    return this.zone.run(() => {
      const snackBarRef = this.snackBar.open(message, action, {
        duration: 4000,
        panelClass: color,
        horizontalPosition: 'center',
      });
      snackBarRef.onAction().subscribe(() => snackBarRef.dismiss());
      return snackBarRef;
    });
  }

  // just a helper method to make code simpler
  handleToastMessage(toast: ToasterMessage): MatSnackBarRef<TextOnlySnackBar> {
    return this.openSnackBar(toast.message, 'x', `${toast.type}-snackbar`);
  }

  addToast(toast: ToasterMessage): void {
    this.toastSteam.next(toast);
  }

  ngOnDestroy(): void {
    this.toastSteam.next({});
    this.toastSteam.complete();
  }

}

list of messages:

messages = [
  {
    text: "This is message 1",
  },
  {
    text: "This is message 2",
  }

];

loop through them and display one message after another

message.forEach( (message, index) => {

        setTimeout(() => {

            this.snackBar.open(message.text, action, {
                duration: this.timeOut,
                verticalPosition: 'bottom', // 'top' | 'bottom'
                horizontalPosition: 'end', //'start' | 'center' | 'end' | 'left' | 'right'
                panelClass: [className],
            });


        }, index * (this.timeOut+500)); // 500 - timeout between two messages

    });

demo:

https://www.coditty.com/code/angular-material-display-multiple-snackbars-messages-in-sequence

Facing the same issue with another requirement, the calling function needs to be informed when all snackbars were closed, I finally went with this solution using recursion:

The calling method:

testFunction(): void {
    this.showMsgs().subscribe(() => {
        // Do stuff after all messages were closed
    }
}

And the function that is showing all messages one after the other and letting the caller function that all the messengers were closed:

showMsgs(): Observable<any> {
    if (this.msgList.length) {
        const msg = this.msgList.pop();
        let closedEvent: Observable<any>;
        // If there are other msgs they should be shown first
        if (this.msgList.length) {
            closedEvent = this.showMsgs().pipe(
                switchMap(() => {
                    return this.snacBar.open(msg, 'Ok', {
                        verticalPosition: 'top' }).afterDismissed();
                }));
        } else { 
            // This is the only msg on the list, show it and return the close subscription
            closedEvent = this.snacBar.open(msg, 'Ok', {
                verticalPosition: 'top' }).afterDismissed();
        }
        return closedEvent;
    }
Related