中安拓也のブログ

プログラミングについて書くブログ

Angular Materialでレスポンシブなサイドメニューを実装する

はじめに

PCなどの大きいディスプレイの時はサイドメニューを常に表示し、モバイル端末などの小さいディスプレイの時はハンバーガーメニューに切り替えてスペースを節約したい。

f:id:l08084:20200320172927p:plain
画面が大きいときはサイドメニュー

f:id:l08084:20200320174008p:plain
画面の横幅が狭くなるとハンバーガーメニューになる

f:id:l08084:20200320174658p:plain
タップするとメニューが横から出てくる

このように端末のディスプレイサイズに合わせて表示形式が変わるサイドメニューを作成していく。

環境

本記事における実行環境と使用ライブラリについて

$ ng version

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

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.20
@angular-devkit/build-angular     0.803.20
@angular-devkit/build-optimizer   0.803.20
@angular-devkit/build-webpack     0.803.20
@angular-devkit/core              8.3.20
@angular-devkit/schematics        8.3.20
@angular/cdk                      7.3.7
@angular/cli                      8.3.20
@angular/fire                     5.2.3
@angular/material                 7.3.7
@ngtools/webpack                  8.3.20
@schematics/angular               8.3.20
@schematics/update                0.803.20
rxjs                              6.5.3
typescript                        3.5.3
webpack                           4.39.2

今回やること

レスポンシブなメニューを実装するために、今回やることだが下記となる。

  • ディスプレイサイズの検知
  • サイドメニューの表示・非表示の切り替え
  • ハンバーガーメニューの表示・非表示の切り替え

ダッシュボード画面

最初に画像で貼ったダッシュボード画面(home.component.html)の実装となる。

ポイントはヘッダー(<app-header>)とサイドメニュー(<mat-sidenav>)の実装、そしてディスプレイサイズを検知するisHandset$の3点である。

<!-- ヘッダー -->
<app-header [isHandset$]="isHandset$" (drawerToggled)="drawer.toggle()"></app-header>

<mat-sidenav-container class="container">
  <!-- サイドメニュー -->
  <mat-sidenav #drawer [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
    [mode]="(isHandset$ | async) ? 'over' : 'side'" class="side-menu" [opened]="!(isHandset$ | async)">
    <app-main-side-menu [isHandset]="(isHandset$ | async)" (drawerClosed)="drawer.close()"></app-main-side-menu>
  </mat-sidenav>
  <!-- メイン コンテンツ -->
  <mat-sidenav-content class="main-contents">
    <!-- サブサイドメニュー -->
    <router-outlet></router-outlet>
  </mat-sidenav-content>
</mat-sidenav-container>

isHandset$

Angular CDKのBreakpointObserverを使用して画面が小さくなったらtrueを返す、isHandset$を定義する。

このisHandset$を使用してハンバーガーメニューを表示したりサイドメニューを消したりして、レスポンシブデザインを実現する。

  • home.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { map } from 'rxjs/operators';

// ...省略

export class HomeComponent {
  public isHandset$: Observable<boolean> = this.breakpointObserver
    .observe(Breakpoints.Handset)
    .pipe(map(result => result.matches));

  constructor(private breakpointObserver: BreakpointObserver) {}

}

ヘッダー(<app-header>)

ngIfisHandset$を使って画面が小さいときのみ、ハンバーガーメニューを表示している。isHandset$はObservableを返すので、asyncパイプも使用する必要がある。

  • header.component.html
<mat-toolbar color="primary">
  <div class="main">
    <!-- ハンバーガーメニュー -->
    <button type="button" mat-icon-button *ngIf="(isHandset$ | async)" (click)="toggle()">
      <mat-icon>menu</mat-icon>
    </button>

    <span class="logo" (click)="signOut()">
      <mat-icon class=" header-icon">
        note
      </mat-icon>
      <span class="title">3Memo</span>
    </span>
    <!-- ログアウトボタン(ログインしている時のみ表示される) -->
    <mat-icon matTooltip="ログアウトする" (click)="signOut()" *ngIf="authenticationService.getUser() | async"
      class="right-icon">exit_to_app
    </mat-icon>
  </div>
