PWA で作った台本ビューア を Svelte で作り直すことにしたので、やったことをメモしていきます。
<第9回> 台本一覧を D&D で並べ替え
今回のテーマは、「台本データ」画面のリストをドラッグ&ドロップで並べ替えられるようにすることです。
標準のドラッグ&ドロップ API や svelte-dnd-action を使うことも検討したのですが、上手く実装できないことがあったので独自に DndList
というコンポーネントを作りました。
今回の概要
- 「台本データ」画面の並べ替えの UI でやりたかったこと。
- 独自実装した
DndList
コンポーネントの説明。
今回使うソースコードは GitHub のこちらのコミット にあります。
並べ替えの UI でやりたかったこと
ドラッグハンドル
セル内のドラッグハンドルを触ることでドラッグを開始するようにします。
これ自体は svelte-dnd-action でも実現可能です (→サンプルコード)。
ドラッグ中の表示
ドラッグ中の表示は以下のようにします。
半透明でポインタに合わせて移動するのが「ゴースト」です。
グレーで移動先をプレビューしているセルが「シャドウ」です。
ゴーストの移動をリスト内に制限する
ドラッグ中はポインタを如何に動かしても、ゴーストがリスト内でしか動かないようにします。
これは、うっかりリストから少し外れてドロップしても、その時点での移動をキャンセルしたくないからです。
特にスクロールを伴う移動では、うっかりリスト外にドロップすることはストレスになると思います。
セルの区切り線を正しく表示する
セルの区切り線は、セルに 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 の変更が反映されるようにします。
<script lang="ts">
/* 中略 */
// リストに DB の内容が自動的に反映されるようにする
let scIndexes = liveQuery(
() => db.scriptIndex.orderBy('sortKey').toArray()
)
$: items = $scIndexes
/* 中略 */
</script>
セル用のコンポーネントを用意する
まず、セル用のコンポーネントをどのように呼び出すか見てください。
以下のコードの DataCell
がセル用のコンポーネントです。
{#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
のプロパティ になっています。
<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
コンポーネントにも item
と cellId
というプロパティを持たせています。
DataCell
コンポーネントのソース全文は こちら をご覧ください。
<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 テーブルを更新する必要があるため、その実装をしています。
<script lang="ts">
/* 中略 */
function listSorted() {
const ids = items.map(item => item.id)
db.sortByIds(ids)
}
</script>
sorted
が発火した時点で items
は並べ替えられているので、items
から id
だけを取り出して、その配列を db.sortByIds
関数 に渡しています。
制限
- このコンポーネントは、リストの並びが「縦」である想定となっています。
- ドラッグをキャンセルする処理はありません。
まとめ
今回 作成した DndList
コンポーネントは、「台本データ」画面以外でも使えるようにはしましたが、あくまで私が使いたい機能の実装であって、他のプロジェクトで使えるほどには汎用的ではありません。
ただ、一部の変数や関数を export
することでさらに汎用性を高めることも可能かとは思います。
たとえばゴーストやシャドウのスタイル、セルの区切り線の扱い等です。
前の章の「制限」が許容できるのであれば、他のプロジェクトで使えるように改造してみるのも良いかと思っています。