ブラウザバックで閉じるモーダルを実装する[Ionic4 Angular]

Ionic Advent Calendar 2019　11日目の記事です。

ModalController でモーダルを表示している時にブラウザのバックボタンを押されると、モーダルが開いたまま後ろのページが前のページに戻ります。

![001-desktop-bad-view.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/3fb1ee89-1c82-4f41-351d-299d7ce457e0.gif)


モーダルの後ろが遷移したことが分かりやすいようにPC表示の例を示しました。
スマホの場合は「バックボタンを押したら画面が何も変わらず、その後モーダルの×ボタンを押したらいつの間にか前のページに遷移していた」というユーザーにとっては「？？？」な状況になります。

![002-mobile-bad-view.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/409587dd-9375-1b45-7708-3e04cfa02d99.gif)


PCやタブレットで閲覧している場合はモーダルが全画面表示でないことの方が多いので、ユーザーは背景の暗くなっている部分をクリックするか、モーダル右上の×ボタンをクリックしてモーダルを閉じようとするでしょう。

しかしスマホで閲覧している場合はモーダルが全画面表示されることの方が多いので、ユーザーは「前に戻る」の感覚でブラウザバックを行います。特にAndroid端末。端末のバックボタンを押すことが多いと思います。
そんなとき「モーダルが閉じずに後ろの画面が前に戻る」という挙動はいただけません。
バックボタンを押したら（＝ブラウザバックしたら）モーダルが閉じてほしい。

というわけで実装していきます。

参考にしたページ
・[Ionic4で modal 系をAndroid Hardware Back Button で閉じる方法](https://qiita.com/nao_honda/items/b7163696f7b5bae5a1c1)
・[Close Modal in Ionic 4 by Back Button](https://stackoverflow.com/questions/52917911/close-modal-in-ionic-4-by-back-button)

## 1. モーダル表示時にダミーの履歴を追加する
モーダルを表示する画面の現在のコードです。

```typescript:second.page.ts
import { Component, OnInit } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { ModalContentComponent } from '../modal-content/modal-content.component';

@Component({
  selector: 'app-second',
  templateUrl: './second.page.html',
  styleUrls: ['./second.page.scss'],
})
export class SecondPage implements OnInit {

  constructor(
    private modalCtrl: ModalController
  ) { }

  ngOnInit() {
  }

  async showModal() {
    const modal = await this.modalCtrl.create({
      component: ModalContentComponent
    });
    modal.present();
  }

}
```
Ionic のモーダルは表示時にURLを変更しません。
モーダル表示時に Web API の History を使用して、ダミーの遷移履歴を追加します。

```typescript:second.page.ts（抜粋）
  async showModal() {
    const modal = await this.modalCtrl.create({
      component: ModalContentComponent
    });
    modal.present();
    // ダミーの履歴を追加
    history.pushState(null, null, `${location.href}#modal`);
  }
```

`history#pushState` メソッドでダミーの履歴を追加します。
第一引数は履歴情報に追加する任意のオブジェクトです。
第二引数は現在使われていません。null で OK。
第三引数は追加する履歴のURLです。今は現在のURLの末尾に `#modal` をつけたURLにします。

これでモーダルを表示した際にURLが変更され、画面遷移のように扱われます。

![003-history-added.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/f475da51-7757-8a9d-a50d-bd52db9edd30.gif)


## 2. ブラウザバックでモーダルを閉じる
今の状態でモーダルを表示した後にブラウザバックを行っても、モーダルは閉じません。（URLの `#modal` は消えるが）
ブラウザバックでモーダルを閉じるためには、ブラウザバックを検知して dismiss メソッドを呼び出す必要があります。
ブラウザバックの検知は window の popstate イベントを監視して行います。

```typescript:second.page.ts（抜粋）
  ngOnInit() {
    fromEvent(window, 'popstate').subscribe(async () => {
      const modal = await this.modalCtrl.getTop();
      if (modal) modal.dismiss();
    });
  }
```

イベント監視は RxJS の `fromEvent` を使って購読しています。
`ModalController#getTop` メソッドは現在表示している一番上のモーダルを取得します。
取得できたらモーダルを閉じます。

このままだと購読処理が残ってしまうので、お作法にならってコンポーネント破棄時に購読解除されるように `takeUntil` オペレータを噛ませます。

```typescript:second.page.ts（一部省略）
export class SecondPage implements OnInit, OnDestroy {

  onDestroy$ = new Subject<void>();

  constructor(
    private modalCtrl: ModalController
  ) { }

  ngOnInit() {
    fromEvent(window, 'popstate')
    .pipe(takeUntil(this.onDestroy$))
    .subscribe(async () => {
      const modal = await this.modalCtrl.getTop();
      if (modal) modal.dismiss();
    });
  }

  ngOnDestroy() {
    this.onDestroy$.next();
  }

  ...

}
```

ブラウザバックでモーダルが閉じるようになります。

![004-popstate-added.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/cfd6a0a1-bdd9-ecde-52ce-7037036ab564.gif)


## 3. ×ボタンでモーダルを閉じた場合に対応
ユーザーが全員ブラウザバックでモーダルを閉じるとは限りません。iPhoneにはバックボタンがないため、ブラウザバックではなくモーダル右上の×ボタンでモーダルを閉じるケースの方が多いでしょう。
現在のコードで×ボタンを押下するとモーダルを閉じることはできますが、URL末尾に `#modal` が残ってしまいます。

![005-popstate-bad-view.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/c1e03140-3d83-4877-1997-1634dead71db.gif)


これを防ぐために、モーダルが閉じ始めたらブラウザの履歴を1つ戻すようにします。

```typescript:second.page.ts（抜粋）
  async showModal() {
    const modal = await this.modalCtrl.create({
      component: ModalContentComponent
    });
    modal.onWillDismiss().then(() => {
      if (history.state == null) history.back();
    });
    modal.present();
    history.pushState(null, null, `${location.href}#modal`);
  }
