Introduction
例えばTodoリストアプリなどを作ろうとすると、リストのアイテムを順番通りに整列させて、ドラッグ&ドロップで並び替えて、その順番を保存したい場面があると思います。
jQuery UIのsortableを利用している記事が多かったのですが、モバイルでも利用する前提だと他のjsも使わなくてはいけなかったりと面倒だったのですが、Sortable.jsというライブラリを利用すればモバイルにも対応してくれていたので「Sortable.js」+「acts_as_list」を使ってドラッグ&ドロップで並び替えできるリストを実装してみました。
0. Sample Model

とても単純なrelationshipを持ったParentとChildモデルがあるとします。
Parentは0個以上のChildを持ち(has_many)、Childは1つのParentを持ちます(belongs_to)。
1. Childモデルに順番の概念を与える
まずは、モデルに順番の概念を与えるためにacts_as_listを使っていきます。
GitHub - swanandp/acts_as_list
gem 'acts_as_list'
$ bundle install
acts_as_listがインストールできたら、Childにposition:integerのカラムを追加してください。
$ rails g migration AddPositionToTodoItem position:integer
$ rake db:migrate
Childの順番はParentごとに管理をしたいので、モデルの定義を次のように書き換えます。
class Parent < ApplicationRecord
has_many :children, -> { order(position: :asc) }
end
class Child
belongs_to :parent
acts_as_list scope: :parent
これでChildモデルに順番の概念を与えることができました。
Childがcreateされるごとにpositionカラムにシーケンシャルな数字が自動で追加してくれます。
また、用意されているメソッドを使うことで順番を変更することができます。
@child.insert_at(2) #position=2に移動
@child.move_lower #position++
@child.move_higher #position--
@child.move_to_top #position=1
@child.move_to_bottom #position=last
2. ドラッグ&ドロップで並び替え可能なリストを作る
これにはSortable.jsを使います。
GitHub - SortableJS/Sortable
GitHubからSortable.min.jsをダウンロードしてapp/assets/javascripts/に配置します。
Sortable.jsではgetElementByIdでHTMLObjectを取得することでリストを並び替え可能にします。
例として、/parents/:idでParentに紐づくChildを一覧で表示しているページを使います。
def show
@parent = Parent.find(params[:id])
@children = @parent.children
end
<ul id="sortable_list">
<% @children.each do |child| %>
<li><%= child %></li>
<% end %>
</ul>
$ ->
el = document.getElementById("sortable_list")
if el != null
sortable = Sortable.create(el, delay: 200)
これで<li>要素をドラッグ&ドロップで並び替えできるようになります。
Sortable.jsのdelayオプションは200msの長押しをしないと並び替え可能な状態にならないことを定義しています。デフォルトでは0msなのですが、スマホだとページのスクロールができなくなってしまうので適切な値を設定するといいと思います。
3. 並び替えをしたらDBを更新する
Sortable.jsでは並び替えが行われたときにonUpdateが呼び出されます。
これをトリガーにajaxでドラッグ&ドロップされた要素の「ドラッグ前の位置」と「ドラッグ後(ドロップ)の位置」をバックエンドに連携して並び順をDB更新するようにします。
まずは「ドラッグ前後の位置」を受け取れるようにコントローラー側にsortアクションを追加します。
urlはparent/:id/sort、from(ドラッグ前の位置)とto(ドラッグ後の位置)を受け取る前提とします。
def sort
@parent = Parent.find(params[:id])
child = @parent.children[params[:from].to_i)
child.insert_at(params[:to].to_i + 1)
head :ok
end
@parent.children[params[:from].to_i]で対象のモデルを取得しています。
そして、child.insert_at(params[:to].to_i + 1)で対象のpositionを更新しています。
Sortable.jsで取得するリストの位置は0から始まるのに対して、insert_atは1から始まるところに注意して、insert_at向けの要素の順番には+1をしています。
Rails4ではrender nothing: trueなどが有効だったようですがRails5では使えなくなっているのでhead :okでフロント側に200OKを返却。
ルーティングも追加しておきます。
patch 'parent/:id/sort', to: 'parent#sort'
ajax通信をする際にParentのidが必要になるのでhiddenフィールドに隠しておいてajaxの前に取得できるようにしておきます。
<ul id="sortable_list">
<% @children.each do |child| %>
<li><%= child %></li>
<% end %>
</ul>
<%= hidden_field_tag :parent_id, @parent.id %>
あとはajaxを実装すれば完成です。
Sortable.jsではonUpdateイベントでoldIndexとnewIndexを取得できるようになっています。それぞれ「ドラッグ前の位置」と「ドラッグ後の位置」です。これを取得してバックエンドにpatchします。
$ ->
el = document.getElementById("sortable_list")
if el != null
sortable = Sortable.create(el,
delay: 200,
onUpdate: (evt) ->
$.ajax
url: 'parent/' + $("#parent_id").val() + '/sort'
type: 'patch'
data: { from: evt.oldIndex, to: evt.newIndex }
)
$("#parent_id").val()でhiddenフィールドに格納しているParentのidを取得してurlを指定してます。typeはroutes.rbで定義したものに合わせて、dataにはバックエンドに連携する情報を記載してます。
以上で完成!!
Conclusion
割とシンプルにドラッグ&ドロップでの並び替えができたと思います。しかもモバイル対応!
ただし、acts_as_listの仕様上、並び替えの度にけっこうなDB更新がかかっていそう(from〜toの間の全てのレコードを更新しているっぽい)。リストが大きくなることが想定されている場合は順番をつける方式は検討した方が良いかも。ranked_modelの方がいいのかな?
