0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-01-29

PWA で作った台本ビューアSvelte で作り直すことにしたので、やったことをメモしていきます。

<第7回> 目次によるジャンプ

今回のテーマは、読込んだ台本データの目次を作ってそこから各見出しにジャンプすることです。
しかしその前に、スクロールロックの改善について説明します。

スクロールロックについては 「<第1回> 基本のコンポーネントを作る」「スクロールを固定する」 という章で説明しましたが、少々問題があって最近その改善をしました。
また、目次から見出しへのジャンプにもスクロールロックの挙動が関わりますので、最初に説明しようと思います。

今回の概要

  • パネルを開いた時の、ルート要素のスクロールロックを改善します。
  • 読込んだ台本データから目次を作ります。
  • 目次から選んだ見出しにジャンプする仕組みを作ります。
  • 目次を開いた時に、閲覧中のシーンの項目を強調するようにします。

今回使うソースコードは GitHub のこちらのコミット にあります。

スクロールロックの改善

ここでいう「スクロールロック」とは、メニューなどのパネルを開いた時にドキュメントをスクロールできなくすることです。
パネルをモーダルにする時は下図のようにオーバーレイ (半透明の板) を敷いてパネル以外は操作できなくしますが、なぜかスクロールは出来てしまうので、それを止めます。
07_scroll_lock.png

第1回での問題点

第1回でやったスクロールロックの問題点として、ロックを解除する時 (パネルを閉じた時) に別の位置にスクロールしてしまうケースがありました。
ドキュメントのルート要素の positionfixed から static に戻して、すぐにスクロール位置も元に戻していたのですが、少し待ってからスクロール位置を戻さないといけなかったようです。

しかし、少し待ってから (具体的には setTimeout のコールバックとして) スクロール位置を戻すようにすると、今度は positionstatic になった瞬間が見えるようになりました。
画面全体がちらつくような感じです。

body-scroll-lock の導入

ちらつきを抑えようと色々やってみたのですが上手く行かず、body-scroll-lock というライブラリのお世話になることにしました。

使い方は body-scroll-lock の GitHub に書いてあります。
フレームワークごとに使い方の例がありますが、Svelte はありません。
Svelte では targetElement 引数をコンポーネントのインスタンスにすれば動きました。

以下のコードはパネルを開閉した時の処理は省略しています。
開いた時に lockScroll() を、閉じた時に unlockScroll() を呼べば良いです。

コンポーネントを残してロックする場合の例
<script lang="ts">
  import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
  import MainMenu from './components/MainMenu.svelte'

  let menu: MainMenu // コンポーネントのインスタンス

  /** スクロールロックする */
  function lockScroll(): void {
    disableBodyScroll(menu) // 引数をコンポーネントのインスタンスにする
  }

  /** スクロールロックを解除する */
  function unlockScroll(): void {
    clearAllBodyScrollLocks()
  }
</script>

