16
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Angular] ngComponentOutlet を使ってタブを実装する

Last updated at Posted at 2018-08-06

はじめに

Angular でタブによるコンポーネントの切り替えを実装したい。
要件は

  • なるべくタブの管理を抽象化したい
  • つまり 「ngIf とかでコンポーネントを囲って切り替える」といったことはしたくない

で、これを実現するために NgComponentOutlet を使ってタブを実装する。
NgComponentOutlet の特徴は コンポーネントを動的に切り替えることができる ことで、今回これで得られるメリットとしては

  • メリット
    • 要件にある「タブ切り替えを ngIf を使って実装しない」を満たせる
    • 上記が満たせるということは、タブが増えるたびに ngIf を増やさなくて済む
    • 関連して、タブ切り替えのための HTML タグも単純化できる

逆にデメリットは

  • デメリット
    • NgComponentOutlet は普段使うことのないであろう( と勝手に思っている )ディレクティブなのでとっつきづらい
    • 抽象化することで、パッと見ただけでは「なにをしているのか」を把握するのが若干難しくなる

といった点が挙げられる。

更新情報

2021/01/11

  • 記事内で扱ったコードを Angular v11.0.5 で確認しました

作業環境

環境 バージョン 備考
Angular CLI v6.1.3 v11.0.5 $ ng --version
Angular v6.1.2 v11.0.5 同上
TypeScript v4.0.2 同上
Node.js v9.4.0 v12.18.3 $ node --version
npm v6.1.0 v6.14.6 $ npm --version
ng version の結果
$ ng version

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

Angular CLI: 11.0.5
Node: 12.18.3
OS: darwin x64

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

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1100.5
@angular-devkit/build-angular   0.1100.5
@angular-devkit/core            11.0.5
@angular-devkit/schematics      11.0.5
@schematics/angular             11.0.5
@schematics/update              0.1100.5
rxjs                            6.6.0
typescript                      4.0.2

本記事で扱う構成

作成にあたってのアプリの構成は次の通り。

アプリの構成
src/
  └ app/
    ├ component/
    │    ├ switch-tab/                       # タブによて読み込むコンポーネントを動的に切り替える
    │    │    ├ switch-tab.component.css
    │    │    ├ switch-tab.component.html
    │    │    └ switch-tab.component.ts
    │    ├ tab-a/                            # タブとして扱われるコンポーネント-A
    │    │    ├ tab-a.component.css
    │    │    ├ tab-a.component.html
    │    │    └ tab-a.component.ts
    │    └ tab-b/                            # タブとして扱われるコンポーネント-B
    │         ├ tab-b.component.css
    │         ├ tab-b.component.html
    │         └ tab-b.component.ts
    ├ model/                                 # switch-tab.service で管理する配列の型となるモデル
    │    └ tab-model.ts
    ├ service/                               # タブを管理するサービス
    │    └ switch-tab.service.ts
    ├ app.component.css
    ├ app.component.html
    ├ app.component.ts
    └ app.module.ts

実現したいこと

冒頭に書いた通り コンポーネントの切り替えをタブで行う こと。
つまり次の図を実現したい。

switch_tab_01.png

  • Tab-A, Tab-B はコンポーネント
  • タブをクリックすることで Tab-A と Tab-B が切り替わる

実現にあたって

大まかな実装方針は下記の通り。

NgComponentOutlet の使い方

さて、実際の実装の前にまずは NgComponentOutlet の使い方について確認を。
( 少々しつこいが ) NgComponentOutlet は動的にコンポーネントを読み込むことができるディレクティブで、テンプレートで ng-container と共に使用する。

NgComponentOutletを使うテンプレート

NgComponentOutletを使うテンプレート
<ng-container *ngComponentOutlet="hoge"></ng-container>

ここで、 hoge は動的に読み込みたいコンポーネント。
ngComponentOutlet にセットされているのはコンポーネントで定義されている変数 hoge だが、この変数に動的に読み込む対象のコンポーネントがセットされている。

テンプレートでやることはこれだけ。
上記コードのように対象のコンポーネントを *ngComponentOutlet="hoge" という形で指定してやるだけでよい。

なお、上記コードでコンポーネントを動的に読み込むためには

  • app.module.ts
  • NgComponentOutlet を使うコンポーネント

で下準備が必要となる。

app.module.ts における準備

app.module.ts
// 省略
import { HogeComponent } from './hoge/hoge.component';

