Ionic Advent Calendar 2019 11日目の記事です。
ModalController でモーダルを表示している時にブラウザのバックボタンを押されると、モーダルが開いたまま後ろのページが前のページに戻ります。
モーダルの後ろが遷移したことが分かりやすいようにPC表示の例を示しました。
スマホの場合は「バックボタンを押したら画面が何も変わらず、その後モーダルの×ボタンを押したらいつの間にか前のページに遷移していた」というユーザーにとっては「???」な状況になります。
PCやタブレットで閲覧している場合はモーダルが全画面表示でないことの方が多いので、ユーザーは背景の暗くなっている部分をクリックするか、モーダル右上の×ボタンをクリックしてモーダルを閉じようとするでしょう。
しかしスマホで閲覧している場合はモーダルが全画面表示されることの方が多いので、ユーザーは「前に戻る」の感覚でブラウザバックを行います。特にAndroid端末。端末のバックボタンを押すことが多いと思います。
そんなとき「モーダルが閉じずに後ろの画面が前に戻る」という挙動はいただけません。
バックボタンを押したら(=ブラウザバックしたら)モーダルが閉じてほしい。
というわけで実装していきます。
参考にしたページ
・Ionic4で modal 系をAndroid Hardware Back Button で閉じる方法
・Close Modal in Ionic 4 by Back Button
1. モーダル表示時にダミーの履歴を追加する
モーダルを表示する画面の現在のコードです。
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 を使用して、ダミーの遷移履歴を追加します。
  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が変更され、画面遷移のように扱われます。
2. ブラウザバックでモーダルを閉じる
今の状態でモーダルを表示した後にブラウザバックを行っても、モーダルは閉じません。(URLの #modal は消えるが)
ブラウザバックでモーダルを閉じるためには、ブラウザバックを検知して dismiss メソッドを呼び出す必要があります。
ブラウザバックの検知は window の popstate イベントを監視して行います。
  ngOnInit() {
    fromEvent(window, 'popstate').subscribe(async () => {
      const modal = await this.modalCtrl.getTop();
      if (modal) modal.dismiss();
    });
  }
イベント監視は RxJS の fromEvent を使って購読しています。
ModalController#getTop メソッドは現在表示している一番上のモーダルを取得します。
取得できたらモーダルを閉じます。
このままだと購読処理が残ってしまうので、お作法にならってコンポーネント破棄時に購読解除されるように takeUntil オペレータを噛ませます。
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();
  }
  ...
}
ブラウザバックでモーダルが閉じるようになります。
3. ×ボタンでモーダルを閉じた場合に対応
ユーザーが全員ブラウザバックでモーダルを閉じるとは限りません。iPhoneにはバックボタンがないため、ブラウザバックではなくモーダル右上の×ボタンでモーダルを閉じるケースの方が多いでしょう。
現在のコードで×ボタンを押下するとモーダルを閉じることはできますが、URL末尾に #modal が残ってしまいます。
これを防ぐために、モーダルが閉じ始めたらブラウザの履歴を1つ戻すようにします。
  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 になります。
ここまで実装してようやくブラウザバックでも×ボタンクリックでも閉じるモーダルを実装することができました。
4. サービス化
現在のコードを見てみましょう。
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
モーダル表示関連の処理を移植します。
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の末尾につけるモーダルの名前を受け取ります。
これでアプリ全体で使用できるようになりました。
呼び出し側はこんな感じ。
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() を使用するようなモーダルに対応するよう引数を追加します。
  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時に戻り値を返すこともできます。戻り値を返す拡張を追加します。
  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 メソッドにモーダルの戻り値を渡します。
<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>
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日目の記事 で @y_hokkey さんが丁寧に解説されていますので、そちらをご覧ください。
モーダル呼び出し側はこんな感じ。
  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 を解決して、モーダルで選択された値を取得します。
クリックしたボタンの色を取得することができました。
×ボタンで閉じた場合は null, ブラウザバックで閉じた場合は undefined になります。
やったぜ。
6. 終わりに
最終的なコードを記載しておきます。
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);
  }
}
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;
    }
  }
}
<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>
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アプリを作ることができました。






