Drag&Dropで JavaFX ListView の任意の行に挿入させてみた

前回「JavaFX ListView の要素を Drag&Dropで移動させてみた - Java開発のんびり日記」で Drag&Drop でリスト間の要素を移動させる簡単なサンプルを作りました。

移動できる先がリストの末尾だけなのは物足りなくて、できればドロップした場所に要素を挿入したいですよね。
ということで、ごり押しなところもありますが、今回は任意の行に挿入するバージョンです。


f:id:hideoku:20130530224740p:plain:w450

作ったサンプルの概要

・ListDrag2.fxml(GUI部分)
・ListDragController2.groovy(MVCのControllerあたり)
・ListDragApp2.groovy(アプリ起動)
・style.css(動的なスタイルを使っているので)
の4つのファイルで構成しています。

構成はほとんど前回のシンプルなものと変わっていません。
すべて同じパッケージ内にあって、sample パッケージに配置しています。

前回と同様、まずはソースコードをすべて書き出します。

ListDrag2.fxml

<AnchorPane id="AnchorPane" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" 
    prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml" fx:controller="sample.ListDragController2">
  <children>
    <ListView fx:id="listViewSrc" layoutX="42.0" layoutY="40.0" prefHeight="200.0" prefWidth="200.0" />
    <ListView fx:id="listViewDest" layoutX="331.0" layoutY="40.0" prefHeight="200.0" prefWidth="200.0" />
  </children>
</AnchorPane>

FXML は前回とまったく変わってません。見た目は変わらずです。

ListDragController2.groovy

class ListDragController2 implements Initializable {

    @FXML private ListView listViewSrc
    @FXML private ListView<String> listViewDest

    private javafx.collections.ObservableList<String> listRecordSrc = FXCollections.observableArrayList()
    private javafx.collections.ObservableList<String> listRecordsDest = FXCollections.observableArrayList()

    @Override
    void initialize(URL url, ResourceBundle resourceBundle) {

        (1..10).each {
            listRecordSrc.add("コーヒー" + it)
        }
        listViewSrc.setItems(listRecordSrc)

        (1..5).each {
            listRecordsDest.add("coffee" + it)
        }
        listViewDest.setItems(listRecordsDest)

        listViewSrc.setOnDragDetected(new EventHandler<MouseEvent>() {
            @Override
            void handle(MouseEvent event) {
                String selectedValue = listViewSrc.getSelectionModel().getSelectedItem()

                Dragboard dragboard = listViewSrc.startDragAndDrop(TransferMode.COPY)

                ClipboardContent content = new ClipboardContent()
                content.putString(selectedValue)
                dragboard.setContent(content)

                event.consume()
            }
        })

        listViewDest.setCellFactory(new Callback<ListView<String>, ListCell<String>>() {
            @Override
            ListCell call(ListView<String> listView) {

                ListCell<String> cell = new ListCell<String>() {
                    @Override
                    protected void updateItem(String value, boolean isEmpty) {
                        super.updateItem(value, isEmpty)
                        if (!isEmpty) {
                            setText(value)
                        }
                    }
                }

                cell.setOnDragOver(new EventHandler<DragEvent>() {
                    @Override
                    void handle(DragEvent event) {
                        event.acceptTransferModes(TransferMode.COPY)
                        event.consume()
                    }
                })

                cell.setOnDragEntered(new EventHandler<DragEvent>() {
                    @Override
                    void handle(DragEvent event) {
                        cell.getStyleClass().remove("cellExit")
                        cell.getStyleClass().add("cellOver")
                    }
                })
                cell.setOnDragExited(new EventHandler<DragEvent>() {
                    @Override
                    void handle(DragEvent event) {
                        cell.getStyleClass().remove("cellOver")
                        cell.getStyleClass().add("cellExit")
                    }
                })

                cell.setOnDragDropped(new EventHandler<DragEvent>() {
                    @Override
                    void handle(DragEvent event) {
                        Dragboard dragboard = event.getDragboard()
                        if (dragboard.hasString()) {
                            javafx.collections.ObservableList<String> items = listView.getItems()

                            int addIndex = cell.getIndex() + 1 // mouseoverしている行の次行
                            if (items.size() < addIndex) {
                                addIndex = items.size()
                            }
                            items.add(addIndex, dragboard.getString())
                        }
                        event.setDropCompleted(dragboard.hasString())

                        event.consume()
                    }
                })
                return cell
            }
        })
    }
}

ドラッグ元リスト(listViewSrc)は前回から変更ありません。
ドラッグされたら DragBoard に値をセットするだけです。

