Angular Materialで学ぶ「どんなアプリでもきっと役立つ」コンポーネント開発

## はじめに

この記事は、Angularの基本的な機能はひととおり知っているけれど、なかなかイケてるUIが作れないという方を対象に、初級と中級の橋渡しを目指して書きました。

本記事で取り扱う題材は大きく2つ ―― **ちょっとかっこいいナビゲーションバー**と、データの読み込み中の **「ぐるぐる」演出** を作り込んでいきます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/770ef900-f1c4-7c0d-3bc7-02a181bf094a.png)

これらは多くのアプリケーションで目にするコンポーネントですが、それだけに誤魔化しがききにくいという側面もあり、わたし自身ベストプラクティスを探りながら試行錯誤を重ねてきました。

本記事の前半は比較的易しめの内容、後半は少し歯応えのある内容を扱っており、できるだけ幅広い方にお楽しみいただけるよう努めたつもりです。 いずれも実践的なトピックを交えておりますので、ぜひ最後までお読みいただけますと幸いです。

### 想定読者

- Angularの基本的な機能をある程度知っている
- Angular CLIのコマンドがちょっと使える
- Angular v17の構文をキャッチアップしたい
- Angular Materialのカスタマイズ方法を知りたい
- ちょっとかっこいいコンポーネントの作り方を知りたい

### 開発環境

各種ツールのバージョンは以下を想定しています。

- **Node.js**: 20.10.0 (LTS)
- **npm**: 10.2.4
- **Angular CLI**: 17.0.5
- **OS**: Windows 11

### 免責事項

本記事の内容は情報提供のみを目的としております。
内容に基づく実施・運用において発生したいかなる損害も、著者は一切の責任を負わないものとします。

## 準備運動

### プロジェクト（ワークスペース）の作成

コマンドプロンプトに以下を入力して、`sample`という名前のプロジェクトを作りましょう。

```bash
> ng new sample --routing --standalone --style scss --ssr false --skip-tests
```

`ng new`コマンドにはいくつかオプションが用意されていますが、ここでは以下のオプションを指定しています。 この他のオプションについては、[Angular CLIのドキュメント](https://angular.jp/cli/new)を参照してください。

|オプション|説明|
|---|---|
|`--routing`|ルーティング（ページ遷移）を有効化します。|
|`--standalone`|`NgModule`に依存しない「スタンドアロンコンポーネント」によるアプリケーションのテンプレートを生成します。|
|`--style`|アプリケーションで利用するスタイルファイルの種類を指定します。`css`、`scss`、`sass`、`less`のいずれかが指定できます。今回は`scss`を指定しました。|
|`--ssr`|サーバサイドレンダリングを有効化するかどうかを指定できます。今回はサーバサイドレンダリングは有効化しません。|
|`--skip-tests`|ユニットテストコードが生成されないようにします。|

先ほどのコマンドを実行すると、次のようなテンプレートが生成されます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/0d2b2010-a2e2-968d-281d-74d026afee09.png)

:::note info
**POINT**
従来の Angular に慣れた方には違和感があるかも知れませんが、生成されたファイルには`app.module.ts`や`app-routing.module.ts`といった`NgModule`が含まれていません。

これは、Angular v15から登場した「スタンドアロンコンポーネント」によって、必ずしも`NgModule`が必要ではなくなったためです。

以前は、外部のモジュール参照やコンポーネントを1つ追加するごとに`NgModule`を編集する必要があり、複数の開発メンバが同一ファイルを頻繁に編集することによる競合やマージ事故が発生しやすい問題を孕んでいました。

これらの問題点は、スタンドアロンコンポーネントによってかなり改善されました。
:::

プロジェクトの作成が無事に成功したら、`sample`フォルダに移動しておきましょう。

```bash
> cd sample
```

### Angular Materialのインストール

続いて、[Angular Material](https://material.angular.io/)[^1]をインストールします。 

[^1]: Angular Materialは、Angular用の公式UIコンポーネントライブラリです。このライブラリを導入することで一貫性のあるUIを簡単に構築できるようになります。

カレントディレクトリが`sample`になっていることを確認し、以下のコマンドを入力しましょう。

```bash
> ng add @angular/material
```

インストール中にいくつか質問が表示されますが、よく分からなければデフォルト応答[^2]で問題ありません。

ただし、`Choose a prebuilt theme name, or "custom" for a custom theme:`という質問に対してだけは、下図のように`Custom`を選択することをお勧めします。

[^2]: 質問に対して何も入力せずにEnterキーを押すと、自動的にデフォルト応答になります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/5f6cdc17-1187-a5ce-5b3c-3934d93d6362.png)

