26
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ドワンゴAdvent Calendar 2020

Day 15

Elmでドッキングウインドウを作ってみた

Last updated at Posted at 2020-12-14

これは「ドワンゴ Advent Calendar 2020」の15日目の記事です。

ドッキングウインドウとは

Visual Studio や InteliJ IDEA のような IDE にはユーザがツールが表示されている子ウインドウをタイル上に自由にレイアウトできる機能があります。

image.png

参考: うまくドッキングしないウィンドウにサヨナラ! - @IT

こちらの機能、様々なアプリケーションで見かけ、また検索すると様々な言語で様々なライブラリが公開されています。プロプライエタリなものが多いようですが、フリーのものもいくつかあるようです。

  • 例1: AvalonDock: 以前 WPF 利用したことがある
  • 例2: Dock Spawn TS: オンラインのデモがある (画像参照)

このドッキングウインドウ、利用する立場からだと直感的でマニュアルも必要なく便利なのですが、いったいどうやったらこのような機構を実装できるのか、それほど自明ではないように思えます。

今回はこのドッキングウインドウの自作に挑戦したのでその軌跡を紹介します。開発言語には Elm を利用しました。Elm はまだバージョンが浅かった時代 (FRP推しだった時代) に触ったことがあるのですが、最近のバージョンはきちんと触ったことがないのでその学習も兼ねています。

Elm についてはオンラインのドキュメントが充実しいて、さらに Qiita 上にも多数記事があります。紹介記事としては以下のものがわかりやすかったです。

以降ちょくちょく Elm の話が出てきますが、ドッキングウインドウ自体はどのような言語・環境で作っても同じように作れそうです。未検証ですが特に React + Redux の組み合わせだと、ほとんど Elm と同じように作れるのではないかと思います。

作ったもの

image.png

本当はもっと機能が充実したものを作りたかったのですが思いのほか苦労しました。今日までに次の機能が実装できました。

  • ペインの追加と削除ができる
  • ペインを領域にドックしたり浮かせたりできる
  • ペインの移動とリサイズができる

一般的なドッキングウインドウにあって今回の実装にない重要機能として、タブとピンがあります。またドキュメントとツールの区別とか細かい機能は一切ないです。しかし、ドッキングウインドウの自由にレイアウトできる機構は実装できたので満足です。

ドッキングウインドウ実現までの軌跡

いきなりドッキングウインドウを作ろうにも、何から手を付ければ良いか分からないので、できそうな部分から順番に作りました。以下でその軌跡を紹介します。

STEP1: 四角を表示してドラッグで移動できるようになる

まず最初のゴールとしてマウスでドラッグできる四角形を描画するプログラムを目指しました。四角をドラッグするプログラムを作るにはグラフィックの描画とマウスのイベントの扱い方が必要になります。まずグラフィックの表示ですが今回は SVG を利用しました。今回作った範囲では HTML でも作れるかと思います。

Elm で SVG は簡単に扱うことができ、公式サイトでもサンプルとして紹介されています。

Elm で SVG を扱うライブラリとしては公式で用意されている elm/svg があります。こちらのライブラリ、数値も含めてあらゆるものを String 型で表すのでそのあたりを改善したライブラリとして elm-community/typed-svg があるようです。今回は公式の elm/svg を利用しました。

次にマウスイベントの扱いですが、以下のページにあるように、サブスクリプションとよばれる仕組みを使うようです。

このあたりを学習することで四角をドラッグするプログラムが作成できました。

Elm のプログラムはモデルとメッセージをどう作るかの部分が肝になります。今回の場合は次のように作りました。

type alias Model =
    { winSize : Size
    , mousePos : Pos
    , draggingStatus : DraggingStatus
    , rect : Rect
    }

全画面に表示するためにブラウザのサイズとマウスの位置をモデルに含めています。ブラウザのリサイズとマウスの移動はサブスクリプションにより通知されます。draggingStatus はドラッグ中であるかそうでないかを表しています。Offset でドラッグしたときに四角の中のどこをクリックしたかを保持しています。

type DraggingStatus
    = NotDragging
    | Dragging Offset

次にメッセージですが、次のようにしました。

type Msg
    = BrowserResize Size
    | MouseMove Pos
    | MouseUp Pos
    | StartDragging Offset

ブラウザのリサイズとマウスの移動とマウスを放したときのメッセージは次のサブスクリプションで発行されます。Decoder の書き方は慣れるまで苦労しました。

subscriptions : Model -> Sub Msg
subscriptions _ =
    let
        resizeMsg : (Size -> a) -> Int -> Int -> a
        resizeMsg f width height =
            f { width = toFloat width, height = toFloat height }

        mousePosDecoder : (Pos -> a) -> Decoder a
        mousePosDecoder f =
            Dec.map2 (\x y -> f { x = x, y = y })
                (Dec.field "offsetX" Dec.float)
                (Dec.field "offsetY" Dec.float)
    in
    Sub.batch
        [ Browser.Events.onResize (resizeMsg BrowserResize)
        , Browser.Events.onMouseMove (mousePosDecoder MouseMove)
        , Browser.Events.onMouseUp (mousePosDecoder MouseUp)
        ]