ドロップ先リスト(listViewDest)はかなり変わっています。
これは後述でくわしく。

ListDragApp2.groovy

class ListDragApp2 extends Application {

    @Override
    void start(Stage stage) {
        Parent root = FXMLLoader.load(getClass().getResource("ListDrag2.fxml"))
        stage.setTitle("リストDrag&Dropサンプルその2")

        def width = 600
        def height = 300
        Scene scene = new Scene(root, width, height)
        stage.setScene(scene)

        String cssPath = getClass().getResource("style.css").toExternalForm()
        scene.getStylesheets().add(cssPath)

        stage.show()
    }

    static void main(String[] args) {
        launch(ListDragApp2.class, args)
    }
}

前回からの変更点は CSS ファイルの読み込みです。
今回使用する CSS ファイルを scene.getStylesheets().add(xxxx) って感じで追加しています。

style.css

.cellOver {
    -fx-border-width: 0 0 2 0;
    -fx-border-color: black;
    -fx-border-style: solid;
}
.cellExit {
    -fx-border-width: 0;
}

Drag&Drop でドロップ先リストにマウスオーバーした際、挿入候補となる箇所を示すために、セル行間に黒いボーダーを表示しています。
その表示の際に使用するスタイルを定義しています。詳しくは後述します。

今回作成したソースはこれですべてです。
以下、要所をピックアップしてまとめていきます。

ドロップ先を ViewList ではなく、ListCell にする

前回はドロップ先として、ViewList のインスタンスを設定して setOnDragXxxx() メソッドを実装しました。
今回の一番の変更点はそのドロップ先を ViewList の各行を構成する ListCell に変更したところです。

ViewList だと、Drag&Drop でマウスオーバーしている行のインデックスを取得できません。
これが変更した理由になります。

listViewDest.getSelectionModel().getSelectedIndex()
listViewDest.getFocusModel().getFocusedIndex()

といったメソッドで行位置をとれるかなと思ったのですが、マウスオーバーでは反応しませんでした。
行位置がとれないと、要素の挿入位置がわからないのでつらいです。

一方で、ListCell はgetIndex() という自分の行位置を返すメソッドを持っています。
ListCell に対して、ドロップイベント(setOnDragXxxx)を実装すれば、行位置を取得して使うことができます。

リスト全体に対してドロップイベントを設定するのではなく、リスト内の各行に対してドロップイベントを設定するということになります。(…重くなるのかな?未検証)

ListView#setCellFactory(Callback callback) という難解なものを実装することになり大変ですが、
ListCell の動きを実装するためには必要なことのようです。

ListView#setCellFactory(CallBack callback) を実装する

listViewDest.setCellFactory(new Callback<ListView<String>, ListCell<String>>() {
    @Override
    ListCell call(ListView<String> listView) {
        /* 行を構成する ListCell のふるまいを実装して返す */
    }
})

詳しくはわかりませんが、etCellFactory() メソッドの引数で Callback インスタンスを設定する。
そのインスタンスの call() メソッドが返す ListCell インスタンスが各行のふるまいを決めることになるので、call() メソッド内で ListCell のふるまいを実装しておけよ…ってことなんだと思います。

CallBack#call() メソッドを実装する

というわけで、call() メソッドです。まずは setOnDragXxxx() 以外のところです。

@Override
ListCell call(ListView<String> listView) {

    ListCell<String> cell = new ListCell<String>() {
        @Override
        protected void updateItem(String value, boolean isEmpty) {
            super.updateItem(value, isEmpty)
            if (!isEmpty) {
                setText(value)
            }
        }
    }

    /* cell.setOnDragXxxx() を実装していく */

    return cell
}

ListCell を返さないといけないので、ListCell インスタンスを生成して返します。
ただ、注意しないといけないのは new しただけだとダメです。

ListCell<String> cell = new ListCell<String>() 
return cell

こんな感じに実装すると、動かしたときに ListView で値が出力されなくなります。
上記であげたように ListCell#updateItem() を実装する必要があります。

セルに対する setOnDragDropped() を実装する

cell.setOnDragDropped(new EventHandler<DragEvent>() {
    @Override
    void handle(DragEvent event) {
        Dragboard dragboard = event.getDragboard()
        if (dragboard.hasString()) {
            javafx.collections.ObservableList<String> items = listView.getItems()

            int addIndex = cell.getIndex() + 1 // mouseoverしている行の次行
            if (items.size() < addIndex) {
                addIndex = items.size()
            }
            items.add(addIndex, dragboard.getString())
        }
        event.setDropCompleted(dragboard.hasString())

        event.consume()
    }
})