インストールに成功すると、`Packages installed successfully.`と表示され（下図赤枠）、その後、いくつかのファイルが自動的に更新されます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/d4e02a5e-7e7f-533b-b525-3c9a1cca0292.png)

### テーマのカスタマイズ

先の手順で`Custom`を選択した場合は、アプリケーションのテーマ（色合い）をカスタマイズできます。

今回は、外部ツールの[Material Design Palette Generator](http://mcg.mbitson.com/)を使ってテーマを作成してみましょう。

[http://mcg.mbitson.com/](http://mcg.mbitson.com/)にアクセスし、好みの配色を選択して「パレット」と呼ばれる配色パターンを作成していきます。

各パレットごとに、基本となる色を1色ずつ選択すると、その色をベースとした濃淡のバリエーションが自動的に作成されます。 ここでは例として、`primary`、`accent`、`warn`という名前で3つのパレットを作成し、それぞれのベースカラーに`#04127C`、`#A0D8AD`、`#BE375A`を選択しました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/d31dd979-ef32-c4a8-2bd9-febe7ff010bf.png)

パレットが完成したら、Angularプロジェクトに反映しましょう。

ツールの右上にある「↓」ボタンを押すと、下図のように「Output Formats」を選択するダイアログが表示されますので、サイドメニューから「ANGULAR JS 2 (MATERIAL 2)」を選択し、ダイアログ右上の「COPY」ボタンを押します。

これで、クリップボードにパレットのコードがSCSSの形式でコピーされました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/1aea89ea-c067-3edc-fcd0-0e3fdcfbe99f.png)

続いて、Angularプロジェクトの`assets`フォルダの下に`palette.scss`というファイルを新規作成し、先ほどコピーしたパレットを丸ごと貼り付けて上書き保存しましょう。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/d5a46c7b-ba1c-1907-4d23-96a839e28f5e.png)

下図は、コピー & ペーストした`palette.scss`です（全体が見えるようエディタを分割表示していますが、すべて1つのファイルです）。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/f7ed5a96-65bc-f5c3-e713-60667567ca2c.png)

最後に、作成したパレットをAngularプロジェクトに反映させるため、`styles.scss`を以下のように編集しましょう。

```diff_scss:styles.scss
  
  // Custom Theming for Angular Material
  // For more information: https://material.angular.io/guide/theming
  @use '@angular/material' as mat;
  // Plus imports for other components in your app.
+ @use "./assets/palette" as palette;
  
  // Include the common styles for Angular Material. We include this here so that you only
  // have to load a single css file for Angular Material in your app.
  // Be sure that you only ever include this mixin once!
  @include mat.core();
  
  // Define the palettes for your theme using the Material Design palettes available in palette.scss
  // (imported above). For each palette, you can optionally specify a default, lighter, and darker
  // hue. Available color palettes: https://material.io/design/color/
- $sample-primary: mat.define-palette(mat.$indigo-palette);
+ $sample-primary: mat.define-palette(palette.$md-primary);

- $sample-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
+ $sample-accent: mat.define-palette(palette.$md-accent);
  
  // The warn palette is optional (defaults to red).
- $sample-warn: mat.define-palette(mat.$red-palette);
+ $sample-warn: mat.define-palette(palette.$md-warn);
  
  // Create the theme object. A theme consists of configurations for individual
  // theming systems such as "color" or "typography".
  $sample-theme: mat.define-light-theme((
    color: (
      primary: $sample-primary,
      accent: $sample-accent,
      warn: $sample-warn,
    )
  ));
  
  // Include theme styles for core and each component used in your app.
  // Alternatively, you can import and @include the theme mixins for each component
  // that you are using.
  @include mat.all-component-themes($sample-theme);
  
  /* You can add global styles to this file, and also import other style files */
  
  html, body { height: 100%; }
  body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
```

これで準備運動は完了です。 お疲れ様でした。

## 素敵なナビゲーションバーをつくる

ここからは、先ほど導入したAngular Materialの実践的な作例として、ナビゲーションバーを構築してみましょう。

先に完成形を示すと、こんな感じのインタラクションが誰でも簡単に作れてしまいます。