// 省略
@NgModule({
  declarations: [
    // 省略
    HogeComponent
  ],
  // ngComponentOutlet で動的にコンポーネントを読み込むにはこの宣言が必要
  entryComponents: [
    HogeComponent
  ],

  // 省略
})
// 省略

通常、コンポーネントを使用する場合は declarations に設定してやるだけでよいが、今回のケースでは entryComponents にも設定してやる必要がある。
これを怠ると、次のエラーが出てブラウザに何も表示されない。

entryComponentsへの設定を怠った場合のエラー情報

entryComponentsへの設定を怠った場合のエラー情報
ERROR Error: "No component factory found for hoge. Did you add it to @NgModule.entryComponents?"

逆に上記エラーが出たら entryComponents がセットされていない可能性があるので、app.module を見直してみると良い。

NgComponentOutletを使うコンポーネント

NgComponentOutletを使うコンポーネント
// 省略
import { HogeComponent } from './hoge/hoge.component';

// 省略
export class AppComponent implements OnInit {
  // 省略

  hoge = HogeComponent;

  // 省略
}

import 宣言したコンポーネントを、直接変数 hoge にセットしている点がポイント。

  1. コンポーネントそのものを変数にセット
  2. セットした変数をテンプレートで ngComponentOutlet で参照
  3. このとき ng-container と共に使用する

これらを app.module.tsにおける準備 とあわせて行うことで、コンポーネント HogeComponent を動的に読み込むという要件が実現される。

以上、簡単ながらサンプルコードで NgComponentOutlet の使い方について見てきた。

それでは以降の項目で、今回の要件である 「NgComponentOutlet を使ったタブの切り替え処理の実装」 について実際のコードを見ていく。

Component の実装

switch-tab.component

switch-tab.component については *ts, *.html, *.css についてコードを載せた。
それぞれについてポイントを見ていく。

switch-tab.component.ts

switch-tab.component.ts
import { Component, OnInit } from '@angular/core';

import { SwitchTabService } from '../../service/switch-tab.service';

import { TabModel } from '../../model/tab-model';

@Component({
  selector: 'app-switch-tab',
  templateUrl: './switch-tab.component.html',
  styleUrls: ['./switch-tab.component.css']
})
export class SwitchTabComponent implements OnInit {

  private _currentTab: any;
  private _tabs!: Array<TabModel>;

  // 次のブロックは setter/getter
  // 今回の実装ではプロパティに対してセット/ゲットするだけの単純なもの

  public get currentTab(): any {
    return this._currentTab;
  }

  public get tabs(): Array<TabModel> {
    return this._tabs;
  }

  /**
   * コンストラクタ ( 本コンポーネントではなにもしない )
   *
   * @memberof SwitchTabComponent
   */
  constructor(
    private switchTabService: SwitchTabService
  ) {}

  /**
   * 初期処理
   *
   * @memberof SwitchTabComponent
   */
  ngOnInit() {
    // view に表示するための情報をここでセットする
    this._tabs = this.switchTabService.tabs;
    this._currentTab = this.switchTabService.getCurrentContents();
  }

  /**
   * ボタンがクリックされた時のイベントハンドラ
   *
   * @param {any} $event イベント情報
   * @memberof SwitchTabComponent
   */
  public onClick($event: any) {
    // クリックされたタブに応じて表示するコンテンツ( component ) を切り替える
    this._currentTab = this.switchTabService.changeCurrentContents($event.target.innerHTML);
  }
}

ポイントは switch-tab.service を DI していること。
このコンポーネントの役割はタブによって表示するコンポーネントを切り替えることだが、その切り替えるコンポーネントを DI している switch-tab.service から取得している。

そして取得したデータを次の2つのプロパティを介してテンプレートで参照することで、コンポーネントの動的読み込みによるタブの切り替えを実現している。

  • currentTab プロパティ

    • 現在表示しているコンポーネントを扱う
    • 後述のテンプレートで ngComponentOutlet にセットされることで、このプロパティにセットされたコンポーネントが動的に読み込まれる
  • tabs プロパティ

    • 本コンポーネントで扱うコンポーネントがセットされる配列
    • 後述のテンプレートで ngFor で扱われ、配列にセットされているコンポーネントがタブ表示される

switch-tab.component.html

