noxi雑記

.NET、Angularまわりの小ネタブログ

Angular CDK を使用して全画面ローディングを表示する

Angular Material をインストールすると必須の依存に含まれている Angular CDK (Component Dev Kit) 。 CDK に実装されている OverlayPortal 、そして Angular Material の Progress spinner を使用して全画面ローディングを作ってみます。


前提条件

この記事は使用している Angular や Angular CDK のバージョンです。

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/


Angular CLI: 7.1.4
Node: 10.14.2
OS: darwin x64
Angular: 7.1.4
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.11.4
@angular-devkit/build-angular     0.11.4
@angular-devkit/build-optimizer   0.11.4
@angular-devkit/build-webpack     0.11.4
@angular-devkit/core              7.1.4
@angular-devkit/schematics        7.1.4
@angular/cdk                      7.2.0
@angular/material                 7.2.0
@ngtools/webpack                  7.1.4
@schematics/angular               7.1.4
@schematics/update                0.11.4
rxjs                              6.3.3
typescript                        3.1.6
webpack                           4.23.1

今回実装したコードは Github 上で公開しています。

github.com

Angular CDK の Portal と Overlay

Angular CDK にはいくつかの機能が含まれていますが、今回はそのうちの OverlayPortal という2つの機能を使用します。

Overlay はその名の通り他のコンポーネントよりも前面に表示するための機能で、 Angular Material では DialogTooltip 、そして Snackbar などで使用されています。
Portal は Angular のコンポーネントやディレクティブを動的にレンダリングするための機能です。通常ですとコンポーネントやディレクティブは HTML テンプレート上に記述して使用しますが、細かな出し分けをしたり、外部から表示内容を差し替えたりすることは難しいです。 Portal を使用するととても簡単にコンポーネントやディレクティブをコードから生成することが可能になります。

実装する

それでは早速実装してみます。実装の流れとしては、

  1. プロジェクトの準備
  2. ローディングを表示するコンポーネントの実装
  3. ローディングを表示するサービスの実装
  4. ローディングのカスタマイズ

となります。

プロジェクトの準備

まずはプロジェクトの準備です。 Angular CLI を使用してプロジェクトの生成と Angular Material の追加を行います。

# Angularプロジェクト生成
ng new sample --style=scss --skip-tests --routing=false

# AngularMaterial追加
ng add @angular/material

ローディングを表示するコンポーネントの実装

プロジェクトが用意できたら、次にただくるくるとプログレススピナーを表示するだけのするだけのコンポーネントを用意します。コンポーネントの生成も Angular CLI のコマンド一発です。

ng generate component spinner-loading-indicator

生成された HTML ファイル app/spinner-loading-indicator/spinner-loading-indicator.component.html をくるくるするだけのプログレススピナーを表示するだけに変更します。

<mat-progress-spinner
  mode="indeterminate"
  color="primary"
  diameter="40"
  strokeWidth="4"
>
</mat-progress-spinner>

これだけではコンパイルエラーになってしまうため、 AppModuleプログレススピナーのモジュールを追加します。 app.module.tsNgModuleimportsMatProgressSpinnerModule を次のように追加します。

import { NgModule } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppComponent } from './app.component';
import { SpinnerLoadingIndicatorComponent } from './spinner-loading-indicator/spinner-loading-indicator.component';

export const MATERIAL_MODULES = [
  MatProgressSpinnerModule,
];

@NgModule({
  declarations: [
    AppComponent,
    SpinnerLoadingIndicatorComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,

    ...MATERIAL_MODULES,
  ],
  providers: [],
  bootstrap: [ AppComponent ]
})
export class AppModule {
}

ローディングを表示するサービスの実装

コンポーネントが実装できたら次はそれを表示するサービスを実装します。サービスも Angular CLI を使用すればサクサク生成できます。

ng generate service loading-indicator

Overlay を使用して前面にコンポーネントを描画するには、 Overlay.create メソッドを使用します。まずは生成されたサービスのコンストラクタで Overlay を受け取ります。 app/loading-indicator.service.ts のコンストラクタを変更します。

import { Overlay } from '@angular/cdk/overlay';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class LoadingIndicatorService {

  constructor(private readonly _overlay: Overlay) {
  }

}

またコンポーネントを作成した時と同じように、 AppModuleimportsOverlayModule を追加します。