<!-- ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/ed85db2c-1fd8-8824-4462-76481bdcaf77.gif) -->

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/e35fef7d-201c-3eae-959f-d2dd9fef5e39.gif)

種を明かすと、Angular Materialには「Schematics」というソースの自動生成機能があり、ほとんどコードを書かずとも、ある程度の完成度のUIを構築できてしまうのです（Schematicsに関する詳しい情報は、[Angular Materialのドキュメント](https://material.angular.io/guide/schematics)を参照）。

ナビゲーションバーのコードを生成するコマンドは次の通りです。

```bash
> ng generate @angular/material:navigation navigation
```

上記のコマンドを実行すると、`navigation`フォルダの配下に3つのファイルが自動生成されます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/842135ac-5563-8213-98e8-bda18a3ebaca.png)

ここからは、作成したナビゲーションが画面に表示されるよう、既存のソースに手を加えていきます。

まずは`app.component.ts`からナビゲーションを参照できるよう、以下のようにインポートを追加しましょう。

```diff_typescript:app.component.ts
  import { Component } from '@angular/core';
  import { CommonModule } from '@angular/common';
  import { RouterOutlet } from '@angular/router';
+ import { NavigationComponent } from './navigation/navigation.component';
  
  @Component({
    selector: 'app-root',
    standalone: true,
-   imports: [CommonModule, RouterOutlet],
+   imports: [CommonModule, RouterOutlet, NavigationComponent],
    templateUrl: './app.component.html',
    styleUrl: './app.component.scss'
  })
  export class AppComponent {
-   title = 'sample';
  }
```

また、`app.component.html`を以下のように置き換えます[^3]。 これで、`app.component.html`の直下にナビゲーションが配置されました。

[^3]: `app.component.html`にもともと存在するソースはすべて削除して、本文中で示しているソースに置き換えてください。

```app.component.html
<app-navigation>
  <router-outlet></router-outlet>
</app-navigation>
```

続いて、`navigation.component.html`を開き、以下のように修正します[^4]。

[^4]: このあとの説明の都合上、`main-container`というCSSクラスを指定していますが、今は気にしないでください。

```diff_html:navigation.component.html
  <mat-sidenav-container class="sidenav-container">
    <mat-sidenav #drawer class="sidenav" fixedInViewport
        [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
        [mode]="(isHandset$ | async) ? 'over' : 'side'"
        [opened]="(isHandset$ | async) === false">
      <mat-toolbar>Menu</mat-toolbar>
      <mat-nav-list>
        <a mat-list-item href="#">Link 1</a>
        <a mat-list-item href="#">Link 2</a>
        <a mat-list-item href="#">Link 3</a>
      </mat-nav-list>
    </mat-sidenav>
    <mat-sidenav-content>
      <mat-toolbar color="primary">
        @if (isHandset$ | async) {
          <button
            type="button"
            aria-label="Toggle sidenav"
            mat-icon-button
            (click)="drawer.toggle()">
            <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
          </button>
        }
        <span>sample</span>
      </mat-toolbar>
      <!-- Add Content Here -->
+     <div class="main-container">
+       <ng-content></ng-content>
+     </div>
    </mat-sidenav-content>
  </mat-sidenav-container>
```

ここまでで、最低限のナビゲーションバーが完成しました。

:::note info
**POINT**
Angular v17では、従来の`*ngIf`という構造ディレクティブが`@if`という構文に置き換わっています。 同様に、`*ngSwitch`は`@switch`に、`*ngFor`は`@for`という構文にそれぞれ置き換わります（従来の構造ディレクティブも依然として利用可能です）。

なお、この仕様変更の影響で、`@`や`{`、`}`といった文字がテンプレートの中で特別な意味を持つようになってしまったため、これらの文字をそのまま利用することができなくなっており、`@`は`&#64;`に、`{`は`&#123;`に、`}`は`&#125;`にエスケープが必要になりました。
:::

### 動作確認

ここまでの作業内容を確認するため、一度ブラウザで見栄えを確かめてみましょう。

コマンドプロンプトに以下のコマンドを入力すると、Angularプロジェクトがビルドされ、ブラウザが起動して動きを確かめることができます。

```bash
> ng serve --open
```

実行結果はこのような感じになります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/3370bedf-0940-fc68-79c4-66be8375a945.png)