前回は ListView の setOnDragDropped() を実装しましたが、今回は ListCell に対して実装します。
前述しましたが、ListView だとドロップした行位置を取得できないので ListCell を使っています。

ドラッグしてきた値をリストに要素追加する主処理は変わっていません。
行位置を取ってきて、要素を挿入する位置を指定するところだけです、増えたのは。

ListView で5行しかないのに7行目とかにドロップすることができて、ObservableList に add するときに例外が発生してしまいます。例外発生を回避するための小細工もいれてたりします。

挿入先がわかるようにドラッグされているときだけスタイルを変える

この後、スタイルを変えるために setOnDragEntered() と setOnDragExited() を実装しているので、
この2つを実装しなくてもやりたいことは実現できます。見た目よくするってことです。

JavaFXCSS でスタイル定義できる

スタイルを設定するので、まずは CSS の定義です。

.cellOver {
    -fx-border-width: 0 0 2 0;
    -fx-border-color: black;
    -fx-border-style: solid;
}
.cellExit {
    -fx-border-width: 0;
}

2つスタイルクラスを定義しています。
枠線を追加するクラス(.cellOver)と枠線をなくすクラス(.cellExit)です。

やりたいのは、ドラッグされたときにセルの下部に枠線を表示して挿入先を示すということです。

JavaFX では -fx- で始まるクラスを使うことができて、スタイルを設定することができます。
ただ、border-bottom-width といった上下左右のいずれかのみ適用させるクラスがないようです。
ということで、上下左右を明示的に指定し bottom 以外を 0 にしています。

そして、.cellExit という枠線をすべて 0 にするクラスもやむなく作っています。
作りたくないのに作っている理由は後で。

ドラッグ&マウスオーバーの有無でスタイルを変える

要素にドラッグされてきたときに呼ばれる setOnDragEntered() と
要素からドラッグが外れたときに呼ばれる setOnDragExited() を実装します。

※補足ですが、要素の上でドラックされているときに呼ばれるのは setOnDragOver() です。

ということで、setOnDragEntered() で枠線を付加する cellOver クラスを ListCell に追加して、
setOnDragExited() でその cellOver クラスを ListCell から削除して、枠線を除去します。

cell.setOnDragEntered(new EventHandler<DragEvent>() {
    @Override
    void handle(DragEvent event) {
        cell.getStyleClass().add("cellOver")
    }
})
cell.setOnDragExited(new EventHandler<DragEvent>() {
    @Override
    void handle(DragEvent event) {
        cell.getStyleClass().remove("cellOver")
    }
})

こういう感じの実装になります。
が、うまくいかないです。枠線が消えません。一度付加された枠線がずっと残る事象発生…
remove() でクラスの設定は削除されているのですが、見た目が変わりません。

色々試してみて、次のような法則を見つけました。(注:あくまでも今回のコンテキストにおいて)
①クラスを remove しても、そのクラスが実現していた見た目が取り消しされるわけではない
②後で追加されたクラスのスタイルが優先される
③すでに存在するクラスを再度追加した場合、②の法則はあてはまらない

ということで、こんな感じの実装で枠線の付加と除去が実現できました。

cell.setOnDragEntered(new EventHandler<DragEvent>() {
    @Override
    void handle(DragEvent event) {
        cell.getStyleClass().remove("cellExit")
        cell.getStyleClass().add("cellOver")
    }
})
cell.setOnDragExited(new EventHandler<DragEvent>() {
    @Override
    void handle(DragEvent event) {
        cell.getStyleClass().remove("cellOver")
        cell.getStyleClass().add("cellExit")
    }
})

もう、ゴリ押しな感じで嫌だ。
でも、挿入先がわかるようになっていい感じになりました。

今後の改善点

かなり長くなりましたが、まとめてみました。
Drag&Drop で操作できるようになると、UI の満足度はぐっと上がりますね。

まだまだ、改善の余地はあるので、挑戦してみようと思っています。

ListView 各行の要素を String で持たせているので、ここをクラス化して各行の情報量を多く持たせるようなこともしたいと思っています。
これに関してはサンプルが色々と転がっていたので簡単にできそうです。

ドラッグしている状態だと、リストのスクロールを上下することができません。
Drag&Drop している状態でマウスカーソルがリストの下部にあるとき、スクロールバーを下に動かしてリストを先送りスクロールさせたいです。

とはいえ、ListView 以外にもやらないといけないことがあるので、まだまだ先の話になりそうです。