Elmで作るsortable list

リチャード 伊真岡 blog

Elmで作るsortable list

リチャード 伊真岡です。Elmでsortable listを作ったので、その作り方を公開します。 どこかの誰かが既にsortable listを作っているだろうと思ったのですが、私が探した限りだと見つからなかったので、同じものを作りたい人の参考になれば幸いです。

Elmのsortable listを自分で作りたいと思った背景

ここでいうsortable listとはこれです。様々なWebアプリケーションのUIで登場するパターンですね。

sortable list

JavaScriptのUIでsortable listを実現する方法はとしては、jQueryで実現する方法が一番有名だと思います。他にもReactでの実装や、プレーンなJavaScriptで実現する方法もあります。

Elmでも同様に既にsortable listの実装が公開されているかと思ったのですが、私が探した限りでは見つけられませんでした。 一番近いものはDiscourse Elm - Elm equivalent of jQuery sortable?だったのですが、CSSのtransform: translateによってリスト内の要素を移動しています。私はtransformによって見た目だけ移動するのではなく、The Elm ArchitectureでいうモデルにあたるArrayを実際にソートしたかったので、自分で作ることにしました。出来上がったコードはこちらです。

GitHub - richardimaoka/elm-sortable-list

もしコードを見るだけでわかってしまった方は、この先を読む必要はありません。コードを見ただけではわからないので解説がほしい、その上で自分でもElmでsortable listを書いてみたいという方は以下を読み進めてください。

私のやり方はあくまで参考として、自分で機能を拡張しても良いでしょうし、学習用の題材としてとらえても、このsortable listはHTMLのdrag and drop APIやElmのイベントハンドラのを学べる面白い題材だと思います。

sortable listを実現する仕組み

このsortable listを実現するためにHTML drag and drop APIの概要を理解することが重要です。 詳細はこちらの記事、HTML5 Rocks - ドラッグ&ドロップAPIに譲りますが、HTMLの要素にdraggable=”true”というHTMLアトリビュートをつけると、その要素がドラッグ可能になります。

ドラッグでゴースト画像ができる

ドラッグした要素を最終的にドロップするまで一連の動作の中で、以下のイベントが発生するので、それらに対し正しくイベントハンドラを用意してsortable listを実現します。 必ずしも全てのイベントをハンドルする必要はなく、この記事で紹介する実装ではdragstart, dragend, dragenterのみハンドルします。

  • Mozilla - HTML ドラッグ&ドロップ API
    • drag: …ドラッグ項目 (要素や選択テキスト) がドラッグされた場合
    • dragend: …ドラッグ操作の終了
    • dragenter: …ドラッグ項目が有効なドロップ対象に入った場合
    • dragexit: …要素がドラッグ操作の選択対象でなくなった場合
    • dragleave: …ドラッグ項目が有効なドロップ対象を離れた場合
    • dragover: …ドラッグ項目が有効なドロップ対象にドラッグされた場合、数百ミリ秒ごとに
    • dragstart: …ユーザーが項目をドラッグ開始した場合
    • drop: …項目が有効なドロップ対象にドロップされた場合

では実際にdragstart, dragenter, dragendが発生する様子をお見せしましょう。 それぞれのイベントがElmの型として定義したDragStart, DragEnter, DragEndに変換され、Elmデバッガ上で表示されています。

ドラッグイベントの例

今回の実装ではsortable listの裏側にあるデータ構造、つまりThe Elm ArchitectureでいうモデルにはArrayを使いました。

Array.fromList
    [ { dragged = False, text = "0" }
    , { dragged = False, text = "1" }
    , { dragged = False, text = "2" }
    , { dragged = False, text = "3" }
    , { dragged = False, text = "4" }
    , { dragged = False, text = "5" }
    , { dragged = False, text = "6" }
    , { dragged = False, text = "7" }
    , { dragged = False, text = "8" }
    , { dragged = False, text = "9" }
    , { dragged = False, text = "10" }
    ]