これはこれでよいのですが、正直、売り物のシステムに組み込むには少し見た目が貧相です。 ここからはプロの技でより見栄えのよいUIに改善していきましょう。

### 問題1: スクロールバーがナビゲーションにかぶる

実際にアプリケーションのコンテンツが充実すると気付くことですが、Schematicsで生成したナビゲーションバーは、スクロールバーに負けてしまいます（下図赤枠）。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/202031c0-7e43-23d3-00a9-b7c120c6bb7d.png)

ページ上部の定位置にあるナビゲーションバーは、スクロールを超越した存在であってほしいので、下図のようにスクロールバーがかぶらないようスクロールバーの上端を調整したいところです。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/fff41463-ccfd-3d19-cef3-18b93f9c63f7.png)

このような場合は、まず、`navigation.component.scss`を以下のように編集します。

```diff_scss:navigation.component.scss
  .sidenav-container {
    height: 100%;
  }
  
  .sidenav {
    width: 200px;
  }
  
  .sidenav .mat-toolbar {
    background: inherit;
  }
  
  .mat-toolbar.mat-primary {
    position: sticky;
    top: 0;
    z-index: 1;
  }
  
+ mat-sidenav-content {
+   height: 100%;
+   display: grid;
+   grid-template-rows: max-content 1fr;
+ }
  
+ .main-container {
+   overflow: auto;
+ }
```

さらに、ナビゲーションバーが他のコンポーネントよりも手前に位置していることを視覚的に示すため、さりげなく影を追加しましょう。

Angular Materialには、影をつけるためのCSSクラスとして`mat-elevation-z0`～`mat-elevation-z24`が定義されているので、これをナビゲーションバーに設定することで立体的な視覚効果を生み出すことができます（ここでは効果を強調するため`mat-elevation-z8`を指定しています）。

```diff_html:navigation.component.html
  <mat-sidenav-container class="sidenav-container">
    <mat-sidenav #drawer class="sidenav" fixedInViewport
        [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
        [mode]="(isHandset$ | async) ? 'over' : 'side'"
        [opened]="(isHandset$ | async) === false">
      <mat-toolbar>Menu</mat-toolbar>
      <mat-nav-list>
        <a mat-list-item href="#">Link 1</a>
        <a mat-list-item href="#">Link 2</a>
        <a mat-list-item href="#">Link 3</a>
      </mat-nav-list>
    </mat-sidenav>
    <mat-sidenav-content>
-     <mat-toolbar color="primary">
+     <mat-toolbar color="primary" class="mat-elevation-z8">
        @if (isHandset$ | async) {
          <button
            type="button"
            aria-label="Toggle sidenav"
            mat-icon-button
            (click)="drawer.toggle()">
            <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
          </button>
        }
        <span>sample</span>
      </mat-toolbar>
      <!-- Add Content Here -->
      <div class="main-container">
        <ng-content></ng-content>
      </div>
    </mat-sidenav-content>
  </mat-sidenav-container>
```

実行結果は以下のようになります。 ナビゲーションバーの存在感がより引き立って見えるようになりました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/285cfb3a-5d49-2cb0-138d-0b383df5480c.png)

### 問題2: サイドメニューが閉じられない

Schematicsでナビゲーションバーを自動生成すると、サイドメニュー（下図赤枠）も一緒に構築されます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/9bbc214b-1fdc-d87a-226a-e89145c9c169.png)

しかしこのサイドメニューは少々クセがあり、必ずしも使い勝手がよいとは言えません。

例えば、一般的なPCのブラウザでアプリケーションを開いた場合、サイドメニューは強制的に表示され、それを閉じる手段がありません。

しかしながら、通常時はメニューを隠しておき、必要に応じて表示・非表示をインタラクティブに切り替えたいケースもあるでしょう。

そこで本節では、サイドメニューを自在に開閉できるよう、ナビゲーションバーを改造してみます。　これは、テンプレートをたった3箇所修正するだけで対応可能です。

それぞれの修正内容はざっくりこのような感じです。

1. 起動時の初期状態をメニュー非表示にする
2. メニューの右上に×ボタンを配置する
   → これでメニューが閉じられるようになる
3. メニューを表示する三本線ボタンの表示条件を変更する
   → 実はもともとスマホ端末向けにメニューを開くためのボタンはコーディングされているので、それをPCでも使えるよう条件を変更する

