JavaScript
jquery.ui
drag&drop
sortable

【jQueryUIを使わずにドラッグ&ドロップを実装したい】Javascriptライブラリ「Sortable」を使ってみた

More than 1 year has passed since last update.

HTML5の主要機能でもあるドラッグ&ドロップですが、
普段、UI周りの実装をすることが少ないエンジニアの方には馴染みが薄い処理の一つではないでしょうか。
私もAPIの存在は知っていたものの、最近まで実装したことがなかったのですが、ついこの間WEBサービスのリニューアル案件で使う機会があったのでその時にインプットしたSortableというライブラリを紹介したいと思います。

jQueryUIを使わずに実装できるライブラリ「Sortable」

自分は特に反jQuery側の人間ではないのですが、やっぱり依存するライブラリが少ないというのは組んでいて安心しますよね!そんな時に見つけたのがこのSortableです。
Sortable / デモページ

主要ブラウザ(IE9含む)はもちろん、タッチデバイスにも対応している優れものです。
そんな万能なライブラリですが、日本語のドキュメントが少ないというのが導入における唯一のリスクだったのですが、
幸いなことに案件のスパンも長かったため、検証も兼ねて初めて自分でドキュメントの日本語化をしてみました。
(ところどころ怪しい部分がありますが、優しくコメントをいただけると助かります)

基本的な使い方

capani.gif

script
let sortable = Sortable.create(hoge, {
    group: "hoge",
    animation: 100
});

基本的な使い方としては、
Sortable.create()の第一引数にドラッグ&ドロップを適用させるリストのIDを指定し、第二引数に適用させるオプションを指定します。

オプション

オプション名 デフォルト値 説明
group "name" ドラッグ&ドロップするグループ名(名前はなんでもいい)
sort true リスト内でのソート可否
delay 0 並び替えを開始するタイミング
disabled false sortオプションがtrueに設定されている際に、ソートを無効化するか
store null リストの順番の保存と取得
animation 150 ソート時のアニメーションスピード
handle ".my-handle" ドラッグできる範囲の指定(そのままだとテキスト選択ができないため)
filter ".ignore-elements" ドラッグさせたくないセレクタを指定
preventOnFilter true filterがトリガされたときに「event.preventDefault()」を呼び出すかどうか
draggable ".item" リスト内のどの部分をドラッグ可能にするかを指定
ghostClass "sortable-ghost" ドロップ中のクラス名の指定
chosenClass "sortable-chosen" 選択中のクラス名の指定
dragClass "sortable-drag" ドラッグ中のクラス名の指定
dataIdAttr 'data-id' 不明
forceFallback false trueに設定すると古いブラウザのフォールバックが適用される(開発用)
fallbackClass "sortable-fallback" forceFallbackを使用したときに複製されたDOM要素のクラス名
fallbackOnBody false forceFallbackを使用したときにcloneされたDOM要素をドキュメントの本文に追加するか
fallbackTolerance 0 forceFallbackを使用したときにドラッグすることができる範囲をピクセルで指定
scroll true ドラッグしながらスクロールできるようにするか
scrollFn function(offsetX, offsetY, originalEvent) { ... } 専用のスクロール機能を持​​つカスタムスクロールバーがある場合に使用
scrollSensitivity 30 スクロールが開始する位置をピクセルで指定
scrollSpeed 10 スクロールするスピードをピクセルで指定

イベント

イベント名 説明
setData データを格納・保存した時
onChoose 要素を選択している時
onStart ドラッグが開始された時
onEnd ドラッグが終了した時
onAdd リスト内に新たに要素が追加された時
onUpdate ソートされリスト内が新たに更新された時
onSort リストの変更(追加/更新/削除)がされた時
onRemove 要素がリストから別のリストに移動し削除された時
onFilter filterで指定された要素をドラッグしようとした時
onMove リスト内またはリスト間で項目を移動した時
onClone 要素が複製された時

「Sortable」を使用した実装例

基本的な使い方とオプションなどが理解できたところで、様々な実装例を見ていきたいと思います。

ドラッグできるポインタの範囲を指定する

capani2.gif

script
let sortable = Sortable.create(hoge, {
  group: "hoge",
  handle: ".my-handle",
  animation: 100
});

特に何も指定せずにそのままの状態だと、ドラッグに反応してリスト内の項目上でテキストを選択することができません。
そこで、handleオプションを使ってドラッグできるポインタの範囲を指定してあげることで、項目の用途はそのままにドラッグ&ドロップさせることができます。

複数のリスト間でドラッグ&ドロップ

capani3.gif

script
Sortable.create(hoge, {
  group: {
    name: "shares",
  },
  animation: 100
});

Sortable.create(fuga, {
  group: {
    name: "shares",
  },
  animation: 100
});

// または

Sortable.create(hoge, {
  group: {
    name: "hoge",
    put: "fuga",
  },
  animation: 100
});

Sortable.create(fuga, {
  group: {
    name: "fuga",
    put: "hoge",
  },
  animation: 100
});

