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
の方がいいのかな?