import { OverlayModule } from '@angular/cdk/overlay';
import { NgModule } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppComponent } from './app.component';
import { SpinnerLoadingIndicatorComponent } from './spinner-loading-indicator/spinner-loading-indicator.component';


export const CDK_MODULES = [
  OverlayModule
];

export const MATERIAL_MODULES = [
  MatProgressSpinnerModule,
];

@NgModule({
  declarations: [
    AppComponent,
    SpinnerLoadingIndicatorComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,

    ...CDK_MODULES,
    ...MATERIAL_MODULES,
  ],
  providers: [],
  bootstrap: [ AppComponent ]
})
export class AppModule {
}

コンストラクタに Overlay を追加したら、 Overlay.create メソッドを呼び出します。呼び出す際にオーバーレイの表示オプションが指定できます。今回は全画面ローディングを実装するため、高さと幅を共に 100% とし、後ろに表示されているものに触れないようバックドロップを設定します。

private _createOverlay(): OverlayRef {
  return this._overlay.create({
    width: '100%',
    height: '100%',
    hasBackdrop: true,
    panelClass: 'app-loading-indicator',
    backdropClass: 'app-loading-indicator-backdrop',
  });
}

そして生成された OverlayRef に対して表示するコンポーネントComponentPortal を利用して生成して渡します。

export interface LoadingIndicatorRef {
  close(): void;
}

...

show(): LoadingIndicatorRef {
  const overlayRef = this._createOverlay();
  const portal = new ComponentPortal(SpinnerLoadingIndicatorComponent);
  overlayRef.attach(portal);

  return {
    close() {
      overlayRef.detach();
      overlayRef.dispose();
    }
  };
}

LoadingIndicatorRef は生成した全画面ローディングに対する参照です。 close メソッドを呼び出すとローディングを終了します。

このままこのコードを実行すると実行時エラーとなってしまいます。 HTML テンプレート以外からも利用するコンポーネント@NgModule@ComponententryComponents に含める必要があります。今回は AppModule を変更します。

@NgModule({
  declarations: [
    AppComponent,
    SpinnerLoadingIndicatorComponent
  ],
  entryComponents: [
    SpinnerLoadingIndicatorComponent
  ],
  ...
})
export class AppModule {
}

最後に、このままだと画面左上に表示されてしまうため、スタイルシートを変更します。 style.scss を開き、くるくるを中心に表示して背景を若干黒くするスタイルを追加します。