このコードの resizeMsgmousePosDecoder は必ずしも let の中に書く必要はないのですが、他の関数からは使われていないということで let の中に入れてしまいました。このあたりどのように書けばスマートかはまだ自信がありません。トップレベル相当と言うことで resizeMsgmousePosDecoder にも型を書いています。メッセージに応じてモデルを変更する update 関数は次のようになります。

細かな型や補助関数は ソースコード全体 にあります。

STEP2: 横方向にペインを追加するプログラム

STEP1のドラッグアンドドロップの段階で必要な技術はおおむね習得できたので、以降では何をどういう順番で作ったのかが重要となります。次に目標としたのは、ペインを横方向に追加していくプログラムです。以下の画像のようになります。

image.png

ここで重要なのは以下の2点です。

  • レイアウトをどういうデータ構造で表現するか
  • どのように追加するか

レイアウトをどのように表すかですがユーザが移動するウインドウをペイント呼びます。ペインは以下のように定義しました。重要なのは位置とサイズを持っている部分になります。

type alias Pane =
    { id : PaneId
    , pos : Pos
    , size : Size
    , title : String
    , status : PaneStatus
    , content : PaneContent
    }

status にはペインの状態を入れます。状態にはどこかにドックされている状態と、ドックされていない状態(フローティング状態)があります。

type PaneStatus
    = Docked DockingAreaId
    | Floating

content には背景色を入れています。背景色は常に白で実装したので実質意味のないフィールドとなっています。実質的に title で区別することとしています。

type alias PaneContent =
    { backgroundColor : String
    }

状態にかかわらずすべてのペインは Dict 型に格納して、PaneId (Intのalias) で識別しています。

type alias PaneDict =
    Dict PaneId Pane

このペインを並べるデータ構造として、DockingArea というのを作りました。

type alias DockingArea =
    { id : DockingAreaId
    , pos : Pos
    , size : Size
    , orientation : Orientation
    , items : List DockingItem
    }

type Orientation
    = Horizontal
    | Vertical

DockingArea 自体も位置とサイズを持っており、items としてペインをリストで保持しています。DockingItem 型は次のようになっています。単純なペインを格納している場合と、別の DockingArea を格納している場合があります。各要素には factor という値があり、親のサイズのうち自身が占めている割合を表示します。DockingAreaitems のすべての factor の値を合計すると 1.0 になるように値を割り振ります。

type alias DockingItem =
    { itemType : DockingItemType
    , factor : Float
    }

type DockingItemType
    = PaneItem PaneId
    | DockingAreaItem DockingAreaId

図で表現すると次のようになります。

image.png

ペイン同様すべての DockingAreaDict 型に格納して、DockingAreaId(Intのalias) で識別しています。図で表現すると次のようになります。ペインを追加する場合は、DockingArea の先頭に追加することとします。DockingArea の先頭のアイテムを取り出して、そのアイテムの factor1/2 を追加するアイテムに割り当てるようにします。

いくつかのドッキングウインドウのレイアウトを触った結果、今回のようにリストで表現する実装と、リストではなくペアで表現している実装があるようでした。ペアで実装する場合は次の図のようになります。

image.png

こちらの場合は pane1 の左側の隙間をドラッグしたときに、そのサイズ変更が pane3pane2 両方に影響します。InteliJ IDEA は挙動をみるとペアで実装してるように見えます。

STEP3: ペインの幅をマウスで変更できるようにする

ドッキングウインドウではマウスでペイン間の幅を変更できるのですが、この変更できる部分は動的に変化します。この変更できる部分を今回はスプリッターと呼びます。例えば4つのペインが存在する場合、スプリッターは3つになります。今回のレイアウトの持ち方だと、スプリッターを移動することで、その両側にあるペインの factor の値を適切に変更する必要があります。

image.png

Elm でこのようなプログラムを作るにはスプリッターを特定する方法が必要になります。今回は次のように、スプリッターが所属する DockingArea とその領域内で何番目のスプリッターかを整数で特定しています。

type alias SplitterId =
    { areaId : DockingAreaId
    , index : Int
    }

スプリッターが特定できれば後は四角形のドラッグアンドドロップと同じ要領で指定した位置に移動させることができます。移動させた位置に応じて factor の値を変更すれば完了です。

STEP4: ペインを切り離してフローティング状態にできるようにする

ペインを並べることができたので次は分離します。

image.png

分離するにはペインのタイトルバーをドラッグすれば良いのですが、マウスをクリックした瞬間に分離すると使い勝手が悪いのでドラッグして一定の距離離れた時に分離するようにします。ペインの分離ができるようになると分離したペインの移動やリサイズを実装したくなります。移動やリサイズの実装は特に難しいところはありません。