複数あるリスト間でお互いの項目をドラッグ&ドロップさせるには、二通り実装する方法があります。
一つは、groupnameの値を同じ値に設定してあげます。
もう一つは、groupputで引き受けるリストのnameをそれぞれ指定します。

使い分けは時と場合によりますが、
この場合は前者の方が移動するリストが増えた際に楽なため、前者の方をおすすめします。

ドラッグさせない項目を指定する

capani4.gif

script
Sortable.create(hoge, {
  group: {
    name: "shares",
  },
  filter: ".ignore-elements",
  animation: 100
});

Sortable.create(fuga, {
  group: {
    name: "shares",
  },
  filter: ".ignore-elements",
  animation: 100
});

項目の中には、ドラッグさせたくないものが発生する場合もあります。
その場合には、オプションのfilterを用いドラッグさせたくない要素を指定します。

リストの削除と復活

capani5.gif

script
Sortable.create(hoge, {
  group: {
    name: "hoge",
  },
  animation: 100
});

var editableList = Sortable.create(fuga, {
  group: {
    name: "fuga",
    put: ["hoge"],
  },
  filter: ".js-remove",
  onAdd: function (evt) {
    var el = editableList.closest(evt.item);
    el.innerHTML += '<i class="js-remove">✖</i>';
  },
  onFilter: function (evt) {
    var el = editableList.closest(evt.item);
    var hoge = document.getElementById("hoge");
    var removeBtn = document.querySelector("#fuga li .js-remove");
    el.removeChild(removeBtn);
    el && el.parentNode.removeChild(el);
    hoge.appendChild(el);
  },
  animation: 100,
});

Wordpressのウィジェット管理画面のような、項目の追加と削除・復活を実装します。

onAdd: function (evt) {
  var el = editableList.closest(evt.item);
  el.innerHTML += '<i class="js-remove">✖</i>';
},

onAddイベントを使い、hogeリストからfugaリストに項目が追加されたことを検知し、
removeボタンを追加します。

onFilter: function (evt) {
  var el = editableList.closest(evt.item);
  var hoge = document.getElementById("hoge");
  var removeBtn = document.querySelector("#fuga li .js-remove");
  el.removeChild(removeBtn);
  el && el.parentNode.removeChild(el);
  hoge.appendChild(el);
},

onFilterイベントを使い、removeボタンがクリックされたことを検知し、
ボタンの削除とその削除されたボタンを元のリストに復活させます。

リストの削除と復活(別パターン)

capani6.gif

script
Sortable.create(hoge, {
  group: {
    name: "hoge",
    pull: function () {
      return "clone";
    }
  },
  sort: false,
  animation: 100
});

var editableList = Sortable.create(fuga, {
  group: {
    name: "fuga",
    put: ["hoge"],
  },
  filter: ".js-remove",
  onAdd: function (evt) {
    var el = editableList.closest(evt.item);
    el.innerHTML += '<i class="js-remove">✖</i>';
  },
  onFilter: function (evt) {
    var el = editableList.closest(evt.item);
    el && el.parentNode.removeChild(el);
  },
  animation: 100,
});

先ほどの実装例とは多少異なりますが、
追加する元のリストの項目は削除せずに、移動先にドラッグ&ドロップした項目をクローンしてあげるだけで済む場合もあるかもしれません。
その場合には、grouppull'clone'を返してあげるようにします。

そうすることで、元のリスト内の項目は削除されずに自分のコピーを移動先に渡してあげることができます。

項目の順番を保存して取得

capani7.gif

html
<ul id="hoge">
  <li data-id="1">element 1</li>
  <li data-id="2">element 2</li>
  <li data-id="3">element 3</li>
</ul>
script
Sortable.create(hoge, {
  group: "save",
  store: {
    get: function (sortable) {
      var order = localStorage.getItem(sortable.options.group.name);
      return order ? order.split('|') : [];
    },
    set: function (sortable) {
      var order = sortable.toArray();
      localStorage.setItem(sortable.options.group.name, order.join('|'));
    }
  }
});

順番を保存するには、
HTMLのdata-idで設定している順番を、storesetでlocalStorageに保存します。

反対に、保存した順番を取得するには、
storegetを用い取得し、getしたものをreturnさせてあげることで、リロードする前の項目順で表示されます。

まとめ

少ない記述で、なおかつ、拡張性も高いため、
ある程度の規模と機能には対応できるライブラリかなという印象です。
また、肝心のパフォーマンスもスムーズに動いてくれているため(SP/TBも)、このライブラリを選んで正解でした。

今後クライアントのフィードバッグを受けて、より深いところまで動かしてみようと思います。
細々出てきた問題に関しては、随時更新していきたいと思います。

日本語化してみて

今回初めて日本語ドキュメントがほとんど無いライブラリを検証してみましたが、
改めてやってみると、検証しながら意味を調べたりすることで、
そのオプションの持っている意味などが、普段誰かがドキュメントに起こしてくれたものを見て理解するより、数倍理解を深めることができたなと思いました。

今回のように時間が取れるかどうかはわかりませんが、次回も新しいものにどんどん触れていきたいと思います!
では、また次回〜