</mat-toolbar>

サイドメニュー(<mat-sidenav>)

サイドメニューの実装にはAngular MaterialのSidenavを使用している。

  <!-- サイドメニュー -->
  <mat-sidenav #drawer [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
    [mode]="(isHandset$ | async) ? 'over' : 'side'" class="side-menu" [opened]="!(isHandset$ | async)">
    <!-- 省略... -->
  </mat-sidenav>

isHandset$を使用することでディスプレイが小さくなった時には、attr.roledialogにし、modeoverに、openedfalseにしている

参考サイト

Angular Material Responsive Navigation | by Ahmed Abouzied | Medium

https://material.angular.io/cdk/layout/overview

Angular Component Dev Kit 入門 - Qiita

Angular CDK Layoutを触ってみる - Qiita

【Angular Router】画面遷移でオブジェクトを渡す

はじめに

Angular Routerを使ってA画面からB画面に遷移する時に、B画面に単一の値ではなく、オブジェクトを渡したい時にどのような書き方をすればいいかまとめました。

環境

  • Angular CLI: 8.3.20
  • Node: 12.13.1
  • OS: darwin x64
  • Angular: 8.2.14

実装

メモのUpsert画面に画面遷移でオブジェクトを渡す処理を実装していきます。

URIの設定

まず、メモのUpsert画面のURI(/home/upsert)をAppRoutingModuleを作成することで設定します。

  • app-routing.module.ts
// ...省略

// メモのUpset画面のURIを設定する
const routes: Routes = [
  {
    path: 'home',
    component: HomeComponent,
    canActivate: [AuthenticationGuard],
    children: [
      { path: '', redirectTo: 'upsert', pathMatch: 'full' },
      {
        path: 'upsert',
        component: MemoUpsertComponent,
        canActivate: [AuthenticationGuard]
      }
    ]
  }
];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

オブジェクトを遷移先の画面に渡す

selectedMemoIdselectedFolderIdのプロパティーをもつオブジェクトparamを作成して、メモUpsert画面に遷移している処理です。なお、遷移先の画面にオブジェクトを渡しています。

    const param = {
      selectedMemoId: id,
      selectedFolderId: this.selectedFolderId
    };
    // メモUpsert画面に遷移する
    this.router.navigate(['/home/upsert', param]);

上記の処理を実施したときのURIは下記のようになります。

localhost:4200/home/upsert;selectedMemoId=9BE2uHckWqH;selectedFolderId=MqDAWFH7R4

オブジェクトとして渡している各プロパティーが名前=値の形式でセミコロン(;)区切りで渡されていることがわかります。このように、オブジェクトの値を渡すと、URIがクエリパラメーター表記(home/upsert/42)ではなく、マトリクスURI(Matrix URI)表記になります。

遷移先の画面でオブジェクトを受け取る

渡されたオブジェクトを遷移先の画面で受け取るには、下記のようにparams.getでオブジェクトのプロパティー名を指定してあげると遷移先のコンポーネント上でその値を使えるようになります。

  • memo-upsert.component.ts
// ...省略

    this.route.paramMap.subscribe((params: ParamMap) => {
      this.selectedMemoId = params.get('selectedMemoId');
      this.selectedFolderId = params.get('selectedFolderId');
    });
}

参考サイト

Angular 日本語ドキュメンテーション

AngularのngIf/elseとng-containerタグとng-templateタグを組み合わせて使う

毎回、AngularでngIf/elseを書くたびに書き方を忘れてググっているので書きました。

ngIf/elseとng-containerタグとng-templateタグを組み合わせて使う

ngIf/elseとng-containerタグとng-templateタグを組み合わせて使うことで、余計なタグを生成せずにDOMの表示を切り替えることができます。