STEP5: フローティング状態のペインをドックする

分離できるようになったら、再度 DockingArea に戻したくなります。どのように操作してドックさせるかですが、今回は Visual Studio のようにアイコンを出して、そこにドラッグしたらドックする挙動とします。アイコンはペインの領域の上にマウスが移動したときに表示するようにします。

image.png

マウスを離すと、

image.png

ドック後の割合ですが以下図のように割り振りました。

image.png

  • 右のペインの factor2/3 にする。
  • 左のペインの factor2/3 にする。
  • 左右のペインから残った量を新しいペインに割り振る。

図を見ると明らかですが、pane3 の右にドックする場合と、pane1 の左にドックする場合の挙動は同じになります。ドックする場合、常に左右にペインがあるとは限りません。ドッキングエリアの両端の場合は、1/2 にして割り振るように実装します。

さらにドッキングエリアが空の場合は特別なアイコンを表示して、ドックしたアイテムが全体を占めるようにします。

image.png

STEP6: 縦に分割できるようにする

横方向の分割が動くようになったので次は縦に分割します。

image.png

マウスを離すと、

image.png

縦の分割では以下の操作を行う必要があります。

  1. 縦方向にレイアウトする DockingArea を新しく作る
  2. ドック先のペインを DockingArea に置き換える
  3. 新しく作った DockingArea の items としてドック先のペイントドッキングするペインを並べる。

実装できたら縦方向にレイアウトしている DockingArea の中にさらに横方向にレイアウトする DockingArea を作成できるようにします。

image.png

レイアウトできたらさらに分離もできるように必要があります。分離をすると items が空の DockingArea が生まれます。この items が空の DockingArea が生じた場合にはレイアウトから取り除きます。具体例としては以下のようなレイアウトがあるとします。

image.png

このレイアウトから A と B を取り除くと空の DockingArea ができるので取り除いて、C が全画面になるようにします。この空のレイアウトは再帰的に発生する (空の DockingArea を取り除いた影響で、別の DockingArea が連鎖的に空になる) ので実装時に注意が必要です。

STEP7: ルートの DockingArea のマーカーを作る

ここまでできればほぼ DockingArea なのですが、例えば以下のようにDを挿入する操作はマーカーが表示されないためできません。

image.png

さらに次のようなルートの DockingArea の方向が変わるような変更もできません。

image.png

このような変更に対応できるようにルート要素の周囲にもアイコンを出します。

image.png

マウスを離すと、

image.png

STEP8: 閉じるボタンを作る

ペインは閉じたいので閉じるボタンを作ります。クリックするとペインが消滅します。

image.png

閉じるは分離処理とほぼ同じなので特に難しくありませんでした。

今後のステップ

ドッキングウインドウのような挙動をするプログラムは書けました。この後に欲しい機能としてはやはりタブ機能があります。タブの他にはウインドウを自動で閉じる機能 (ピン機能) があります。

Elmの感想

ドッキングウインドウはそもそもの挙動が複雑なので、どのような言語だろうとあまり難易度に変化はなさそうではありますが、今回のように SVG をがっつり操作するプログラムには向いているように感じました。今回 Elm をがっつり触ったので初心者の感想をまとめておきます。

デバッガーが便利

Elm Debugger がとても便利です。モデルの内容を GUI で表示してくれて、さらにメッセージの履歴を閲覧して過去に状態を戻すことができるので、不具合を一瞬で発見できます。

image.png

型が通れば正しく動く

これは OCaml のような言語でも同様でとても感覚的なものになりますが、他のプログラミング言語だと、型を通した後に正しく動くかどうかを確認するフェーズがあると思うのですが、Elm だと型が通ればたいてい正しく動きます。もちろんモデルの作り方が適当だとこの恩恵は得られない訳ですが、型が通って正しく動かない場合は、モデルの作り方が悪いのではないかという発想になります。

例えば、今回の実装では Pane のIDと DockingArea のIDがどちらも INT になっていて、取り違えてもエラーになりません。仮にこれを別の型として定義すれば、このあたりのミスを検出できるようになりそうです。

コードの整理がやりやすい

今回かなり試行錯誤を繰り返しました。特にドッキングエリアの構造をどうやってモデルで表現すべきかは作りながら決めていきました。適当に作って後で直しても、型にがっちり守られているので安心して実装を整理できるのは Elm の利点だと感じました。(しただ今のコードはかなりひどいです)

まとめ

正直なところ、実際に作り始めるまでドッキングウインドウを作るのはそんなに難しくないと考えていましたが、実際に作ってみると意外と大変でした。しかもタブまで実装が終わりませんでした。会社で同じような分野をある程度長く続けていると、どうしても慣れてしまってあまり技術的な部分での苦労を感じなくなってしまいがちですが、たまには考え方の違う言語を触るのは大切だなと今年も思いを新たにしました。

26
15
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?