モデルであるArrayをソートする部分は、UIのことを忘れて純粋に要素を並べ替えるアルゴリズムの問題です。そこが正しく実装できればあとはElmがよしなにUI上のリストの要素を並べ替えてくれます。

利用できるソートのアルゴリズムはいくつか選択肢があると思いますが、私は以下のように実装しました。まず、「ドラッグ元」となる要素と「ドロップ先」となる要素を考え、それら2つの要素に挟まれる部分がソート対象とします。

ソートアルゴリズム1

次に「上から下にドラッグ&ドロップ」する場合と「下から上にドラッグ&ドロップ」する場合に分けます。この場合分けによってソート対象の要素が下向き、上向きどちらに移動するかが変わります。

ソートアルゴリズム2

ソートのアルゴリズムができたら、後はドラッグ&ドロップのイベントハンドラを使って、ソートのアルゴリズムをトリガすれば完成です! 以上の説明で十分な方は先程のEllieのコードに戻って再び流れを追っていただき、より詳しい説明が必要な方は以下で紹介する実装手順を見てください。

実装の手順

ここから先は、最終形まで最短距離で進むのではなく、少しずつ動く部分を作って組み合わせていきます。最初にArrayとそれを表示するElmのビューを用意しましょう。

基本構造の実装

type alias Model = Array String

initialModel: Model
initialModel =
  Array.fromList
      [ "0"
      , "1"
      , "2" 
      , "3"
      , "4"
      , "5" 
      , "6" 
      , "7" 
      , "8"
      , "9" 
      , "10" 
      ]

view : Model -> Html Msg
view model =
    div []
        (Array.toList (Array.map elementView model))


elementView : String -> Html Msg
elementView elem =
    div
        [ style "background-color" "coral"
        , style "margin" "15px"
        , style "max-width" "100px"
        , style "min-height" "30px"
        ]
        [ text elem ]

draggable “true”を指定すると、要素がドラッグ可能になります。ドラッグによってゴースト要素が見える様になったはずです。

elementView elem =
    div
        [ ...
        , draggable "true" -- { これを追加 }
        ]
        [ text elem ]

ここまでのコードをEllieで見ると、こうなります。

ドラッグして、そのままもとに戻す動作の実装

イベントハンドラを設定していきます。ドラッグされた要素をわかりやすくするため、ドラッグが開始されたらstyle "opacity"を設定します。

opacity

これを実現するためonDragStartイベントハンドラを実装しましょう。Elmのイベントハンドラは書き方になれないと、少し難しいかもしれません。

on “イベント名” (Json.Decodeで包んだメッセージ)

という順番で書くのが基本です。Elmのイベントハンドラの書き方についてもっと詳しく知りたい方は、記事の最後に掲載した参考文献をたどってください。

イベントハンドラにはJson.Decodeのimportも必要なので、こうなります。

-- elm install elm/jsonでJson.Decodeを事前にインストール
import Json.Decode as Decode

onDragStart : Msg -> Attribute Msg
onDragStart msg =
    on "dragstart" ( Decode.succeed msg )

次にonDragStartで送るメッセージ型を定義しましょう。

type Msg = DragStart Element

Element型はArray内の要素を表します。最初のArrayは単純なArray Stringでしたが、ここでArray Elementに書き換えます。 Elementのdraggedは要素がドラッグされている最中はdragged = Trueとなり、ドラッグされていない状態の要素はdragged = Falseです。

type alias Element =
    { dragged : Bool
    , text : String
    }

type alias Model =
    Array Element

initialModel : Model
initialModel =
    Array.fromList
        [ { dragged = False, text = "0" }
        , { dragged = False, text = "1" }
        , { dragged = False, text = "2" }
        , { dragged = False, text = "3" }
        , { dragged = False, text = "4" }
        , { dragged = False, text = "5" }
        , { dragged = False, text = "6" }
        , { dragged = False, text = "7" }
        , { dragged = False, text = "8" }
        , { dragged = False, text = "9" }
        , { dragged = False, text = "10" }
        ]