ngIf-else構文

条件句の真偽値に応じて、表示するDOMを切り替えることができます。条件句が真の時はngIfのタグを、条件句が偽のときはelseで指定しているタグを表示します。

<ng-container>タグ

テンプレート上で使用しても何も表示されないタグです。*ngIf*ngForの構造化ディレクティブを付けて使用する事で余計なタグを生成せずに、表示するタグの切り替えや、ループを行うことができます。

<ng-container>
  <div>Hey!!!!</div>
</ng-container>

上記の例では、<ng-container>タグ自体はコメント化されるため表示されませんが、ラップしているdiv要素は表示されます

*ngFor<ng-container>上で使っている例

    <ng-container *ngFor="let fraction of fractionList; let i = index">
      <app-fraction-input [(fraction)]="fractionList[i]"></app-fraction-input>
    </ng-container>

<ng-template>タグ

デフォルトでは表示されずに、ラップしている要素ごとコメント化されるタグです。#で名前をつけることができます。

<ng-template>
  <div>Hey!!!!!!</div>
</ng-template>

上記の例では、<ng-template>タグ自体も<ng-template>タグにラップされているdiv要素も表示されません

組み合わせて使った例

ngIf-else構文により、条件句(memoId)がtrueのときは<ng-container>タグでラップされている要素が表示されます。また、条件句が偽の時にはelseで指定されているcreateLabelブロック(<ng-template>タグ)の方が表示されます。

<!-- 条件が真の時には「Update」と表示される -->
<ng-container *ngIf="memoId; else createLabel">Update</ng-container>
<!-- 条件が偽の時には「Create」と表示される -->
<ng-template #createLabel>Create</ng-template>

環境

  • Angular CLI: 8.3.20
  • Node: 12.13.1
  • OS: darwin x64
  • Angular: 8.2.14

参考サイト

コンポーネントにおけるObservableの購読 - Angular After Tutorial

Angularの便利タグng-container, ng-content, ng-template - Qiita

https://angular.jp/guide/structural-directives

【障害メモ】[Ionic v3][Android]電話の発信と管理を許可しないとDevice Idを取得できない

f:id:l08084:20200222203852p:plain
電話の発信と管理の許可についてのモーダル

障害内容

Ionic v3で作成されたAndroidアプリで、「電話の発信と管理を許可しますか?」と表示されるモーダルで「許可しない」を選択したユーザーの、端末識別ID(Unique Device ID)を取得できないエラーが発生した。

環境

  • cordova (Cordova CLI) : 8.0.0
  • Ionic Framework : ionic-angular 3.9.5
  • Android 9.0

原因

端末識別ID(Unique Device ID)を取得するのに使用しているCordovaプラグインであるUniqueDeviceIDが、電話の発信と管理を許可していないユーザーに対応していないのが原因となる。電話の発信と管理を許可していないユーザーの端末からDevice IDを取得できないため、下記のエラーを出力してDevice IDの取得に失敗する。

app.js:1 Exception occurred: getDeviceId: uid 10192 does not have android.permission.READ_PHONE_STATE.

エラーが発生した箇所

電話の発信と管理を許可していないユーザーの場合は、Device IDの取得に失敗するためthis.uniqueDeviceID.get()の部分で上記のエラーが発生する。

import { UniqueDeviceID } from '@ionic-native/unique-device-id';

@Injectable()
export class DeviceIdGetService {
    private deviceId: string;

    constructor(private uniqueDeviceID: UniqueDeviceID, private platform: Platform) {
        this.deviceId = '';
    }

    public init() {
        if (this.platform.is('cordova')) {
   // 電話の発信と管理を許可していない場合は、DeviceIdを取得できない
            this.uniqueDeviceID.get()
                .then((uuid: any) => {
                    this.deviceId = this.getPlatformPrefix().concat(uuid);
                })
                .catch((error: any) => {
                    console.log(error);
                });
        } else {
            this.deviceId = 'Browser';
        }
    }
}

