LoginSignup
2
1

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

Last updated at Posted at 2022-09-26

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

使っている技術

  • Svelte (チュートリアル終えたくらい)
  • PWA (簡単なアプリを作れるくらい)
  • TypeScript (初心者)

記事リンク

<第1回> 基本のコンポーネントを作る

今回の概要

  • 今回はプロジェクトを作るところからです。
  • まだ PWA にはしていません。
  • 基本的な UI としてヘッダとドロワーをコンポーネントにするところまでです。

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

元の台本ビューアについて

元になっている台本ビューアのソースコードは GitHub のこちらのコミット にあります。
実際に動くものは以下にあります。

機能

  • シーン目次
  • 台本内検索
  • 文字サイズ変更
  • 横書き・縦書き

01_pscv002.png

プロジェクト作成

Windows 10 で、node, npm, yarn がインストールされている前提です。
私の環境では以下のバージョンでした。

  • node : 16.17.0
  • npm : 8.18.0
  • yarn : 1.22.19

プロジェクトは Vite で作って、TypeScript も使える設定にしました。

> yarn create vite pscv --template svelte-ts

次にプロジェクトフォルダ (この場合は pscv) に入ってパッケージのインストールをしました。

> cd pscv
> yarn

これで node_modules フォルダや設定ファイルが出来ます。
以下のコマンドで開発サーバが起動します。

> yarn dev

TypeScript を使う場合の変更

tsconfig.json に以下の設定を追加しました。
この設定をしないと、コンポーネントのファイル名などを大文字始まりにした時にエラーになるようです。

tsconfig.json (抜粋)
{
  "compilerOptions": {
    (中略)
    "forceConsistentCasingInFileNames": false  <- これを追加
  },
}

マウント先を変える

インストール直後は、index.html の body 内の div 以下にアプリがマウントされるようになっています。
これを body 直下にマウントするようにしました。

理由は、これから作る main 要素や Header コンポーネントを body 直下に置きたいからです。
もし div をすげ替えるなど SPA 的なことをするなら、この構造は変えない方が良いです。

やり方は、まず index.html の body 内の div を削除します (→ソース全文)。

index.html (抜粋)
  <body>
    <div id="app"></div>  <!-- この行を削除 -->
    <script type="module" src="/src/main.ts"></script>
  </body>

そして src/main.ts の target を変更します (→ソース全文)。

src/main.ts (抜粋)
const app = new App({
  target: document.getElementById('app')  // 変更前
  target: document.body                   // 変更後
})

不都合な操作を封じる

src/app.css を以下のようにして、スマホ特有の不都合な操作を封じました (→ソース全文)。

src/app.css (抜粋)
* {
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;  /* 強調をなくす */
  touch-action: manipulation;  /* ダブルタップでズームさせない */
}

:root {
  -webkit-text-size-adjust: 100%;  /* Safari 回転対策 */
}

body {
  overscroll-behavior: none;  /* ドラッグでリロードさせない */
}

リロードさせたくない理由は、Android デバイスの Back ボタンをフックするために window.history を使うからです。

ヘッダ

ヘッダに台本のタイトルとドロワー (目次とメニュー) のボタンを表示するようにして、これを Header コンポーネントとしました。

01_header.png

ヘッダなのでスクロールで動かないように position: fixed; にします。
それ以外のポイントとして (1) 画像のパスを正しく設定する, (2) ボタンのイベントを親コンポーネントに渡す, があります。

画像のパスを正しく設定する

画像は以下のパスに配置しました。
※ Google の Material Icons からダウンロードして使っています。

pscv
 └─public
    └─ui_icon
       ├─menu_open_black_24dp.svg
       └─toc_black_24dp.svg

public フォルダ内のコンテンツは、ビルドするとアプリのルートにコピーされます。

Header コンポーネントでは画像のパスを以下のように指定しました (→ソース全文)。

src/components/Header.svelte (抜粋)
<script lang="ts">
  import tocIcon from '/ui_icon/toc_black_24dp.svg'
  import menuIcon from '/ui_icon/menu_open_black_24dp.svg'
</script>

<header>
  <button>
    <img alt="目次" src="{tocIcon}" />
  </button>
  <h1>タイトル</h1>
  <button>
    <img alt="メニュー" src="{menuIcon}" />
  </button>
</header>

import 文が何をするかというと、パスをサーバルートからのパスに変換します (→参考)。
Vite のデフォルトではアプリのルートがサーバルートなのでこの変換は必要ありません。
アプリのルートを変える場合は vite.config.ts に base というコンフィグを追加します (そしてデプロイ時にその位置にデプロイします)。

vite.config.ts (抜粋)
export default defineConfig({
  base: '/pscv/', // これを追加
  plugins: [svelte()]
})

ボタンのイベントを親コンポーネントに渡す

やり方は 公式のチュートリアル に書いてあるので、その通りにやりました (→ソース全文)。