// LoadingIndicator
.app-loading-indicator {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.app-loading-indicator-backdrop {
  background-color: rgba(0, 0, 0, .28);
}

ローディングのカスタマイズ

最後に、今回実装した全画面ローディングを、呼び出し時に見た目が変更出来るようにカスタマイズします。具体的には全画面ローディング呼び出し時に、引数として設定したオプションのオブジェクトを SpinnerLoadingIndicatorComponent のコンストラクタにインジェクションします。

コンポーネントの変更

まずはコンポーネント側から変更していきます。今回は

  • スピナーの色
  • 線の幅
  • スピナーの直径

の3つをカスタマイズ可能にしたいと思いますので、次のように設定するためのインターフェースを定義します。 spinner-loading-indicator.component.ts に追記します。

import { ThemePalette } from '@angular/material';

/**
 * Indicatorを表示するときのオプション
 */
export interface SpinnerLoadingIndicatorOptions {
  color?: ThemePalette;
  diameter?: number;
  strokeWidth?: number;
}

/**
 * オプションのデフォルト値
 */
const DEFAULT_OPTIONS: SpinnerLoadingIndicatorOptions = { color: 'primary', diameter: 40, strokeWidth: 4 };

またオブジェクトをインジェクションするため、インジェクションするためのトークンを作成します。これも spinner-loading-indicator.component.ts に追記します。

/**
 * オプションを受け取るためのトークン
 */
export const SPINNER_LOADING_INDICATOR_OPTIONS = new InjectionToken<SpinnerLoadingIndicatorOptions>('SPINNER_LOADING_INDICATOR_OPTIONS');

あとはこのオプションを SpinnerLoadingIndicatorComponent のコンストラクタで受け取るだけです。 @Inject(SPINNER_LOADING_INDICATOR_OPTIONS) を指定します。またオプションが指定されない可能性も考えて @Optional() も付与します。

@Component({
  selector: 'app-spinner-loading-indicator',
  templateUrl: './spinner-loading-indicator.component.html',
  styleUrls: [ './spinner-loading-indicator.component.scss' ],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SpinnerLoadingIndicatorComponent implements OnInit {

  color: ThemePalette;
  diameter: number;
  strokeWidth: number;

  constructor(@Optional() @Inject(SPINNER_LOADING_INDICATOR_OPTIONS) options?: SpinnerLoadingIndicatorOptions) {
    options = Object.assign({}, DEFAULT_OPTIONS, options || {});
    this.color = options.color;
    this.diameter = options.diameter;
    this.strokeWidth = options.strokeWidth;
  }

  ngOnInit() {
  }

}

HTML テンプレートも受け取ったオプションの値を反映するように変更します。 spinner-loading-indicator.component.html を次のように変更します。

<mat-progress-spinner
  mode="indeterminate"
  [color]="color"
  [diameter]="diameter"
  [strokeWidth]="strokeWidth"
>
</mat-progress-spinner>

サービスの変更

サービス側の変更は、全画面ローディングを表示するメソッドの引数にオプションのオブジェクトを受け取ることと、コンポーネント生成時にオプションのオブジェクトを DI のインジェクターに含むことの2点です。

まずメソッドの引数でオプションのオブジェクトを受け取ることができるように変更します。

show(options?: SpinnerLoadingIndicatorOptions): LoadingIndicatorRef {
  const overlayRef = this._createOverlay();
  const portal = new ComponentPortal(SpinnerLoadingIndicatorComponent);
  overlayRef.attach(portal);

  return {
    close() {
      overlayRef.detach();
      overlayRef.dispose();
    }
  };
}

次にこのオプションのオブジェクトを含むインジェクターを作成します。ひとまずはアプリケーショングローバルのインジェクターをサービスのコンストラクタで取得し、これをベースとしてオプションのオブジェクトを追加する形にします。
まずはコンストラクタでアプリケーショングローバルのインジェクターを取得します。

@Injectable({
  providedIn: 'root'
})
export class LoadingIndicatorService {

  constructor(
    private readonly _injector: Injector,
    private readonly _overlay: Overlay
  ) {
  }

  ...
}

そしてこのアプリケーショングローバルのインジェクターを元にして、新しい PortalInjector を生成します。 ComponentPortal のコンストラクタの3番目の引数に PortalInjector が指定できますので、生成するコンポーネントが使用するインジェクターを指定します。

const injector = new PortalInjector(this._injector, new WeakMap([
  [SPINNER_LOADING_INDICATOR_OPTIONS, options]
]));
const portal = new ComponentPortal(SpinnerLoadingIndicatorComponent, null, injector);

ここまで実装すれば完成です。最終的なサービスのコードはこのようになりました。

import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalInjector } from '@angular/cdk/portal';
import { Injectable, Injector } from '@angular/core';
import {
  SPINNER_LOADING_INDICATOR_OPTIONS,
  SpinnerLoadingIndicatorComponent,
  SpinnerLoadingIndicatorOptions
} from './spinner-loading-indicator/spinner-loading-indicator.component';

export interface LoadingIndicatorRef {
  close(): void;
}

@Injectable({
  providedIn: 'root'
})
export class LoadingIndicatorService {

  constructor(
    private readonly _injector: Injector,
    private readonly _overlay: Overlay
  ) {
  }

show(options?: SpinnerLoadingIndicatorOptions): LoadingIndicatorRef {
  // Injectorの作成
  const injector = new PortalInjector(this._injector, new WeakMap([
    [SPINNER_LOADING_INDICATOR_OPTIONS, options]
  ]));
  const portal = new ComponentPortal(SpinnerLoadingIndicatorComponent, null, injector);

  const overlayRef = this._createOverlay();
  overlayRef.attach(portal);

  return {
    close() {
      overlayRef.detach();
      overlayRef.dispose();
    }
  };
}

  private _createOverlay(): OverlayRef {
    return this._overlay.create({
      width: '100%',
      height: '100%',
      hasBackdrop: true,
      panelClass: 'app-loading-indicator',
      backdropClass: 'app-loading-indicator-backdrop',
    });
  }
}

終わりに

この記事では CDK の OverlayPortal を使用して全画面ローディングを実装してみました。 Angular CDK には他にも仮想スクロールやドラッグ&ドロップなど、アプリケーション実装時に知っていると便利なものがいくつもあります。次回は Portal を使用して他のコンポーネントng-template を挟み込む方法を紹介します。