```diff_html:navigation.component.html
  <mat-sidenav-container class="sidenav-container">
    <mat-sidenav #drawer class="sidenav" fixedInViewport
        [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
        [mode]="(isHandset$ | async) ? 'over' : 'side'"
-       [opened]="(isHandset$ | async) === false">
+       [opened]="false">

-     <mat-toolbar>Menu</mat-toolbar>
+     <mat-toolbar>Menu
+       <span style="flex: 1 1 auto;"></span>
+         <button mat-icon-button (click)="drawer.close()">
+         <mat-icon>close</mat-icon>
+       </button>
+     </mat-toolbar>
    
      <mat-nav-list>
        <a mat-list-item href="#">Link 1</a>
        <a mat-list-item href="#">Link 2</a>
        <a mat-list-item href="#">Link 3</a>
      </mat-nav-list>
    </mat-sidenav>
    <mat-sidenav-content>
      <mat-toolbar color="primary" class="mat-elevation-z8">

-       @if (isHandset$ | async) {
+       @if (!drawer.opened) {
          <button
            type="button"
            aria-label="Toggle sidenav"
            mat-icon-button
            (click)="drawer.toggle()">
            <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
          </button>
        }
        <span>sample</span>
      </mat-toolbar>
      <!-- Add Content Here -->
      <div class="main-container">
        <ng-content></ng-content>
      </div>
    </mat-sidenav-content>
  </mat-sidenav-container>
```

この修正によって、ナビゲーションメニューの左端に、サイドメニューを開くための三本線のボタンが表示されるようになります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/d98ab52b-b3e0-60e9-c0bb-edb272fe4d80.png)

さらに、サイドメニューの右上には、メニューを閉じるための×ボタンが追加されました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/259e003a-e9e6-871c-044c-2d8221a353e9.png)

このようにすることで、メニューを折りたたむとアプリケーションの横幅を広々と使えるようになります。

### 問題3: ナビゲーションが地味

これは好みの問題ですが、たとえばシステム導入先の企業様のテーマカラーやロゴなどをあしらうと、それだけで断然オリジナリティが出るものです。

この改造のためには、少しだけ前準備が必要です。 まず、適当な画像（システムのロゴなど）を用意し、それを`assets`フォルダの下に配置しましょう。 フォルダ階層は次の通りとなります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/fe7be5f2-38c9-c122-c5ee-90113b38d003.png)

続いて、ナビゲーションバーの背景色にグラデーションを設定します。 `navigation.component.scss`に、新たなクラスを追加しましょう。

```diff_scss:navigation.component.scss
  .sidenav-container {
    height: 100%;
  }
  
  .sidenav {
    width: 200px;
  }
  
  .sidenav .mat-toolbar {
    background: inherit;
  }
  
  .mat-toolbar.mat-primary {
    position: sticky;
    top: 0;
    z-index: 1;
  }
  
  mat-sidenav-content {
    height: 100%;
    display: grid;
    grid-template-rows: max-content 1fr;
  }
  
  .main-container {
    overflow: auto;
  }
  
+ .toolbar-background {
+   background: linear-gradient(82deg, #04127C 35%, #65B3A4 90%, #A0D8AD 100%);
+ }
```

最後に、テンプレートを修正します。 ナビゲーションバーのスタイルに、先ほどのCSSクラスを設定し、さらにシステムのロゴを配置します。

```diff_html:navigation.component.html
  <mat-sidenav-container class="sidenav-container">
    <mat-sidenav #drawer class="sidenav" fixedInViewport
        [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
        [mode]="(isHandset$ | async) ? 'over' : 'side'"
        [opened]="false">
      <mat-toolbar>Menu
        <span style="flex: 1 1 auto;"></span>
          <button mat-icon-button (click)="drawer.close()">
          <mat-icon>close</mat-icon>
        </button>
      </mat-toolbar>
      <mat-nav-list>
        <a mat-list-item href="#">Link 1</a>
        <a mat-list-item href="#">Link 2</a>
        <a mat-list-item href="#">Link 3</a>
      </mat-nav-list>
    </mat-sidenav>
    <mat-sidenav-content>
-     <mat-toolbar color="primary" class="mat-elevation-z8">
+     <mat-toolbar color="primary" class="mat-elevation-z8 toolbar-background">
+       <mat-toolbar-row>
          @if (!drawer.opened) {
            <button
              type="button"
              aria-label="Toggle sidenav"
              mat-icon-button
              (click)="drawer.toggle()">
              <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
            </button>
          }
-         <span>sample</span>
+         <img src="/assets/logo.png" style="height: 1rem;">
+         <span style="flex: 1 1 auto;"></span>
+         <button mat-button class="mat-headline-4">
+           <mat-icon>account_circle</mat-icon>
+           日電 太郎
+         </button>
+       </mat-toolbar-row>
      </mat-toolbar>
      <!-- Add Content Here -->
      <div class="main-container">
        <ng-content></ng-content>
      </div>
    </mat-sidenav-content>
  </mat-sidenav-container>
```