src/components/Header.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher } from 'svelte'

  const dispatch = createEventDispatcher()
</script>

<header style="background-color: {headerColor};">
  <button class="left-button" on:click="{() => dispatch('openToc')}" >
    <img alt="目次" src="{tocIcon}" />
  </button>
  <h1>タイトル</h1>
  <button class="right-button" on:click="{() => dispatch('openMainMenu')}" >
    <img alt="メニュー" src="{menuIcon}" />
  </button>
</header>

親コンポーネント (src/App.svelte) の方は以下のような感じです (→ソース全文)。
目次用の Toc コンポーネントとメニュー用の MainMenu コンポーネントはまだありませんが、プロパティ xxIsOpentrue/false で表示/非表示を切り替える仕組みを作っておきます。

src/App.svelte (抜粋)
<script lang="ts">
  import Header from "./components/Header.svelte"
  import Toc from "./components/Toc.svelte"
  import MainMenu from "./components/MainMenu.svelte"

  let tocIsOpen = false
  let menuIsOpen = false
</script>

<Header
  on:openToc="{() => { tocIsOpen = true }}"
  on:openMainMenu="{() => { menuIsOpen = true }}"
/>
{#if tocIsOpen}
  <Toc />
{/if}
{#if menuIsOpen}
  <MainMenu />
{/if}

main 要素

main 要素は台本の内容を表示する場所です。
元の台本ビューアでは mainHeader と同じように position: fixed; にしていたのですが (下図の (1))、今回は position: absolute; にしました (下図の (2))。

01_main_layout.png

何が違うかというと、(2) にするとスマホのブラウザでスクロールした時に、アドレスバー等の UI が隠れて画面を広く使えるようになります。
PWA としてインストールして使う場合には関係ありません。

とりあえず適当な内容を入れて Header より先に (Header より背面になるように) 書きます。

src/App.svelte (抜粋)
<main>
  <適当な内容 />
</main>
<Header
  .....

スタイルは以下のようにしました (同じ App.svelte の下の方にあります)。

src/App.svelte (抜粋)
<style>
  main {
    position: absolute;
    top: 48px;
    left: 0;
    right: 0;
    padding: 8px 12px 12px 18px;
  }
</style>

適当な内容を表示するコンポーネント

開発中に適当な内容を表示するためのコンポーネントも作ってみました。
src/components/LoremIpsum.svelte にあるので内容はそちらをご覧ください。
たとえば以下のようにすると、main の中に適当な div を4個表示します。

src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  import LoremIpsum from './components/LoremIpsum.svelte'
</script>

<main>
  <LoremIpsum blockCount="{4}" />
</main>

01_lorem_ipsum.png

ドロワー

ヘッダの左右のボタンを押して表示する TocMainMenu はどちらもドロワーとして作りました。
作りは同じですので Toc コンポーネントだけ見ていきます。

Toc コンポーネントはドロワー本体のほかにオーバーレイも表示します。
これもコンポーネントとして作りました。

01_overlay.png

オーバーレイ

Overlay コンポーネントは、モーダルな UI を表示する時に使えるような、単に全画面を覆うだけの div 要素にしました。
click イベントを親にフォワードするよう on: ディレクティブをつけてあります。

src/components/Overlay.svelte
<script lang="ts">
  export let color = 'black'
  export let opacity = 0.25
</script>

<div
  style:background-color="{color}"
  style:opacity
  on:click
></div>

<style>
  div {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
</style>

ドロワーを閉じる仕組み

オーバーレイをクリックするか「閉じる」ボタンをクリックすることで、close イベントを親にフォワードするようにしました。
Toc コンポーネントの内容は以下のような感じです。
ドロワー部分のスタイル等は ソース全文 をご覧ください。

src/components/Toc.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher } from 'svelte'
  import Overlay from "./Overlay.svelte"
  import LoremIpsum from './LoremIpsum.svelte'
  import closeIcon from '/ui_icon/close_black_24dp.svg'

  const dispatch = createEventDispatcher()

  function close() {
    dispatch('close')
  }
</script>

<div class="overlay overlay-gone" bind:this="{overlay}" >
  <Overlay on:click="{close}" />
</div>
<div class="panel panel-gone" bind:this="{panel}" >
  <h1>目次</h1>
  <button class="close-button">
    <img alt="閉じる" src="{closeIcon}" on:click="{close}" />
  </button>
  <適当な内容 />
</div>

親コンポーネント (src/App.svelte) の方は以下のように close イベントを処理するようにしました (→ソース全文)。

src/App.svelte (抜粋)
{#if tocIsOpen}
  <Toc on:close="{() => { tocIsOpen = false }}" />
{/if}
{#if menuIsOpen}
  <MainMenu on:close="{() => { menuIsOpen = false }}" />
{/if}

これで一応ドロワーが出たり消えたりするのですが、アニメーションをつけたいところです。

ドロワーのアニメーション

Svelte の transition でドロワーとオーバーレイを同時にアニメーションする方法が分からなかったので、スクリプトで制御するようにしました。
前節のソースコードに余計な classbind があったのはそのためです。

2022/10/11 追記
第3回「About を開くまでの部分」で、ドロワーのアニメーションの実装をもっとすっきりした書き方に改善しました。

ドロワー本体は panel というクラスです。これと、「閉じる時にどう変化するか」を表す panel-gone というクラスを用意しました。

src/components/Toc.svelte (抜粋)
<style>
  .panel {
    position: fixed;
    top: 0;
    left: 0;
    min-width: 180px;
    max-width: 100%;
    max-height: 100%;
    background: white;
    overflow: auto;
    transition: transform 0.2s, opacity 0.2s;
  }
  .panel-gone {
    transform: translateX(-180px);
    opacity: 0;
  }
  /* 中略 */
</style>

上記のスタイルにすることで、閉じる時 (panel-gone クラスを適用した時) は 180px だけ左に移動しつつ透明になり、開く時 (panel-gone クラスを取消した時) はその逆の動きをするようになります。

オーバーレイは、移動はせずに透明度だけ変えれば良いです。

src/components/Toc.svelte (抜粋)
<style>
  /* 中略 */
  .overlay {
    transition: opacity 0.2s;
  }
  .overlay-gone {
    opacity: 0;
  }
  /* 中略 */
</style>

アニメーションのタイミング

次に、これらのクラスを適用する (または取消す) タイミングについてです。

まず、コンポーネントがマウントされた時に「閉じた状態から開いた状態へ」アニメーションして欲しいので、panel-goneoverlay-gone を適用した状態から始めます。

⇒ 前節「ドロワーを閉じる仕組み」のソースコード参照

そして開くアニメーションのために onMount() で「閉じた状態のクラス」を取消す処理を設定します。
この時レンダリングを待つ必要があるらしいので、setTimeOut() で遅延させるようにしました (Svelte の tick() では上手くいきませんでした)。

src/components/Toc.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher, onMount } from 'svelte'
  /* 中略 */
  let panel
  let overlay
  let disabled = false

  onMount(async () => {
    setTimeout(() => {
      overlay.classList.remove('overlay-gone')
      panel.classList.remove('panel-gone')
    }, 0)
  })
</script>

disabled プロパティは閉じる時に使います。

閉じる時はアニメーションの完了を待ってから close イベントを親にフォワードするように、先ほどの関数を書き換えます。
アニメーション中に再びイベントを処理しないようにフラグ (disabled プロパティ) を立てるようにしました。

src/components/Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  function close() {
    if (disabled) { return }
    disabled = true

    overlay.classList.add('overlay-gone')
    panel.classList.add('panel-gone')

    setTimeout(() => {
      dispatch('close')
    }, 200)
  }
</script>

MainMenu コンポーネントも、やっていることは同じです (→ソース全文)。

スクロールを固定する

ドロワー等のモーダルな UI を開いている時は、台本をスクロールできないようにしたいと思いました。
オーバーレイに色を入れているのも、それを想定してのことです。

ここでのスクロールを固定する方法は問題があったので、第7回 で改善しています。

スクロールを固定する機能は App コンポーネントの中で完結しますが、HTML ルート要素のスタイルは src/app.css の方で変えておくのが良いです。

src/app.css (抜粋)
:root {
  width: 100%;
  /* 中略 */
}

これは、スクロールを固定するのにルート要素を position: fixed; にした時、ルート要素の幅が変わらないようにするためです。

position: fixed; にするとスクロール位置もリセットされるので、その分だけ中身を動かして移動量を相殺しないと見た目が不自然になります。

01_scroll_lock.png

以下のようなコードを書くことで、モーダルになったら HTML ルート要素のスクロールを固定して中身の位置を調整し、モーダルを解除したら元の状態を復元するようにしました。

src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  let main
  $: isModal = tocIsOpen || menuIsOpen
  $: if (main) {
    if (isModal) {
      const scrollTop = document.documentElement.scrollTop
      document.documentElement.style.position = 'fixed'
      main.style.top = `${48 - scrollTop}px`
    } else {
      const scrollTop = 48 - main.offsetTop
      main.style.top = '48px'
      document.documentElement.style.position = 'static'
      document.documentElement.scrollTop = scrollTop
    }
  }
</script>

<main bind:this="{main}" >
  <LoremIpsum blockCount="{4}" />
</main>

isModal はモーダル状態を表し、リアクティブ宣言 しているので TocMainMenu が開いたり閉じたりするたびに更新されます。
その下の if 文もリアクティブ宣言されているので、isModal の値が変わるたびに実行されます。
モーダルになった時は上図の右側の状態になるようにして、モーダルが解除されたら左側の状態に戻るようにすれば出来上がりです。

ただし、今後は縦書き表示に対応するので、スクロール周りはいろいろと変更が入ると思います。

参考リンク

シリーズ記事一覧

2
1
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
2
1