elementViewにopacityを設定します。

elementView : Element -> Html Msg
elementView elem =
    div
        [
          -- {このstyle "opacity"を追加}
          style "opacity"
            (if elem.dragged then 
                "0.5"

             else
                "1.0"
            )
        , ...
          -- {onDragStartも追加}
        , onDragStart (DragStart elem)
        ]
        -- {ここでelem.textを呼び出し}
        [ text elem.text ]

updateはこうなります。

update : Msg -> Model -> Model
update msg model =
    case msg of
        DragStart draggedElement ->
            -- findは後ほど貼るEllieにて実装を確認してください
            case find (\elem -> elem.text == draggedElement.text) model of
                Nothing ->
                    model

                Just index ->
                    Array.set index { draggedElement | dragged = True } model

これで先程の、ドラッグを開始したら元の位置にある要素が透けて見える動作が実現しました。

opacity

このままでは透けた要素がドラッグ終了後も元に戻らないので、ドラッグ終了後は半透明の要素を元の不透明な見た目に戻す動作を実装します。 まずはonDragEndハンドラを追加し、

elementView : Element -> Html Msg
elementView elem =
    div
        [ ... 
        , onDragEnd DragEnd
          ...
        ]
        [ text elem.text ]

onDragEnd : Msg -> Attribute Msg
onDragEnd msg =
    on "dragend" (Decode.succeed msg)

updateでDragEndメッセージを処理します。

type Msg
    = DragStart Element
    | DragEnd

update msg model =
    case msg of
        ...

        DragEnd ->
            let
                maybeIndex =
                    -- findは後ほど貼るEllieにて実装を確認してください
                    find (\elem -> elem.dragged) model

                maybeElement =
                    maybeIndex |> Maybe.andThen (\index -> Array.get index model)
            in
            Maybe.withDefault model
                (Maybe.map2
                    (\index element -> Array.set index { element | dragged = False } model)
                    maybeIndex
                    maybeElement
                )

これでドラッグを開始してから、ドラッグ終了によって要素をもとに戻すことはできました。しかし、肝心のドロップによる要素のソートは出来ていません。 この先ではいよいよソート部分の実装を説明していきます。

ここまでのコードをEllieで見ると、こうなります。

ソート部分の実装

再びドラッグからドロップの流れで発生する一連のイベントを振り返りましょう。この記事の実装で扱うのはdragstart, dragend, dragenterの3つでした。

ドラッグイベントの例

実は、ドラッグ&ドロップを行うと、dragendに連れてdropイベントも発生するのですが、今回の実装ではdropを無視しています。 なぜなら、dragenter時点、つまりドラッグした要素を他の要素の上に重ねた時点でソートを行うので、後はソート済みの状態でdragendをハンドルする際に、半透明になった要素を不透明に戻せばいいからです。

いよいよソートアルゴリズムの実装です。sortable list UIのソートアルゴリズムでは、コンピュータ・サイエンスで習うようなバブルソートやクイックソートなどの非常に有名なアルゴリズムは使えません。 下図を見てもわかるように、Array全体をソートするのではなく、ドラッグ元とドロップ先に挟まれた一部分のみをソートするからです。

ソートアルゴリズム1

しかし、Arrayをソート対象部分と、その上下の3つの部分に分ければアルゴリズムは非常に単純になります。それぞれをupper, lower, toRotateと呼びましょう。Elmのコードでもこの変数名を使っています。 あとは、下図にあるようにソートの向きに気をつければアルゴリズムは実装できます。

ソートアルゴリズム1

コードスニペットを貼ると長くなるので、下に貼り付けたEllieを確認してください。ソートアルゴリズムの実装はsortとrotateという2つの関数に分かれており、dragenterイベントによってソートをトリガーしています。 再び完成形のコードをEllieで見てみましょう。

解説は以上になります。参考になれば嬉しいです。

参考文献