noxi雑記

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

Angular CDK の Portal で部分的に要素を差し込む

Angular のコンポーネントに対して外のコンポーネントから表示する要素を指定したい場合、 ng-content を使用すると自身の子要素として指定された要素を展開することができます。子要素を展開したい箇所が1つだけであれば ng-content を使用すれば十分ですが、複数箇所あると ng-content では対応できません。こんな時は Angular CDK に実装されている Portal を使用することで複数箇所に子要素を展開することができます。


前提条件

この記事は使用している 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

実装してみる

今回実装するのはこんな感じのツールバーです。

f:id:noxi515:20181227163411p:plain

見た目は MatToolbar そのものですが、左端にタイトルがあり、右端にメニューがあります。この右端のメニュー部分を外部から要素を指定できるように実装します。図にもあるとおり、ツールバー上に表示されているメニューボタン部分と…ボタンを押すと表示される隠れたメニューボタンの2箇所を指定できるようにします。

Portal を使用して子要素をレンダリングするには CdkPortalcdkPortalOutlet を使用します。 CdkPortal<ng-template> と共に使用し UI パーツに名前を付ける役割を担い、 cdkPortalOutlet はその CdkPortalレンダリングする箇所に指定します。

今回の実装の流れは

  1. プロジェクトの作成
  2. CdkPortal ディレクティブの実装
  3. ツールバーコンポーネントの実装
  4. 利用方法

となります。

プロジェクトの作成

プロジェクトの作成は Angular CLI を使用して行います。

ng new angular-portal-render-component --routing=false --style=scss --skip-tests
angular-portal-render-component
ng add @angular/material

これで Angular Material が追加された状態の Angular プロジェクトが生成されます。

CdkPortal ディレクティブの実装

CdkPortal ディレクティブの実装はとても簡単です。 CdkPortal を継承したディレクティブを作ればいいだけだからです。
まずはディレクティブを CLI を使用して生成します。今回は2箇所を指定できるように作るため、2個作ります。

ng generate directive toolbar-menu
ng generate directive toolbar-menu-more

そしてそれぞれを CdkPortal を継承する形に変更します。 toolbar-menu の方は app/toolbar-menu.directive.ts を次のようにします。

import { CdkPortal } from '@angular/cdk/portal';
import { Directive } from '@angular/core';

@Directive({
  selector: '[appToolbarMenu]'
})
export class ToolbarMenuDirective extends CdkPortal {
}

toolbar-menu-more の方はこれと全く同じなため省略します。

ツールバーコンポーネントの実装

雛形となるコンポーネントの生成は CLI で行います。

ng generate component toolbar

生成されたらまずは TS ファイルから変更します。 app/toolbar/toolbar.component.ts を開き、メニューボタンと…メニューボタンの中身をそれぞれ受け取るたえの @ContentChild を付与したフィールドを追加します。 @ContentChild の引数に設定するのは先ほど生成した2つのディレクティブ ToolbarMenuDirectiveToolbarMenuMoreDirective です。次のコードでは表示するタイトルも変更出来るように title@Input に指定しています。

import { ChangeDetectionStrategy, Component, ContentChild, Input, OnInit } from '@angular/core';
import { ToolbarMenuMoreDirective } from '../toolbar-menu-more.directive';
import { ToolbarMenuDirective } from '../toolbar-menu.directive';

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

  /**
   * タイトル
   */
  @Input()
  title = '';

  /**
   * メニューボタン群
   */
  @ContentChild(ToolbarMenuDirective)
  menuButtons?: ToolbarMenuDirective;

  /**
   * Moreなメニューボタン群
   */
  @ContentChild(ToolbarMenuMoreDirective)
  moreMenuButtons?: ToolbarMenuMoreDirective;

  constructor() {
  }

  ngOnInit() {
  }

}

次に HTML を変更します。 cdkPortalOutletCdkPortalレンダリングしたい箇所で <ng-template> または <ng-container> と一緒に利用します。 app/toolbar/toolbar.component.html をこのようにすると、最初の画像のようなツールバーレンダリングできるようになります。

<mat-toolbar color="primary">

  <!-- タイトル -->
  <span class="title">{{title}}</span>

  <span class="spacer"></span>

  <!-- メニューボタン群 -->
  <ng-container [cdkPortalOutlet]="menuButtons" *ngIf="!!menuButtons"></ng-container>

  <!-- ...メニュー -->
  <ng-container *ngIf="!!moreMenuButtons" >

    <button mat-icon-button [mat-menu-trigger-for]="menuMore">
      <mat-icon>more_vert</mat-icon>
    </button>

    <mat-menu #menuMore>
      <ng-container [cdkPortalOutlet]="moreMenuButtons"></ng-container>
    </mat-menu>

  </ng-container>

</mat-toolbar>

このままだとタイトルとメニューボタンとの間が詰まってしまうため、少し CSS も追加します。 app/toolbar/toolbar.component.scss に以下の行を追加します。

.spacer {
  flex-grow: 1;
}

最後に AppModule に不足しているモジュールを追加します。今回使用したモジュールは CDK の PortalModule 、 Material の MatMenuModule MatToolbarModule です。ついでにポータルで表示予定のボタンとアイコンの MatButtonModule MatIconModule も併せて追加します。

import { PortalModule } from '@angular/cdk/portal';
import { NgModule } from '@angular/core';
import { MatButtonModule, MatIconModule, MatMenuModule, MatToolbarModule } from '@angular/material';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';


export const CDK_MODULES = [
  PortalModule,
];

export const MATERIAL_MODULES = [
  MatButtonModule,
  MatIconModule,
  MatMenuModule,
  MatToolbarModule,
];

@NgModule({
  ...
  imports: [
    BrowserModule,
    BrowserAnimationsModule,

    ...CDK_MODULES,
    ...MATERIAL_MODULES
  ],
  ...
})
export class AppModule {
}

利用方法

今回作成したコンポーネントとディレクティブを使用するには、コンポーネントの子要素として <ng-template> を追加し、この <ng-template> に対してディレクティブを付与します。メニューボタンと…ボタンの中に編集と削除のボタンをそれぞれ配置してみます。

<app-toolbar [title]="title">

  <!-- メニューボタン -->
  <ng-template appToolbarMenu>
    <button mat-icon-button>
      <mat-icon>edit</mat-icon>
    </button>
    <button mat-icon-button>
      <mat-icon>delete</mat-icon>
    </button>
  </ng-template>

  <!-- Moreメニューの中のメニューボタン -->
  <ng-template appToolbarMenuMore>
    <button mat-menu-item>
      <mat-icon>edit</mat-icon>
      <span>Edit</span>
    </button>
    <button mat-menu-item>
      <mat-icon>delete</mat-icon>
      <span>Delete</span>
    </button>
  </ng-template>

</app-toolbar>

これが最初の画像の HTML となります。