noxi雑記

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

Angular CDK Overlayで表示するComponentとデータを受け渡す

Angular CDK Overlay の4本目の記事です。前回は Backdrop を使用する方法についてでした。

noxi515.hateblo.jp

今回は Angular CDK Overlay で表示される Component に対して引数のデータを渡し、また表示された結果を呼び出し元で受け取る方法です。ざっくりと言ってしまえば Angular Material のダイアログとやることは同じです。



環境

Angular CLI: 9.1.3
Node: 12.16.2
OS: win32 x64

Angular: 9.1.3
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.901.3
@angular-devkit/build-angular     0.901.3
@angular-devkit/build-optimizer   0.901.3
@angular-devkit/build-webpack     0.901.3
@angular-devkit/core              9.1.3
@angular-devkit/schematics        9.1.3
@angular/cdk                      9.2.1
@angular/material                 9.2.1
@ngtools/webpack                  9.1.3
@schematics/angular               9.1.3
@schematics/update                0.901.3
rxjs                              6.5.5
typescript                        3.8.3
webpack                           4.42.0

今回のコードは こちら に Push しています。

Overlayで表示するComponentとデータを受け渡す

Overlayで表示するComponentにデータを渡す

まず初めに Overlay で表示している Component に対して任意のデータを初期値として渡してみます。データを渡す際には Angular の Injector と InjectionToken を使用します。

まずは Overlay を表示する機能をサービスとして切り出します。

// app/overlay/overlay.service.ts

import type { GlobalPositionStrategy, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { Overlay } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import type { ComponentRef, Type } from '@angular/core';
import { Injectable } from '@angular/core';

/**
 * Overlayを表示するサービス
 */
@Injectable({
  providedIn: 'root',
})
export class AppOverlayService {

  constructor(
    private readonly _overlay: Overlay,
  ) {
  }

  /**
   * Overlayを表示します。
   */
  show<TComponent>(component: Type<TComponent>): void {
    let positionStrategy: GlobalPositionStrategy = this._overlay.position().global();
    positionStrategy = positionStrategy.centerVertically().centerHorizontally();

    const config: OverlayConfig = {
      positionStrategy,

      width: 'auto',
      height: 'auto',
    };
    const overlayRef: OverlayRef = this._overlay.create(config);
    const componentPortal: ComponentPortal<TComponent> = new ComponentPortal(component);
    const componentRef: ComponentRef<TComponent> = overlayRef.attach(componentPortal);
  }
}

このサービスは今までは AppComponent にベタ書きしていたオーバーレイを表示する処理を、任意の Component を表示するサービスとして再実装しています。利用するには AppOverlayService をコンストラクタで受け取り、ボタンクリックイベントなどで show メソッドを呼び出します。

// app/app.component.ts

import { Component } from '@angular/core';
import { OverlayContentComponent } from './overlay-content/overlay-content.component';
import { AppOverlayService } from './overlay/overlay.service';

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

  constructor(
    private readonly _overlay: AppOverlayService,
  ) {
  }

  showOverlay() {
    this._overlay.show(OverlayContentComponent);
  }
}

続いて、引数を渡す為の準備をします。引数は InjectionToken を利用してコンストラクタインジェクションします。
動的にインジェクションを実装するには Injector を自分で作らねばなりません。 Injector の作成時には親となる Injector と追加する StaticProvider が必要になりますので、先ほど作成したサービスのコンストラクタに Injector を追加し、新しい Injector を作成します。

// app/overlay/overlay.service.ts

