1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-05-12

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

<第9回> 台本一覧を D&D で並べ替え

今回のテーマは、「台本データ」画面のリストをドラッグ&ドロップで並べ替えられるようにすることです。
標準のドラッグ&ドロップ APIsvelte-dnd-action を使うことも検討したのですが、上手く実装できないことがあったので独自に DndList というコンポーネントを作りました。

今回の概要

  • 「台本データ」画面の並べ替えの UI でやりたかったこと。
  • 独自実装した DndList コンポーネントの説明。

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

並べ替えの UI でやりたかったこと

ドラッグハンドル

セル内のドラッグハンドルを触ることでドラッグを開始するようにします。
これ自体は svelte-dnd-action でも実現可能です (→サンプルコード)。
09_drag_handle.png

ドラッグ中の表示

ドラッグ中の表示は以下のようにします。
半透明でポインタに合わせて移動するのが「ゴースト」です。
グレーで移動先をプレビューしているセルが「シャドウ」です。
09_ghost_and_shadow.png

ゴーストの移動をリスト内に制限する

ドラッグ中はポインタを如何に動かしても、ゴーストがリスト内でしか動かないようにします。
これは、うっかりリストから少し外れてドロップしても、その時点での移動をキャンセルしたくないからです。
特にスクロールを伴う移動では、うっかりリスト外にドロップすることはストレスになると思います。

セルの区切り線を正しく表示する

セルの区切り線は、セルに border-bottom を設定して表示しています。
ただし一番下のセルについては、リストの下端よりも上に配置される場合以外は区切り線を表示しません。

このルールをドラッグ中も保つようにして、ドラッグ中も区切り線がない、または余計に表示されるといったことがないようにします。

標準の API 等でできなかったこと

やりたかったことのうち、以下のことが標準のドラッグ&ドロップ API や svelte-dnd-action ではできませんでした。
※やり方を調べるのが大変そうで諦めたことも、「できなかったこと」としています。

  • ゴーストの移動をリスト内に制限する
  • セルの区切り線を正しく表示する
  • ドラッグ中はリスト以外はスクロールしない

3つめの「ドラッグ中はリスト以外はスクロールしない」は、「台本データ」画面では考慮する必要はないのですが、コンポーネントとしては考慮するようにしました。
考慮しないとどうなるかというと、たとえば svelte-dnd-action では、スマホのブラウザでタッチ操作でドラッグした時にドラッグとページスクロールが同時に起こります。

DndList コンポーネント

独自実装した DndList コンポーネントについて説明します。
ソースコードは こちら をご覧ください。

機能の概要

  • コンポーネントプロパティとして items を受け取ります。
    • items はオブジェクトのリストで、各要素に id プロパティが必須です。
  • items はドラッグによって並び順が変わり、親で bind した変数にも反映されます。
  • ドラッグ中は、ドロップ後の並び順のプレビューがリアルタイムに表示されます。
  • ドラッグ中にゴーストがリストの表示範囲の上端または下端に達した時、可能ならスクロールします。
  • ドラッグ開始時に disableScroll イベントを発火します。
    • イベントを受け取った親は、必要ならスクロールロックの処理をします。
  • ドラッグ終了時に enableScroll イベントを発火します。
    • イベントを受け取った親は、必要ならスクロールロックを解除します。
  • ドラッグ終了時に sorted イベントを発火します。
    • イベントを受け取った親は、items の並び順に応じて必要な処理をします。

使い方

「台本データ」画面で DndList コンポーネントをどのように使っているか、簡単に説明します。
「台本データ」画面のソースコード全文は こちら をご覧ください。

items を用意する

前回は Dexie.js の liveQuery の結果を そのまま表示 していました。
liveQuery は DB の内容を映す窓のようなものなので、今回のように並べ替えの対象とすることはできません。
そこで、ただの配列として items を用意して、これを liveQuery に対してリアクティブにすることで DB の変更が反映されるようにします。

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

  // リストに DB の内容が自動的に反映されるようにする
  let scIndexes = liveQuery(
    () => db.scriptIndex.orderBy('sortKey').toArray()
  )
  $: items = $scIndexes

  /* 中略 */
</script>

セル用のコンポーネントを用意する

まず、セル用のコンポーネントをどのように呼び出すか見てください。
以下のコードの DataCell がセル用のコンポーネントです。