対応方針

アプリによる電話の発信と管理を許可するようにユーザーに強制することはできないため、device idの取得に失敗してもアプリを利用できるようにするか、電話の発信と管理を許可していないユーザーを検知して、許可を促すメッセージを表示するしかない。

【TypeScript】Promiseをasync/awaitに書き直す

はじめに

非同期処理を書く時にPromiseを使いがちでasync/awaitを全然使えてない......かたっぱしからPromiseで書いた処理をasync/awaitに書き換えていくことでasync/await力を高めたい

環境

  • typescript@3.5.3

実践

Promiseで書いている非同期処理を、async/awaitに書き換えていきます。

例1: Promiseが値を返さないパターン

Promiseが値を返さないパターン(Promise<void>)の処理をasync/awaitを使った処理で書き直しました。

Before: Promiseで書かれた処理

Promise<void>を返すメソッドupdateMemo()をメソッドonSubmit()から呼んでいます。

updateMemo()はWebAPI(Firebase)にアクセスしてメモデータの更新処理を実施し、その実行結果をPromiseとしてonSubmit()に返しています。

  public onSubmit() {
    // スピナーを表示する
    this.spinnerService.show();

    this.updateMemo(this.memo)
      .then(() => {
        // Promiseが成功したら入力フォームをリセットする
        this.createFormGroup.reset();
      })
      .finally(() => {
        // Promiseが成功しても失敗してもスピナーを非表示にする
        this.spinnerService.hide();
      });
  }

  // Promiseを返すメソッド
  public updateMemo(memo: Memo): Promise<void> {
    return this.memoCollection.doc(memo.id).update({
      title: memo.title,
      description: memo.description,
      folderId: memo.folderId,
      updatedDate: memo.updatedDate
    });
  }

onSubmit()では、WebAPI(Firebase)からレスポンスが帰ってきた後の処理をthen()の中に書いています。

After: async/awaitに書き直した処理

先ほどの処理をasync/awaitで書き直したのが下記となります。

awaitを使うことで指定した関数のPromiseの結果が返されるまで処理を待機させることができるため、このように非同期処理を逐次処理のように記述することができます。

  public async onSubmit() {
    // スピナーを表示する
    this.spinnerService.show();

    try {
      await this.memoService.updateMemo(this.memo);
      // Promiseが成功したら入力フォームをリセットする
      this.createFormGroup.reset();
    } catch (err) {
      console.log(err);
    } finally {
      // Promiseが成功しても失敗してもスピナーを非表示にする
      this.spinnerService.hide();
    }
  }

  // Promiseを返すメソッド
  public updateMemo(memo: Memo): Promise<void> {
    return this.memoCollection.doc(memo.id).update({
      title: memo.title,
      description: memo.description,
      folderId: memo.folderId,
      updatedDate: memo.updatedDate
    });
  }

Promiseを返すメソッドを呼び出す処理の前にthis.memoService.updateMemo(this.memo);awaitを記載しています。 また、awaitはasyncがついているfunction内でしか使えないので、onSubmit()の前にasyncを追記しました。

例2: Promiseが値を返すパターン

Promiseが値を返すパターン(Promise<firebase.firestore.DocumentReference>)の処理をasync/awaitを使った処理で書き直しました。

Before: Promiseで書かれた処理

先ほどの例と同じような処理ですが、今回はPromiseから値を返しているので、then()内でPromiseの返した値を受け取って処理に使っています。

  public onSubmit() {
    // スピナーを表示する
    this.spinnerService.show();

    this.registerMemo(this.memo)
      .then(docRef => {
        this.memoService.memoCollection.doc(docRef.id).update({
          id: docRef.id
        });
        // フォームの内容をリセットする
        form.resetForm();
      })
      .finally(() => {
        // スピナーを非表示にする
        this.spinnerService.hide();
      });
  }

  // Promiseを返すメソッド
  public registerMemo(
    memo: Memo
  ): Promise<firebase.firestore.DocumentReference> {
    return this.angularFireStore.collection('memos').add(memo);
  }