ここまでの内容を実行すると、結果は以下のようになります。 だいぶ良い感じの見た目になりましたね。 ナビゲーションバーに関しては、ひとまずこんなところでしょうか。

<!-- ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/ad2b33bf-405d-a9c8-9dc0-19b8dc77a01e.png) -->

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/c88b5512-21c1-acdb-5e93-8aaeaa886b97.png)

ほんのわずかに手を加えるだけでも、ここまでオリジナリティが出るので、モックアップなどを作る際には取り入れてみるとお客様に喜んでいただけるかも知れません。

## ローディング部品をつくる

ここまでは、自動生成したコードにほんのわずか手を加えるだけのお手軽篇でしたが、ここからは<ruby>本気<rt>ガチ</rt></ruby>篇ということで、データの読み込み中にユーザを和ませるためのUI部品、通称「**ぐるぐる**」を作っていくことにします。

時間のかかる検索処理などの際に、うまく活用するとユーザの体感ストレスを抑えることができるので、ここぞというときに用意しておくと大変重宝します[^5]。

[^5]: ただし、濫りに使うとかえって鬱陶しいため、使い過ぎに注意しましょう。

<!-- ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/af27d918-4fac-72fd-50f8-75f6acff6c9d.gif) -->

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/02b623df-43e5-000d-eb3f-ea401c6bb5f6.gif)

一見簡単そうに見えますが、Angular CDKのオーバレイ効果とアニメーションモジュールを駆使した、それなりに手の込んだ部品です。

見た目の演出だけでなく、部品としての使い勝手と汎用性には特に拘りました。

それでは早速作り方を見ていきましょう。

```bash
> ng generate component loading
> ng generate service loading/loading
```

上記のコマンドを実行すると、`loading`フォルダの下にコンポーネントとサービスがひとまとまりに生成されます。 これらが部品の本体になります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/db4934b1-d970-b5dc-85cc-9f8ffeaeaa59.png)

次に、オーバレイを行うために必須の設定を行いましょう。

`styles.scss`の中にある`/* You can add global styles to this file, and also import other style files */`という**コメントの次の行**に、以下の1行を追加します。

```styles.scss
@import '@angular/cdk/overlay-prebuilt.css';
```

続いて、オーバレイ用のコンポーネントの表示/非表示を制御するサービス`loading.service`を書いていきましょう。

サービスが公開するメソッドとして`show()`と`hide()`を実装し、それぞれが呼ばれたときにぐるぐるの表示状態を切り替えるロジックを実行します。

ぐるぐるにはアニメーションが表示されているため、即座に表示/非表示がスイッチするわけではなく、出現時には100ミリ秒、消失時には1000ミリ秒の“ふんわりとした過渡期”があります。 このわずかなタイミングで`show()`や`hide()`が立て続けに呼ばれた際におかしなことが起こらないよう、内部ではかなり泥臭い制御を行っているのです。

```loading.service.ts
import { Overlay } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { Injectable, inject } from '@angular/core';
import { LoadingComponent } from './loading.component';

@Injectable({ providedIn: 'root' })
export class LoadingService {
  // 画面全体を覆うようにオーバレイを作成
  private readonly overlay = inject(Overlay);
  private readonly overlayRef = this.overlay.create({ height: '100%', width: '100%' });
  private readonly duration = 1000;
  
  private _visible: boolean = false;
  private _timeoutId?: any;

  // 可視状態フラグ
  get visible() {
    return this._visible;
  }

  // ぐるぐるをオーバレイ表示する
  show() {
    if (!this._timeoutId) {
      clearTimeout(this._timeoutId);
      this.overlayRef.detach();
      this._timeoutId = undefined;
    }
    this._visible = true;
    this.overlayRef.attach(new ComponentPortal(LoadingComponent));
  }

  // ぐるぐるを非表示にする
  hide() {
    this._visible = false;
    this._timeoutId = this._timeoutId || setTimeout(() => {
      this.overlayRef.detach();
      this._timeoutId = undefined;
    }, this.duration);
  }
}
```