DataList.svelte (抜粋)
    {#if items}
      <DndList
        bind:items="{items}"
        let:item="{scIndexItem}"
        let:cellId
        on:sorted="{listSorted}"
      >
        <DataCell
          item="{scIndexItem}"
          cellId="{cellId}"
          on:showPSc="{e => showPSc(e.detail.scriptId)}"
          on:showInfo="{e => showInfo(e.detail.id)}"
        />
      </DndList>
    {/if}

DataCell は実際には複数あって、DndList 内のループの中の各 slot に対して生成されます。
セルごとに必要な情報は slot のプロパティ になっています。

DndList.svelte (抜粋)
<div class="scroll-box" bind:this="{scrollBox}">
  <div bind:this="{cellsRow}">
    {#each items as item (item.id)}
      <slot item="{item}" cellId="cell{item.id}"></slot>
    {/each}
  </div>
</div>

ですので、それを受け取るのに DataCell コンポーネントにも itemcellId というプロパティを持たせています。
DataCell コンポーネントのソース全文は こちら をご覧ください。

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

  import type { ScriptIndex } from '../lib/db'
  import type { DndCellItem } from './UI/DndList.svelte'

  /* 中略 */

  // コンポーネントプロパティ
  export let item: DndCellItem
  export let cellId: string

  let scIndex = item as ScriptIndex
</script>

<div
  id="{cellId}"
  class="cell bottom-line"
>
  <!-- 中略 -->
  <div class="label">{scIndex.name}</div>
  <!-- 中略 -->
  <div id="dragHandle" class="icon drag-handle">
    <img src="{dragHandle}" alt="drag" />
  </div>
</div>

<style>
  .cell:last-child {
    border-bottom-width: 0;
  }
  .bottom-line:last-child {
    border-bottom-width: 1px;
  }
</style>

item プロパティの型は、親コンポーネントでエラーが出ないように DndList.svelte で定義されている DndCellItem にしますが、実際の中身は ScriptIndex を期待するので、キャストして別の変数 (scIndex) に入れています。
DndCellItem には id プロパティが必要ですが、ScriptIndex にも id があるので問題ありません。

cellId はこのセルの (HTML 要素としての) id 属性に設定しておく必要があります。

セル内でドラッグハンドルとして使いたい要素には dragHandle という id を設定します。
イベントリスナーは DndList コンポーネントが設定してくれます。

cell クラスと bottom-line クラスを定義することで、bottom-line クラスだった場合だけ最後のセルに区切り線を表示するようにします。
bottom-line クラスのオン・オフは DndList コンポーネントによって行われます。

イベントハンドラの実装

DndList から親コンポーネントが受け取るイベントは、disableScroll, enableScroll, sorted の3つです。

disableScroll イベント

ドラッグ開始時に発火します。
スマホのタッチ操作でドラッグとスクロールが同時に起きないように、このイベントを受け取った親はスクロールロックの処理をします。
台本ビューアでは モーダルな UI を開いている時はページのスクロールをロックしている ので、このイベントを処理する必要はありません。

enableScroll イベント

ドラッグ終了時に発火します。
disableScroll と対になっていて、親がスクロールロックを解除するためのものです。

sorted イベント

ドラッグ終了時に発火します。
ドラッグが終了した時点で items の並べ替えは bind によって反映されますが、それ以外の処理をしたい場合にハンドラを実装します。

台本ビューアでは items の元になっている DB の scriptIndex テーブルを更新する必要があるため、その実装をしています。

DataList.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  function listSorted() {
    const ids = items.map(item => item.id)
    db.sortByIds(ids)
  }
</script>

sorted が発火した時点で items は並べ替えられているので、items から id だけを取り出して、その配列を db.sortByIds 関数 に渡しています。

制限

  • このコンポーネントは、リストの並びが「縦」である想定となっています。
  • ドラッグをキャンセルする処理はありません。

まとめ

今回 作成した DndList コンポーネントは、「台本データ」画面以外でも使えるようにはしましたが、あくまで私が使いたい機能の実装であって、他のプロジェクトで使えるほどには汎用的ではありません。

ただ、一部の変数や関数を export することでさらに汎用性を高めることも可能かとは思います。
たとえばゴーストやシャドウのスタイル、セルの区切り線の扱い等です。

前の章の「制限」が許容できるのであれば、他のプロジェクトで使えるように改造してみるのも良いかと思っています。

参考リンク

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?