Svelte で PWA (台本ビューア) <第4回>

[PWA で作った台本ビューア](https://github.com/satamame/pscv/tree/0.0.2) を [Svelte](https://svelte.jp) で作り直すことにしたので、やったことをメモしていきます。

- 前回 : [<第3回> 更新プロンプトを出す](https://qiita.com/satamame/items/36c6761d363ca3894824)
- 次回 : [<第5回> データを読込む](https://qiita.com/satamame/items/529b016e95f6d412dbad)
- [シリーズ記事一覧](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee#%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%E8%A8%98%E4%BA%8B%E4%B8%80%E8%A6%A7)

# <第4回> Back ボタン対応

**今回の概要**

- Android の Back ボタンに対応するためのハンドラを作ります。
- ハンドラがアプリの状態に応じて正しく動作するようにします。
- Back ボタン以外の方法で「閉じる」をした時の整合性を考えます。

今回使うソースコードは [GitHub のこちらのコミット](https://github.com/satamame/pscv/tree/qiita.04) にあります。

# Android の Back ボタン

Back ボタンは Android OS に備わっているインターフェイスで、JavaScript にはそのイベントを取得するような API がありません。
それでも PWA をネイティブアプリのように動かしたければ、少々トリッキーなことをする必要があります。

PWA はブラウザのエンジンで動いていますので、Back ボタンを押したときにブラウザに何が起こるかを考えて、それを逆手に取ったような JavaScript を書きます。

## Back ボタンの動き

ブラウザで Back ボタンを押した時の動きは以下の3種類があります。

1. プルダウンやモーダルダイアログなどの UI を閉じる。
2. 閉じるものが無ければ、ページの履歴を遡る。
3. ページの履歴がなければ、ブラウザを閉じる。

このうち、2 と 3 は PWA でも自動的に起こります。
また 1 の UI のうちプルダウンなどブラウザの機能で表示しているものも、Back ボタンを押せば自動的に閉じます。

PWA で問題となるのが、[第1回](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee) で作ったダイアログやドロワーのような、**JavaScript で開いたパネルを閉じる時**です。

![04_back_button.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/32fa4c4d-e85d-6b0f-f996-5d5b666580c4.png)

# History API を使う

JavaScript では Back ボタンそのもののイベントを取ることはできませんが、Back ボタンによって履歴を遡る動作になる時は [popstate イベント](https://developer.mozilla.org/ja/docs/Web/API/Window/popstate_event) を取ることができます。
なのであらかじめ**履歴を捏造**しておけば Back ボタンが押されたことを検知できます。
それには [History API](https://developer.mozilla.org/ja/docs/Web/API/History_API) を使います。

## 考えなければならないこと

履歴を操作する上で考えなければならないことがいくつかありますので、以下で説明します。
ただし、このプロジェクトはいわゆる router は使っていませんので、router を併用した時の問題は考慮していません。
つまり、Back ボタンの処理のために捏造した履歴だけを考えます。

### 1. 考え得る状況を把握する

まず、少なくとも履歴がある状態とない状態があります。
履歴がない状態で Back ボタンを押すとアプリが閉じます。
逆の言い方をすると、Back ボタンでアプリを閉じてほしい状況では**履歴が存在してはいけません。**

:::note info
本記事では、戻る方向に履歴があることを「履歴がある」と言うことにします。
:::

履歴がある状態では、Back ボタンによって何をするか決めなければなりません。
このプロジェクトでは今のところ Back ボタンで閉じたいパネルが3個あるので、そのうちのどれかを閉じることになります。

履歴に情報を持たせてそれを元に決めることもできますが、ここでは出来るだけシンプルに、Back ボタンが押されるたびに画面の状況から判断することにします。
これにより捏造する履歴は1個で十分となります。

画面の状況は以下のいずれかとなります。

1. パネルが開いていない。
2. 目次 (`Toc` コンポーネント) が開いている。
3. メニュー (`MainMenu` コンポーネント) が開いている。
4. バージョン情報 (`About` コンポーネント) が開いている。

### 2. 履歴を捏造するタイミング

Back ボタンでアプリを閉じてほしい状況では履歴があってはならないので、履歴を捏造するタイミングは各パネルが開く時にすべきです。
逆に、パネルが開いていない状況に戻る時は履歴は削除されなければなりません。

### 3. パネルを閉じる方法による違い

Back ボタンでパネルを閉じる時は、履歴を遡りながら (つまり捏造した履歴を削除しながら) 閉じることになります。
他の方法 (×ボタン等) で閉じる場合はこれが起こらないので、[history.back()](https://developer.mozilla.org/ja/docs/Web/API/History/back) で履歴を削除する必要があります。

# Android の判定

Back ボタンに関わる処理は Android だけで実行したいので、Android であることを判定できるように [src/lib/ua.ts](https://github.com/satamame/pscv/blob/qiita.04/src/lib/ua.ts) を作りました。

```typescript:src/lib/ua.ts
const ua = navigator.userAgent.toLowerCase()

export const isAndroid = ua.indexOf("android") >= 0
```

判定したいところで `isAndroid` を `import` すれば OK です。

# 履歴を捏造する関数

新しく src/lib/back.ts を作って、Back ボタン周りの関数はここに書くことにしました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.04/src/lib/back.ts))。
まずは Back ボタンが押された場合に備えて履歴を捏造しておくための関数です。

```typescript:src/lib/back.ts (抜粋)
/** Back ボタンに備えて履歴を捏造しておく関数 */
export function keepBackable() {
  if (!history.state?.backHook) {
    history.pushState({ backHook: true }, '')
  }
}
```

History API の [pushState()](https://developer.mozilla.org/ja/docs/Web/API/History/pushState) でブラウザの履歴を追加できます。
第1引数に状態を表すオブジェクトを設定すれば、あとで `history.state` で参照することができます。

ここではこの「状態」を使って、「状態に 'backHook' というプロパティがあって true なら Back ボタン用の履歴である」というルールにしています。

この関数はパネル (Back ボタンで閉じたいもの) を開くタイミングで呼ぶべきなので、たとえば目次 (`Toc` コンポーネント) の `onMount` を以下のようにすれば良いです。

```html:src/components/Toc.svelte (抜粋)
<script lang="ts">
  import { isAndroid } from '../lib/ua'
  import { keepBackable } from '../lib/back'

  /* 中略 */

  onMount(async () => {
    if (isAndroid) { keepBackable() } // ここで履歴を捏造する
    setTimeout(() => { gone = false }, 0)
  })
  /* 中略 */
</script>
```

# Back ボタン用のハンドラを用意する

Back ボタン用のハンドラは実のところ `popstate` イベントハンドラです。
そしてハンドラの中で今の画面の状況から処理を判定できるように、情報を渡す仕組みが必要です。
とりあえず以下のような「イベントハンドラを設定するための関数」を作ることにします。

```typescript:src/lib/back.ts (作りかけ)
/** Back ボタンハンドラを設定する関数 */
export function initBackHandler(getInfo: () => object): void {
  // イベントハンドラを設定する
  window.addEventListener('popstate', function (event) {
    const info = getInfo()
    // Back ボタンを押された時の処理
  }
}
```

このように何らかの情報を渡す関数 (ここでは `getInfo`) を引数に取ることで、ハンドラが実行された時にその関数から今の状況を取れるようにしておきます。

## ハンドラに渡したい情報

ハンドラに渡したい情報は「どのパネルが開いているか」という情報ですが、それとともに「開いているパネルを閉じる関数」も渡したいです。
というか、各パネルについて「開いていれば `close` 関数、開いていなければ `undefined`」を渡すことで、開いているかどうかも判定できます。

以上のことから、`initBackHandler()` の引数 (`getInfo` だったもの) から取得する情報は以下のような型にしました。

```typescript:src/lib/back.ts (抜粋)
export type BackFunc = {
  toc: (() => void) | undefined,
  menu: (() => void) | undefined,
  about: (() => void) | undefined,
}
```

## ハンドラの中身の実装

現状ではパネルは最大で1枚までしか開かないので、優先順位などは考えずに開いているパネルを閉じれば良いです。
厳密には `About` コンポーネントは `MainMenu` コンポーネントが閉じ終える前 (アニメーション中) に開きますが、そのタイミングで Back ボタンを押されることは稀ですし、押されたとしても何も起こらないか `About` コンポーネントが閉じるだけです。
そうなるように、複数の事が同時に起きたり同じ事が繰返し起きたりすることを避ける実装にしています。

`initBackHandler` の実装は以下のようになります。
`getInfo` だった引数は `getBackFunc` という名前に変えて、`BackFunc` 型を返すようにしました。

```typescript:src/lib/back.ts (作りかけ)
/** Back ボタンハンドラを設定する関数 */
export function initBackHandler(getBackFunc: () => BackFunc): void {
  // Back ボタンが押されたら適切な関数を実行するハンドラ
  window.addEventListener('popstate', function (event) {
    const backFunc = getBackFunc() // 各パネルの close 関数を取得
    if (backFunc.toc) {
      backFunc.toc() // Toc の close 関数があれば、それを実行
    } else if (backFunc.menu) {
      backFunc.menu() // MainMenu の close 関数があれば、それを実行
    } else if (backFunc.about) {
      backFunc.about() // About の close 関数があれば、それを実行
    }
  })
}
```

今は `if` 文だけのシンプルな構造ですが、画面の構成が複雑になったらここのロジックを作り込んでいけば良いのです。

# App.svelte 側の対応

App.svelte から `initBackHandler()` を呼ぶ必要があります。
引数は「`BackFunc` 型オブジェクトを返す関数」ですので、以下のようになります ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.04/src/App.svelte))。

```html:src/App.svelte (抜粋)
<script lang="ts">
  import { isAndroid } from './lib/ua'
  import type { BackFunc } from './lib/back'
  import { initBackHandler } from './lib/back'

  /* 中略 */

  let toc
  let menu
  let about

  /* 中略 */

  // Android の Back ボタンのハンドラを準備する
  if (isAndroid) {
    initBackHandler((): BackFunc => {
      // Back ボタンが押された時に実行したい関数を取得するコールバック
      return {
        toc: toc?.close,
        menu: menu?.close,
        about: about?.close,
      }
    })
  }
</script>
```

これが動くためには、`toc`, `menu`, `about` という変数がそれぞれのコンポーネントを指していなければなりません。
また、各 `close` 関数をコンポーネントの外から参照できるように `export` する必要もあります。
たとえば `Toc` コンポーネントの場合はこうなります。

```html:src/App.svelte (抜粋)
{#if tocIsOpen}
  <Toc
    bind:this="{toc}"
    on:close="{() => { tocIsOpen = false }}"
  />
{/if}
```

```html:src/components/Toc.svelte (作りかけ)
<script lang="ts">
  /* 中略 */
  export function close() {
    if (gone) { return } // 閉じるアニメーション開始後は何もしない
    gone = true
    setTimeout(() => {
      dispatch('close')
      if (isAndroid) { history.back() }
    }, 200)
  }
</script>
```

`close` 関数についてはさらに、`history.back()` を呼ぶようにしています。
これは前述した「Back ボタン以外の方法 (×ボタン等) で閉じた時」のために必要なのですが、以下で説明するように少し変更が必要です。

# Back ボタン以外で閉じた時

Back ボタン以外でパネルを閉じた時は、`history.back()` を呼ばないと捏造した履歴が残ったままになって、次に Back ボタンを押した時にアプリが閉じません。
`popstate` ハンドラの中で「パネルを開いていなければアプリを閉じる」という処理ができれば良いのですが、「アプリを閉じる」という API はありません。

そのため、とりあえず `close` 関数の最後で `history.back()` を呼ぶようにします。
実はこれで大体うまく動きます。
なぜなら、Back ボタンでパネルを閉じた時はすでに履歴がなくなっているので、さらに履歴を遡ろうとしても何も起きないからです。

ただし問題がない訳ではありません。
PWA としてインストールせずにブラウザで実行している場合、これだとパネルを閉じるつもりで Back ボタンを押すと前のページ (ホーム画面など) に遷移してしまいます。

そこで、履歴を遡る時も「捏造した履歴がある場合だけ」という制限をつけます。
以下の関数を作って、`close` 関数からはこれを呼ぶようにします。

```typescript:src/lib/back.ts (抜粋)
/** Back ボタン用に捏造した履歴があれば、遡る */
export function back() {
  if (history.state?.backHook) {
    history.back()
  }
}
```

Toc.svelte は以下のようになります ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.04/src/components/Toc.svelte))。

```html:src/components/Toc.svelte (抜粋)
<script lang="ts">
  import { keepBackable, back } from '../lib/back'
  /* 中略 */
  export function close() {
    if (gone) { return }
    gone = true
    setTimeout(() => {
      dispatch('close')
      if (isAndroid) { back() } // 上で作った関数を呼ぶように変更した
    }, 200)
  }
</script>
```

`history.back()` をすると `popstate` ハンドラが実行さるので、Back ボタンを押した場合は `popstate` ハンドラが2回実行されることになります。
ただし2回目が呼ばれた時点でパネルは閉じているので、2回目は何もせずに終了します。

# About.svelte を開く場合

メニューの「バージョン情報」を押して About.svelte を開く時は、`MainMenu` コンポーネントを閉じつつ `About` コンポーネントを開いています。

```html:src/components/MainMenu.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  // 「バージョン情報」を押した時のハンドラ
  function openAbout() {
    close()
    dispatch('openAbout')
  }
</script>
```

しかしここで `close()` をしてしまうと (`close()` 内で `back()` をすることにしたので)、`About` コンポーネントが開いてすぐに閉じてしまいます。
なので `close` を呼ばずに同じことをする (ただし `back()` は呼ばない) ようにします ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.04/src/components/MainMenu.svelte))。

```html:src/components/MainMenu.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  function openAbout() {
    if (gone) { return }
    gone = true
    setTimeout(() => { dispatch('close') }, 200)
    dispatch('openAbout')
  }
</script>
```

About.svelte の `onMount` で `keepBackable()` を呼んでいたとしても履歴は1個までしか作られないので問題ありません。

# Service Worker 更新時の問題

[第3回](https://qiita.com/satamame/items/36c6761d363ca3894824) で実装した Service Worker 更新の処理にも対応する必要があります。

Service Worker 更新時には画面がリロードされてパネルが閉じますが、履歴は残ったままになります。
つまり、何らかのパネルを開いた状態で「更新」ボタンを押すと履歴が残ったままパネルが閉じて、Back ボタンを押してもアプリが閉じなくなります。

もちろんもう1回押せば閉じるのですが、気持ち悪いので最初に1回 `back` 関数を実行しておくことにしました。
場所は `initBackHandler()` の中にしました。

```typescript:src/lib/back.ts (抜粋)
/** Back ボタンハンドラを設定する関数 */
export function initBackHandler(getBackFunc: () => BackFunc): void {
  // Back ボタンが押されたら適切な関数を実行するハンドラ
  window.addEventListener('popstate', function (event) {
    /* 中略 */
  })
  // 念のため、Back ボタン用の履歴をクリアする
  back()
}
```

# 動作確認

動作確認のために画面上にログを出す仕組みを作ってみました。
ソースは [こちらのブランチ](https://github.com/satamame/pscv/tree/qiita/04_debug) にあるので興味のある方はご覧ください。

![04_debug_log.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/3c8faa17-a4eb-a70a-dcbd-30a87ada8700.png)

上の画像で赤く塗ってあるところがログが出ているところです。括弧の中は `history.length` と `history.state` の値です。
これは以下の操作をした時のログです。

1. パネルを開く。
1. Back ボタンで閉じる。
1. パネルを開く。
1. ×ボタンで閉じる。

最初にパネルを開いて以降は履歴の数が2個で固定されていること、×ボタンで閉じた時は捏造した履歴がある状態から「戻る」をしていることが分かります。

# 参考リンク

- [SPAのPWAでAndroidの戻るボタン問題はこれで解決！](https://kahoo.blog/solve-android-back-button-problem-on-pwa/)
- [Android Back Button on a Progressive Web Application closes de App](https://stackoverflow.com/questions/43329654/android-back-button-on-a-progressive-web-application-closes-de-app)