次に、ぐるぐるを表示するためのSCSSを書きましょう。 コンポーネントが表示領域いっぱいに覆いかぶさるよう、`width`と`height`をそれぞれ`100%`に設定し、その中央に「ぐるぐる」のコンポーネントが配置されるよう構成しています。

さらに、ぐるぐるが際立つよう、背景をぼかすことで遠近感を演出します[^6]。

[^6]: 実案件で使用する際は、あまりぼかしを強くかけすぎるとわざとらしくなり、さらにフロントエンドのパフォーマンスも低下するので、ほどほどに調整しましょう。

```loading.component.scss
:host {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: none;
}

.main-container {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: none;

  /* 背景をぼかす */
  backdrop-filter: saturate(25%) blur(5px);
  background-color: rgba(255, 255, 255, 0.3);
  
  /* コンポーネントを上下左右中心に配置する */
  display: flex;
  justify-content: center;
  align-items: center;
  user-select: none;
}
```

続いて、コンポーネントの実装です。 ここでインポートしている`MatProgressSpinnerModule`が、「ぐるぐる」の正体です。

また、「ぐるぐる」の表示/非表示が切り替わるタイミングで発動するさりげないフェードイン / フェードアウトのアニメーションもここで定義します。

フェード効果があることで、ユーザ目線での唐突感が和らぐので、これに限らず適度なアニメーションはお勧めです。

```loading.component.ts
import { Component, inject } from '@angular/core';
import { animate, style, transition, trigger } from "@angular/animations";
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { LoadingService } from './loading.service';

@Component({
  selector: 'app-loading',
  standalone: true,
  imports: [MatProgressSpinnerModule],
  templateUrl: './loading.component.html',
  styleUrl: './loading.component.scss',
  animations: [
    trigger('foreTrigger', [
      transition(':enter', [
        style({ opacity: 0 }),
        animate('100ms', style({ opacity: 1 })),
      ]),
      transition(':leave', [
        style({ opacity: '*' }),
        animate('1000ms', style({ opacity: 0 })),
      ]),
    ])
  ]
})
export class LoadingComponent {
  readonly loading = inject(LoadingService);
}
```

最後に、テンプレートのマークアップです。　表示/非表示を切り替える`@if`の仕掛けと、そのタイミングでフェード効果が発動するようアニメーションのトリガーをバインドしています。

```loading.component.html
@if(loading.visible) {
  <div @foreTrigger class="main-container">
    <div>
      <mat-spinner strokeWidth="15"></mat-spinner>
    </div>
  </div>
}
```

ここまでで、ぐるぐる部品ができました。 部品の中身は入り組んでいますが、その分、使い方は至ってシンプルになっています。

### ぐるぐる部品の使い方

ここからはぐるぐる部品の使い方を見ていきましょう。

主にコンポーネントから部品を利用する例で簡単に解説すると――

1. `LoadingService`をDIする（`inject()`関数でもコンストラクタ引数でも）
2. `LoadingService#show()`でぐるぐるが出現する
3. `LoadingService#hide()`でぐるぐるが非表示になる

たったこれだけです。 念のため擬似コードを示すと以下の通りとなります。 とても簡単ですね。

```typescript
import { Component } from '@angular/core';
import { LoadingService } from './loading/loading.service';
import { inject } from '@angular/core';

@Component({ /* 略 */ })
export class AppComponent {

  // LoadingServiceをインジェクト
  private readonly loading = inject(LoadingService);

    /* 中略 */
  
    // ぐるぐるを表示させたいとき
    this.loading.show();

    // ぐるぐるを非表示にしたいとき
    this.loading.hide();
}
```

## まとめ

いかがでしたでしょうか。

今回は、Angularの新機能とAngular Materialを使って、「どんなアプリでもきっと役立つコンポーネント開発」をご紹介しました。

v17になってますます強力になったAngularと、ちょっとしたカスタマイズで大きく化けるAngular Materialの魅力を、ぜひ多くの皆様に知っていただければ幸いです。

引き続き、フロントエンドからバックエンドまで楽しい小ネタをご紹介していきたいと思います。

最後までお読みいただきありがとうございました。