{#if menuIsOpen}
  <MainMenu
    bind:this="{menu}"
    on:close="{() => { menuIsOpen = false }}"
  />
{/if}

iOS での問題

body-scroll-lock (4.0.0-beta) を使った時に、iOS (16.0.2) で問題がありました。
disableBodyScroll() をすると以下のようになったのです。
07_scroll_lock_ios.png
disableBodyScroll() のソースコード を見てみると、iOS デバイスかどうかで処理を分けているようです。

幸い、「第1回での問題点」は iOS では起きていなかったので、iOS の場合は body-scroll-lock を使わないようにしました。

改善後のコード

改善後のコードは以下のようになりました (→ソース全文)。
isIOS別途定義 して import しています。

src/App.svelte (抜粋)
<script lang="ts">
  /* import (略) */
  /* 変数宣言 (略) */

  const rootElement = document.documentElement
  let viewerTop = HEADER_HEIGHT

  // スクロールロックを解除した直後に実行するコールバック
  let onUnlockScroll: (() => void) | null = null

  /* 中略 */

  $: isModal = tocIsOpen || menuIsOpen || dataIsOpen || aboutIsOpen

  // モーダル状態が変わった時の処理
  $: if (viewer) {
    if (isModal) {
      lockScroll() // スクロールロックする
    } else {
      unlockScroll() // スクロールロックを解除する
    }
  }

  /** スクロールロックする */
  function lockScroll(): void {
    // iOS の場合は style を書き換える方法を使う
    if (isIOS) {
      const scrollTop = rootElement.scrollTop
      rootElement.style.position = 'fixed'
      viewerTop = HEADER_HEIGHT - scrollTop
    }
  }
  // iOS 以外では body-scroll-lock を使う
  $: if (!isIOS) {
    if (toc) {
      disableBodyScroll(toc)
    } else if (menu) {
      disableBodyScroll(menu)
    } else if (data) {
      disableBodyScroll(data)
    } else if (about) {
      disableBodyScroll(about)
    }
  }

  /** スクロールロックを解除する */
  function unlockScroll(): void {
    if (isIOS) {
      // iOS の場合は style を書き換える方法を使う
      const scrollTop = HEADER_HEIGHT - viewerTop
      viewerTop = HEADER_HEIGHT
      rootElement.style.position = 'static'
      rootElement.scrollTop = scrollTop
    } else {
      // iOS 以外では body-scroll-lock を使う
      clearAllBodyScrollLocks()
    }
    // コールバックがあれば実行する
    if (onUnlockScroll) {
      setTimeout(() => {
        onUnlockScroll()
        onUnlockScroll = null
      }, 0)
    }
  }
</script>

<Viewer
  bind:this="{viewer}"
  bind:psc
  bind:top="{viewerTop}"
/>

<!-- 中略 -->

{#if menuIsOpen}
  <MainMenu
    bind:this="{menu}"
    on:close="{() => { menuIsOpen = false }}"
  />
{/if}

<!-- 他のパネル (略) -->

今のところ複数のパネルを同時に開くことはありませんが、iOS 以外でスクロールロックする時にどのパネルを有効にするかの判定順は、Android の Back ボタンで閉じるパネルを決める時 と同じにしてあります。
そして、リアクティブ宣言 を使って lockScroll() の外で処理しています。

第1回ではスクロールロック時に main 要素の top を動かすことで見た目の位置をロック前に合わせていました。
その後 main 要素を廃止したので、ここでは Viewer コンポーネントtop プロパティを持たせて同じことをしています。

onUnlockScroll

上記のコードで定義している onUnlockScroll は、スクロールロックを解除した直後に実行するコールバックです。
目次から見出しにジャンプする処理など、パネルを閉じてスクロールロックを解除してから実行したい処理は、このコールバックにセットすることにします。

目次を作る

目次のデータ

目次のデータは台本の見出し行 (題名, 登場人物見出し, 柱) の情報を抽出したものです。
この情報を PSc クラスのプロパティにしました。
PSc クラスについては 「<第5回> データを読込む」 を参照してください。

PSc クラスに headlines というプロパティを作って、コンストラクタで値を生成するようにしました (→ソースコード)。
これで、今まで通り台本データを読込むだけで目次データを使えます。

あとは目次である Toc コンポーネントにもプロパティを作って、App コンポーネントの psc プロパティをバインドしておけば、その情報を使えるようになります。

src\App.svelte (抜粋)
{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    on:close="{() => { tocIsOpen = false }}"
  />
{/if}
src\components\Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  export let psc: PSc
</script>

<div class="panel" class:gone>
  <!-- 中略 -->
  <ul>
    {#each psc.headlines as item}
      <li>{item.text}</li>
    {/each}
  </ul>
</div>

07_toc.png

見出しにジャンプする

目次項目を選択した時に見出しにジャンプするには、最終的にはドキュメントのルート要素をスクロールさせる必要があります。
どこへスクロールさせるかは見出し要素の位置によるので、Viewer コンポーネントで計算します。
つまりこの処理には、App とその子である Toc, Viewer という3つのコンポーネントが関わります。

コンポーネントの親子関係
App
 ┣━ Toc
 ┗━ Viewer
  1. Toc がイベントを dispatch して自分を閉じる。
  2. App がスクロールの処理を onUnlockScroll にセットする。
  3. その処理の中で Viewer にスクロール位置を問い合わせる。

Toc の実装

(→ソース全文)

src/components/Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */

  /** index 番目の見出し行にスクロールする */
  function goToHeadline(index: number) {
    dispatch('goToHeadline', { index })
    // Android の場合は履歴を遡りながらスクロールする必要がある
    // close() 後に履歴を遡るとスクロールがリセットされてしまう
    if (isAndroid) {
      back()
    } else {
      close()
    }
  }
</script>

<div class="panel" class:gone>
  <!-- 中略 -->
  <ul>
    {#each psc.headlines as item, index}
      <li on:click="{() => { goToHeadline(index) }}">
        {item.text}
      </li>
    {/each}
  </ul>
</div>

on:clickgoToHeadline というイベントを dispatch しています。
引数には index (何番目の見出しか) を渡しています。

dispatch した後に Android の場合は close() ではなく back() をしていますが、これはスクロールをリセットされてしまうのを防ぐためです。
close() からも back() が呼ばれていて「履歴があれば遡る」という動きをするので、先に back() を呼ぶことで、閉じた後に履歴を遡らない (スクロールをリセットされない) ようにしています (<第4回> Back ボタン対応 を参照)。

Viewer の実装

(→ソース全文)

src/components/Viewer.svelte (抜粋)
<script lang="ts">
  /* 中略 */

  /** 行の Y 座標を返す */
  export function getLineY(index: number): number {
    if (index == 0) {
      return 0
    }
    /* 行には必ず <p> があるので children[0] を対象とする */
    const target = container.children[index].children[0] as HTMLElement
    const marginTop = window.getComputedStyle(target).marginTop
    const y = target.offsetTop - parseFloat(marginTop)
    return y
  }

  /** 見出し行の Y 座標を返す */
  export function getHeadlineY(index: number): number {
    return getLineY(psc.headlines[index].lineIndex)
  }

  /* 中略 */
</script>

App の実装

(→ソース全文)

src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */

  /** スクロールロック解除後に見出し行に移動するという設定をする */
  function goToHeadline(index: number): void {
    onUnlockScroll = () => {
      const y = viewer.getHeadlineY(index)
      rootElement.scrollTop = y
    }
  }
</script>

{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    on:close="{() => { tocIsOpen = false }}"
    on:goToHeadline="{(e) => { goToHeadline(e.detail.index) }}"
  />
{/if}

目次で現在のシーンを強調する

目次を開いた時に、閲覧中のシーンの項目を強調するようにします。
07_highlight_toc_item.png

Toc コンポーネントに current というプロパティを持たせて、この値に相当する項目 (<li> 要素) に、強調表示のためのクラスを指定しています。

src/components/Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  export let current: number
</script>

<div class="panel" class:gone>
  <!-- 中略 -->
  <ul>
    {#each psc.headlines as item, index}
      <li
        class:current="{ index == current }"
        on:click="{() => { goToHeadline(index) }}"
      >
        {item.text}
      </li>
    {/each}
  </ul>
</div>

<style>
  .current {
    font-weight: bold;
  }
</style>

これを開く App コンポーネント側では、現在のスクロール位置から強調する見出し (current に渡す値) を判定する必要があります。
この判定は2段階になっていて、まず画面の一番上に表示されている行の番号を Viewer に判定してもらって、次にその行が属する見出しの番号を PSc クラスに判定してもらいます。

src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  let currentTocIndex = 0

  /** スクロール位置の見出しを判定してから目次を開く */
  function openToc(): void {
    const currentLineIndex = viewer.getLineIndexAtY(rootElement.scrollTop)
    currentTocIndex = psc.headlineForLine(currentLineIndex)
    tocIsOpen = true
  }
</script>

<!-- 中略 -->

{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    bind:current="{currentTocIndex}"
    on:close="{() => { tocIsOpen = false }}"
    on:goToHeadline="{(e) => { goToHeadline(e.detail.index) }}"
  />
{/if}

判定用の2つの関数は PWA や Svelte とあまり関係ないので、ソースのリンクを貼るにとどめます。どちらも バイナリサーチ を使っています。

Viewer.getLineIndexAtY() には動作確認用のコードがあります。
App.svelteViewer.svelte/* DEBUG */ と書かれているところのコメントアウトを解除すると、スクロールを監視して常に画面の一番上の行 (と判定された行) が赤くなるようになります。
07_debug.png

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?