import type { GlobalPositionStrategy, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { Overlay } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import type { ComponentRef, Type, ValueProvider } from '@angular/core';
import { Injectable, InjectionToken, Injector } from '@angular/core';

/**
 * Overlayで表示されるComponentに渡されるデータを受け取るInjectionToken
 */
export const APP_OVERLAY_DATA = new InjectionToken<any>('APP_OVERLAY_DATA');

/**
 * Overlayを表示するサービス
 */
@Injectable({
  providedIn: 'root',
})
export class AppOverlayService {

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

  /**
   * Overlayを表示します。
   */
  show<TComponent>(component: Type<TComponent>, data?: any): void {
    let positionStrategy: GlobalPositionStrategy = this._overlay.position().global();
    positionStrategy = positionStrategy.centerVertically().centerHorizontally();

    const config: OverlayConfig = {
      positionStrategy,

      width: 'auto',
      height: 'auto',
    };
    const overlayRef: OverlayRef = this._overlay.create(config);

    // 表示するComponentに渡すデータを含むInjectorの作成
    const injector: Injector = !data ? this._injector : Injector.create({
      parent: this._injector,
      providers: [
        { provide: APP_OVERLAY_DATA, useValue: data } as ValueProvider,
      ],
    });

    // Component作成時に利用するInjectorを第3引数で指定する
    const componentPortal: ComponentPortal<TComponent> = new ComponentPortal(component, null, injector);
    const componentRef: ComponentRef<TComponent> = overlayRef.attach(componentPortal);
  }
}

サービスに引数を渡す機能を追加したので、次にサービスを呼び出す Component 側を変更します。変更すると言ってもただ引数を増やすだけですが。

// app/app.component.ts

showOverlay() {
  this._overlay.show(OverlayContentComponent, { name: 'hoge' });
}

最後に表示される Compnent 側で引数を受け取ります。引数の受け取りはコンストラクタで APP_OVERLAY_DATA InjectionToken 経由になります。呼び出し側で引数を指定していなかった場合も想定して @Optional も付けています。

// app/overlay-content/overlay-content.component.ts

import { Component, Inject, OnInit, Optional } from '@angular/core';
import { APP_OVERLAY_DATA } from '../overlay/overlay.service';

@Component({
  selector: 'app-overlay-content',
  templateUrl: './overlay-content.component.html',
  styleUrls: ['./overlay-content.component.scss'],
})
export class OverlayContentComponent implements OnInit {

  constructor(@Inject(APP_OVERLAY_DATA) @Optional() readonly data?: any) {
  }

  ngOnInit(): void {
  }

}

ここまでの実装を実行するとこのように、オーバーレイで表示される Component に対して引数が渡されていることが確認出来ます。

f:id:noxi515:20200426195126p:plain
オーバーレイで引数を渡した結果

Overlayで表示するComponentから結果を受け取る

無事表示する Component に対して表示時の引数を渡す事ができたので、続いて表示した Component から表示元に対して表示結果を返します。結果を返すには表示している Component からオーバーレイのクローズ処理を呼ぶ必要がありますので、 OverlayRef をラップしたクラスを実装して Injector に呼び出し引数と併せて詰めます。

まずは OverlayRef をラップしたクラスを作ります。このクラスでできることはオーバーレイを閉じること、閉じる際に結果を渡せること、そして閉じた際に渡された結果を外部に通知できることです。 OverlayRef#detachments は OverlayRef が detach または dispose されたことを通知する Observable です。これを購読し、 closed$ で処理結果を外部に通知することに繋げます。

app/overlay/overlay-ref.ts

import type { OverlayRef } from '@angular/cdk/overlay';
import { Observable, Subject } from 'rxjs';
import { first } from 'rxjs/operators';

export class AppOverlayRef<TComponent = any, TResult = any> {

  private readonly _closed$ = new Subject<TResult | undefined>();

  get closed$(): Observable<TResult | undefined> {
    return this._closed$.asObservable();
  }

  component!: TComponent;
  returnData?: any;

  constructor(
    private readonly _overlayRef: OverlayRef,
  ) {
    // OverlayRefに表示されているComponentがDetachされたらclosed$に値を放流する
    this._overlayRef.detachments()
      .pipe(first())
      .subscribe(() => {
        this._closed$.next(this.returnData);
        this._closed$.complete();
      });
  }

  close(data?: TResult): void {
    this.returnData = data;
    this._overlayRef.dispose();
  }
}

ラップしたクラスを実装したら、 AppOverlayService で Injector を生成している処理を変更し、 AppOverlayRef もInjectorに含めるようにします。また呼び出し側で結果を受け取ることができるように AppOverlayRef を戻り値にします。

// app/overlay/overlay.service.ts

/**
 * Overlayを表示します。
 */
show<TComponent>(component: Type<TComponent>, data?: any): AppOverlayRef {
  let positionStrategy: GlobalPositionStrategy = this._overlay.position().global();
  positionStrategy = positionStrategy.centerVertically().centerHorizontally();

  const config: OverlayConfig = {
    positionStrategy,

    width: 'auto',
    height: 'auto',
  };
  const overlayRef: OverlayRef = this._overlay.create(config);

  // 表示するComponentに渡すデータを含むInjectorの作成
  const appOverlayRef: AppOverlayRef<TComponent> = new AppOverlayRef<TComponent>(overlayRef);
  const injector: Injector = !data ? this._injector : Injector.create({
    parent: this._injector,
    providers: [
      { provide: AppOverlayRef, useValue: appOverlayRef } as ValueProvider,
      { provide: APP_OVERLAY_DATA, useValue: data } as ValueProvider,
    ],
  });

  // Component作成時に利用するInjectorを第3引数で指定する
  const componentPortal: ComponentPortal<TComponent> = new ComponentPortal(component, null, injector);
  const componentRef: ComponentRef<TComponent> = overlayRef.attach(componentPortal);

  appOverlayRef.component = componentRef.instance;

  return appOverlayRef;
}

最後に呼び出し元の AppComponent で AppOverlayRef#closed$ を購読すれば、表示した Component から呼び出し元に対して値を返す処理が完成します。

// app/app.component.ts

showOverlay() {
  const ref = this._overlay.show(OverlayContentComponent, { name: 'hoge' });
  ref.closed$.subscribe(v => this.result = v);
}

終わりに

4記事に分けて Angular CDK Overlay の簡単な使い方を記してみました。 Overlay は機能をラップしたモジュールが提供されていないため一々使うのが面倒臭かったりするのですが、 MatDialog のように Overlay をラップした Service / Module を実装してしまうと実装がスッキリするのでお勧めです。
Overlay を使うことで日付入力やドロップダウンなど対象要素のすぐ近くに表示する処理や全画面に表示するローディング、画面の固定位置に表示されるエラー表示などがとても簡単に作成できます。これを機に是非試してみてはいかがでしょうか。