switch-tab.component.html
<div class="tab-area-base">
  <ul class="tab-menu-base">
    <ng-container *ngFor="let tab of tabs">
      <li [class.current]="tab.current" (click)="onClick($event)">{{tab.name}}</li>
    </ng-container>
  </ul>

  <!-- ngComponentOutlet で動的にコンポーネントを読み込む -->
  <ng-container *ngComponentOutlet="currentTab"></ng-container>
</div>

ポイントは 2点。

  • ngFor で DI した switch-tab.service から取得したコンポーネントの配列を回してタブ表示していること
  • ngComponentOutlet で現在表示対象となるコンポーネントの動的読み込みを行っていること

ngFor でループ処理をしていることで <li>タグ を何度も記述せずに済んでいるし、ngComponentOutlet を使用することで、切り替え対象のコンポーネントを ngIf で管理せずに済んでいる。

switch-tab.component.css

switch-tab.component.css
div.tab-area-base {
  height: 50px;
  width: 90%;
  margin: auto;
}

ul.tab-menu-base {
  overflow: hidden;
  list-style: none;
  margin: 0px auto;
  padding: 0;
  border-bottom: 5px solid rgb(240, 240, 240);
}

ul.tab-menu-base li {
  float: left;
  margin: 20px 8px 0 0 ;
  padding: 0;
  background: rgb(180, 180, 180);
  border-radius: 10px 10px 0 0;
  height: 100%;
  width: 200px;
  font-size: 1.5em;
  text-align: center;
}

ul.tab-menu-base li.current {
  background: rgb(240, 240, 240);
}

こちらについて特筆する点は無し。
CSS でタブUI を実現するための一般的なスタイルを記述している(ハズ)。
前掲の switch-tab.component.html とあわせて見ていただければ。。。

tab-a.component, tab-b.component

tab-a.component, tab-b.componentswitch-tab.component でタブ切り替えに使われるだけのコンポーネントなので説明を割愛する。

Model の実装

tab-model

tab-model.ts
export class TabModel {
  private _name: string;
  private _contents: any;
  private _current: boolean;

  /**
   * コンストラクタ
   *
   * @param {string} _name タブ名
   * @param {*} _contents コンテンツ( 実態はコンポーネントそのもの )
   * @param {boolean} _current 現在表示中か否かを示すフラグ
   * @memberof TabModel
   */
  constructor(_name: string, _contents: any, _current: boolean) {
    this._name = _name;
    this._contents = _contents;
    this._current = _current;
  }

  // 以降のブロックは setter/getter
  // 今回の実装ではプロパティに対してセット/ゲットするだけの単純なもの

  public set name(_name: string) {
    this._name = _name;
  }

  public get name(): string {
    return this._name;
  }

  public set contents(_contents: any) {
    this._contents = _contents;
  }

  public get contents(): any {
    return this._contents;
  }

  public set current(_current: boolean) {
    this._current = _current;
  }

  public get current(): boolean {
    return this._current;
  }
}

tab-model でのポイントは

  • タブとして扱うコンポーネントを管理する型

であるという点。
そのためのプロパティとして

  • name: 表示するタブ名
  • contents: 表示するコンテンツ( コンポーネント )
  • current: 現在表示していることを示すフラグ

を用意している。
これを後述の switch-tab.service で配列の型として指定することで、 switch-tab.service 経由で扱う際に型推論のメリットを享受できる。

サービスの実装

switch-tab.service

switch-tab.service.ts
import { Injectable } from '@angular/core';

import { TabModel } from '../model/tab-model';

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

  private _tabs!: Array<TabModel>;

  // 次のブロックは setter/getter
  // 今回の実装ではプロパティに対してセット/ゲットするだけの単純なもの

  public get tabs(): Array<TabModel> {
    return this._tabs;
  }

  public set tabs(tabs: Array<TabModel>) {
    this._tabs = tabs;
  }

  /**
   * コンストラクタ
   *
   * @memberof SwitchTabService
   */
  constructor() { }

  /**
   * 現在表示中のコンテンツを取得する
   *
   * @returns {any} 表示中のコンテンツ( 実態はコンポーネント... これも抽象クラス作ってやると型指定できるけど今回はそこまでしない )
   * @memberof SwitchTabService
   */
  public getCurrentContents(): any {
    // this._tabs は Array 型なので「for-in」は使わないで無難に「for文」で回す
    // 「for-of」でも良いけれど、それだとデバッグ時に「this」やら「_tabs」が何故か「undefined」になって気持ち悪い...
    for (let index = 0; index < this._tabs.length; index++) {
      const target = this._tabs[index];
      if (target.current) {
        return target.contents;
      }
    }
  }

  /**
   * 表示するコンテンツを切り替える
   *
   * @param {string} name クリックされたタブのタブ名
   * @returns {any} 切り替え先のコンテンツ( 実態はコンポーネント... これも抽象クラス作ってやると型指定できるけど今回はそこまでしない )
   * @memberof SwitchTabService
   */
  public changeCurrentContents(name: string): any {
    let contents: any;

    // this._tabs は Array 型なので「for-in」は使わないで無難に「for文」で回す
    // 「for-of」でも良いけれど、それだとデバッグ時に「this」やら「_tabs」が何故か「undefined」になって気持ち悪い...
    for (let index = 0; index < this._tabs.length; index++) {
      const target = this._tabs[index];
      target.current = false;

      if (target.name === name) {
        target.current = true;
        contents = target.contents;
      }
    }
    return contents;
  }
}