After: async/awaitに書き直した処理

一つ目の例と同様にPromiseを返すメソッドを呼び出す部分にawaitを記載しています(const docRef = await this.registerMemo(this.memo);)が、それだけではなく、Promise内で返された値も受け取ってdocRef変数に代入しています。

なお、Promise内でエラーがあった場合はcatch()内に処理が移ります。

  public async onSubmit(form: NgForm) {
    // スピナーを表示する
    this.spinnerService.show();

    try {
      const docRef = await this.registerMemo(this.memo);

      this.memoCollection.doc(docRef.id).update({
        id: docRef.id
      });
      form.resetForm();
    } catch (err) {
      console.log(err);
    } finally {
      // スピナーを非表示にする
      this.spinnerService.hide();
    }
  }

  // Promiseを返すメソッド
  public registerMemo(
    memo: Memo
  ): Promise<firebase.firestore.DocumentReference> {
    return this.angularFireStore.collection('memos').add(memo);
  }

参考サイト

async await - TypeScript Deep Dive 日本語版

async/await 入門(JavaScript) - Qiita

【障害メモ】[ionic-v3][iOS]ion-pickerとion-modalを両方開くとion-modalを閉じることができなくなる

はじめに

クリックイベントが検知されないときって、ほかのDOM要素が覆っていないかとか、z-indexの設定とかしか気にしていなかったんですが、 posinter-events: noneが設定されていたせいでクリックができないケースに遭遇したので備忘録としてメモします。

環境

  • cordova (Cordova CLI) : 8.0.0
  • Ionic Framework : ionic-angular 3.9.5
  • iOS 13

発生した障害

iOS端末で検知、Ionicのドラムロール(ion-picker)を開いた状態でモーダル(ion-modal)も開くと、モーダル上のボタンが押下できなくなる

原因

ドラムロールを開いた時に適用されるCSSにpointer-events: noneが含まれていたため、クリックイベントが無効になっていた

修正方法

画面全体にかかっているpointer-events: noneによってタップイベントがすべてキャンセルされているため、pointer-events: autoを設定することでpointer-events: noneを解除した。

.disable-scroll {
  .ion-page {
    pointer-events: auto;
  }
}

まとめ

クリックイベントを検出できないバグの時に、z-indexの設定を気にするだけでなく、pointer-events:noneになっている可能性も疑うべしという教訓を得た

参考サイト

[iOS 12.2 Beta & Scrolling] Scrolling Freeze Issue with iOS 12.2 Beta · Issue #984 · ionic-team/ionic-v3 · GitHub

pointer-events - CSS: カスケーディングスタイルシート | MDN

VSCodeでGitプッシュすると失敗する

f:id:l08084:20200104194609p:plain
プッシュに失敗したときのダイアログ

はじめに

Visual Studio CodeでGItプッシュするとエラーが発生する事象が発生しました。SourceTreeでは問題なくプッシュができていて、VSCodeでもコミットまではできます。

環境

  • Visual Studio Code Version:1.41.1
  • OS Version: macOS Catalina 10.15.2

発生したエラー

ssh_askpassファイルが存在しませんというエラーメッセージが表示されています。

Git: ssh_askpass: exec(/usr/X11R6/bin/ssh-askpass): No such file or directory

対処方法

$ ssh-add ~/.ssh/id_rsa
  1. VSCodeのターミナルで上記のコマンド($ ssh-add ~/.ssh/id_rsa)を実行して秘密鍵を追加する
  2. VSCodeを再インストールする

参考サイト

Bug: Git: ssh_askpass: exec(/usr/X11R6/bin/ssh-askpass): No such file or directory · Issue #32097 · microsoft/vscode · GitHub

macOSのアップデートをした後にsshキーが無いと言われた - Qiita