```

`onWillDismiss` メソッドはモーダルが閉じ始めたら Promise を返します。`Promise#then` 内で `history#back` メソッドを呼び出し、履歴を1つ戻します。

ただし履歴を1つ戻したいのは×ボタンがクリックされたときだけです。ブラウザバックで dismiss された場合には実行したくありません（ブラウザバック時点ですでに履歴は1つ戻っている）。
そこで `history.state == null` をチェックして、 state が null の場合だけ履歴を戻します。この null は、 `history#pushState` メソッドの第一引数です。ブラウザバックしていれば、このダミー履歴は既に破棄されているため、 `state == null` にはなりません。×ボタンでモーダルを閉じた場合は、ダミー履歴が残っているため `state == null` になります。

ここまで実装してようやくブラウザバックでも×ボタンクリックでも閉じるモーダルを実装することができました。

![006-before-serviced.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/b0c90daa-a764-52b5-823a-9a4e0ea8c997.gif)


## 4. サービス化
現在のコードを見てみましょう。

```typescript:second.page.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { ModalContentComponent } from '../modal-content/modal-content.component';
import { fromEvent, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({
  selector: 'app-second',
  templateUrl: './second.page.html',
  styleUrls: ['./second.page.scss'],
})
export class SecondPage implements OnInit, OnDestroy {

  onDestroy$ = new Subject<void>();

  constructor(
    private modalCtrl: ModalController
  ) { }

  ngOnInit() {
    fromEvent(window, 'popstate')
    .pipe(takeUntil(this.onDestroy$))
    .subscribe(async () => {
      const modal = await this.modalCtrl.getTop();
      if (modal) modal.dismiss();
    });
  }

  ngOnDestroy() {
    this.onDestroy$.next();
  }

  async showModal() {
    const modal = await this.modalCtrl.create({
      component: ModalContentComponent
    });
    modal.onWillDismiss().then(() => {
      if (history.state == null) history.back();
    });
    modal.present();
    history.pushState(null, null, `${location.href}#modal`);
  }

}