switch-tab.service でのポイントは 3点。

  • 一つ目は タブとして扱うコンポーネントを管理するためのプロパティとして tab-model 型の配列 _tabs を用意していること
  • 二つ目、三つ目として、_tabs に対して以下のメソッドを用意していること
    • 現在表示しているコンテンツを取得するメソッド
    • タブクリック時に現在表示しているコンテンツを切り替えるメソッド

app.module と app.component

app.module

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { SwitchTabComponent } from './component/switch-tab/switch-tab.component';
import { TabAComponent } from './component/tab-a/tab-a.component';
import { TabBComponent } from './component/tab-b/tab-b.component';

import { SwitchTabService } from './service/switch-tab.service';

@NgModule({
  declarations: [
    AppComponent,
    SwitchTabComponent,
    TabAComponent,
    TabBComponent,
  ],
  // ngComponentOutlet で動的にコンポーネントを読み込むにはこの宣言が必要
  entryComponents: [
    TabAComponent,
    TabBComponent
  ],
  imports: [
    BrowserModule,
  ],
  providers: [
    SwitchTabService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

app.module でのポイントは 2点。

  • 一つ目は app.module.tsにおける準備 で触れたとおり、 entryComponents へのセットすること
  • 二つ目は SwitchTabService を DI のため providers へセットすること

app.component

app.component.ts
import { Component, OnInit } from '@angular/core';

import { TabAComponent } from './component/tab-a/tab-a.component';
import { TabBComponent } from './component/tab-b/tab-b.component';

import { SwitchTabService } from './service/switch-tab.service';

import { TabModel } from './model/tab-model';

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

  private _tabs!: Array<TabModel> = new Array();

  /**
   * コンストラクタ ( 本コンポーネントではなにもしない )
   *
   * @param {SwitchTabService} switchTabService
   * @memberof AppComponent
   */
  constructor(
    private switchTabService: SwitchTabService
  ) {
  }

  /**
   * 初期処理
   *
   * @memberof AppComponent
   */
  ngOnInit() {
    // タブの情報をここでセットする
    // サービスにセットされたタブ情報は、それを利用するコンポーネントで共有される
    this._tabs.push(new TabModel('Tab-A', TabAComponent, true));
    this._tabs.push(new TabModel('Tab-B', TabBComponent, false));
    this.switchTabService.tabs = this._tabs;
  }
}

app.component でのポイントは 1点。

  • switch-tab.component で扱うコンポーネントを switch-tab.service にセットすること

switch-tab.service は DI によってインスタンスがアプリ全体で共有されるので、ここでセットしたコンポーネント情報が switch-tab.component で扱える、という流れになる。
もしタブとして扱いたいコンポーネントが増えた場合はここでセットしてやるだけで良い。それだけで switch-tab.component は扱う対象のコンポーネントが増えたことも意識せずに動作する。

実行結果

初期表示: Tab-A を表示

アプリ起動直後は Tab-A が表示される。
これは app.componentngOnInit() で TabModel のインスタンス生成時に true をセットしているため。

switch_tab_02.png

切り替え: Tab-B を表示

Tab-B をクリックすることで、Tab-B がちゃんと表示されている。
これは switch-tab.servicechangeCurrentContents() で選択したタブへの切り替え処理を行うことで実現している。

switch_tab_03.png

参考

ソースコード

今回の記事で作成したコードは こちら にアップしてあるのでご参考まで。

16
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?