```
随分とコードが増えてしまいました。これをモーダルを使う画面すべてに記述するのは現実的ではありません。
他の画面でも使えるように、サービス化してしまいましょう。
以下のコマンドを実行して、service ディレクトリ配下に ModalService を作成します。

```
ionic g service service/modal
```

モーダル表示関連の処理を移植します。

```typescript:modal.service.ts
import { Injectable } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { fromEvent } from 'rxjs';

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

  constructor(
    private modalCtrl: ModalController
  ) {
    fromEvent(window, 'popstate')
    .subscribe(async () => {
      const modal = await this.modalCtrl.getTop();
      if (modal) modal.dismiss();
    });
  }

  async present(component: any, modalName: string) {
    const modal = await this.modalCtrl.create({
      component: component
    });
    modal.onWillDismiss().then(() => {
      if (history.state == null) history.back();
    });
    modal.present();
    history.pushState(null, null, `${location.href}#${modalName}`);
  }

}

```
ルートサービスにしたので `takeUntil` オペレータは削除しました。
`present` メソッドには第一引数でモーダル表示するコンポーネント、第二引数でURLの末尾につけるモーダルの名前を受け取ります。

これでアプリ全体で使用できるようになりました。
呼び出し側はこんな感じ。

```typescript:second.page.ts
import { Component, OnInit } from '@angular/core';
import { ModalContentComponent } from '../modal-content/modal-content.component';
import { ModalService } from '../service/modal.service';

@Component({
  selector: 'app-second',
  templateUrl: './second.page.html',
  styleUrls: ['./second.page.scss'],
})
export class SecondPage implements OnInit {

  constructor(
    private modalService: ModalService
  ) { }

  ngOnInit() {
  }

  showModal() {
    this.modalService.present(ModalContentComponent, 'modal');
  }

}
```
作成した ModalService をDIして、`ModalService#present` メソッドを呼び出すだけです。

## 5. ModalService#present メソッドの拡張
Ionic の ModalController は通常の Component のように `@Input()` に値を渡すことができます。
 `@Input()` を使用するようなモーダルに対応するよう引数を追加します。

```typescript:modal.service.ts（抜粋）
  async present(component: any, modalName: string, props: {[key: string]: any} = {}) {
    const modal = await this.modalCtrl.create({
      component: component,
      componentProps: props // @Input()に渡すパラメータ
    });
    modal.onWillDismiss().then(() => {
      if (history.state == null) history.back();
    });
    modal.present();
    history.pushState(null, null, `${location.href}#${modalName}`);
  }
```

省略可能引数 `props` を追加しました。

さらにModalController は dismiss時に戻り値を返すこともできます。戻り値を返す拡張を追加します。

```typescript:modal.service.ts（抜粋）
  async present(component: any, modalName: string, props: {[key: string]: any} = {}, hasReturnValue: boolean = false) {
    const modal = await this.modalCtrl.create({
      component: component,
      componentProps: props
    });
    modal.onWillDismiss().then(() => {
      if (history.state == null) history.back();
    });
    modal.present();
    history.pushState(null, null, `${location.href}#${modalName}`);
    if (hasReturnValue) {
      // 戻り値フラグが立っている場合
      const detail = await modal.onDidDismiss();
      return detail.data;
    }
  }
```
第四引数にモーダルの戻り値の有無を表す省略可能引数 `hasReturnValue` を追加しました。
true のときは `modal.onDidDismiss` メソッドが返す Promise を解決して `detail` を取得し、`detail.data` を返します。

モーダル側では dismiss メソッドにモーダルの戻り値を渡します。

```html:modal-content.component.html
<ion-header>
  <ion-toolbar>
    <ion-title>modal</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="close()">
        <ion-icon slot="icon-only" name="close"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <p>{{ message }}</p>
  <ion-button (click)="close('blue')">BLUE</ion-button>
  <ion-button color="danger" (click)="close('red')">RED</ion-button>
</ion-content>
```

```typescript:modal-content.component.ts
import { Component, OnInit, Input } from '@angular/core';
import { ModalController } from '@ionic/angular';

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

  @Input() message: string;

  constructor(
    private modalCtrl: ModalController
  ) { }

  ngOnInit() {}

  close(color: string = null) {
    this.modalCtrl.dismiss(color);
  }

}

```
`ModalController#dismiss` メソッドに `color` を渡しています。

このあたりの Modal のデータインプット・アウトプットについては、[9日目の記事](https://qiita.com/y_hokkey/items/6c0c72f8858b479d8105#%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%81%AE%E6%B8%A1%E3%81%97%E6%96%B9-1) で @y_hokkey さんが丁寧に解説されていますので、そちらをご覧ください。

モーダル呼び出し側はこんな感じ。

```typescript:second.page.ts（抜粋）
  async showModal() {
    const selectedColor = await this.modalService.present(
      ModalContentComponent, 'modal', { message: 'select color!' }, true
      );
    console.log(selectedColor);
  }
```
`present` メソッドの第四引数に true を渡し、モーダルが閉じられるのを待ちます。
モーダルが閉じられると `ModalService#present` メソッド内で dismiss メソッドの戻り値が解決され、 `ModalService#present` メソッドはモーダル呼び出し側に Promise で返します。モーダル呼び出し側で Promise を解決して、モーダルで選択された値を取得します。

![007-final.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220796/73dff43d-59a0-9ba0-c52b-4d53aeab9876.gif)

クリックしたボタンの色を取得することができました。
×ボタンで閉じた場合は null, ブラウザバックで閉じた場合は undefined になります。

やったぜ。

## 6. 終わりに
最終的なコードを記載しておきます。

```typescript:second.page.ts（モーダルを利用する画面）
import { Component, OnInit } from '@angular/core';
import { ModalContentComponent } from '../modal-content/modal-content.component';
import { ModalService } from '../service/modal.service';
import { ModalController } from '@ionic/angular';

@Component({
  selector: 'app-second',
  templateUrl: './second.page.html',
  styleUrls: ['./second.page.scss'],
})
export class SecondPage implements OnInit {

  constructor(
    private modalService: ModalService
  ) { }

  ngOnInit() {
  }

  async showModal() {
    const selectedColor = await this.modalService.present(
      ModalContentComponent, 'modal', { message: 'select color!' }, true
      );
    console.log(selectedColor);
  }

}

```

```typescript:modal.service.ts（モーダル表示サービス）
import { Injectable } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { fromEvent } from 'rxjs';

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

  constructor(
    private modalCtrl: ModalController
  ) {
    fromEvent(window, 'popstate')
    .subscribe(async () => {
      const modal = await this.modalCtrl.getTop();
      if (modal) modal.dismiss();
    });
  }

  async present(component: any, modalName: string, props: {[key: string]: any} = {}, hasReturnValue: boolean = false) {
    const modal = await this.modalCtrl.create({
      component: component,
      componentProps: props
    });
    modal.onWillDismiss().then(() => {
      if (history.state == null) history.back();
    });
    modal.present();
    history.pushState(null, null, `${location.href}#${modalName}`);
    if (hasReturnValue) {
      const detail = await modal.onDidDismiss();
      return detail.data;
    }
  }

}
```

```html:modal-content.component.html（モーダル画面View）
<ion-header>
  <ion-toolbar>
    <ion-title>modal</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="close()">
        <ion-icon slot="icon-only" name="close"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <p>{{ message }}</p>
  <ion-button (click)="close('blue')">BLUE</ion-button>
  <ion-button color="danger" (click)="close('red')">RED</ion-button>
</ion-content>
```

```typescript:modal-content.component.ts（モーダル画面Model）
import { Component, OnInit, Input } from '@angular/core';
import { ModalController } from '@ionic/angular';

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

  @Input() message: string;

  constructor(
    private modalCtrl: ModalController
  ) { }

  ngOnInit() {}

  close(color: string = null) {
    this.modalCtrl.dismiss(color);
  }

}

```


開発者としては「×ボタンあるんだからブラウザバックで戻らないでよ...」と思いつつ、いざAndroidで実機テストするとついついバックボタンを押してしまうんですよね。
スマホの場合モーダルが全画面表示になるので、「今その画面は遷移して表示されたのか、それともモーダルなのか」はユーザーは認識できません。というか普段の慣れで「バックボタン押せば前の画面に戻る」と思ってしまいます。

よりネイティブに近い挙動をするWebアプリを作ることができました。