JavaFXで2つのListViewのスクロールを同期する方法

とある目的があって (それがなんなのかはまた別途まとめます。) 、JavaFXListViewTableView について、同じ数のアイテムを持つ 2 つの ListView (TableView) のスクロール状態を同期する方法について調べてみました。

やりたいこと

次のように同じレコード数の ListView を並べます。
f:id:aoe-tk:20150522191146p:plain

片方をスクロールさせると、もう片方も一緒にスクロールさせるようにします。 どのスクロール手段 (マウスホイール、スワイプ、スクロールバーの操作) を用いても同期するようにします。
f:id:aoe-tk:20150522191220p:plain

Swing での実現方法

これを実現するにあたって、Swing で行った方法と同じ方法を使えないかを考えました。

Swing では、JListJTable をスクローラブルにするためには、JScrollPane に追加を行う必要があるのですが、この JScrollPane が保持する Model (BoundedRangeModel) を 2 つの JScrollPane で共有することで、簡単にスクロールを同期することが可能になります。
まさしく MVC パターンの利点ですね。

以下のサイトで詳しく説明されているので、参考にしてください。

ateraimemo.com

JavaFX では何もしなくても ListView や TableView はスクロール可能です *1 。ListView (TableView) 自身がスクロールバーを保持しています。
JavaFXScrollBar には value プロパティがあり、このプロパティがスクロール位置を示します。ということは、2 つの ListView (TableView) が保持する ScrollBar の value プロパティをバインドすることで、スクロールを同期させることが可能なはずです。

ListView が保持する ScrollBar の取得

ここで問題が発生します。ListView や TableView には、自身が保持する ScrollBar のインスタンスを取得するための公式の API が存在しないのです。どうやって ScrollBar のインスタンスを取得すればいいのでしょうか?

実は Node には lookuplookupAll という、CSS セレクタで自分の子供として保持している Node インスタンスを探すためのメソッドがあります。
そして、 ListView の CSS リファレンス を見ると、次のような情報が記載されています。
f:id:aoe-tk:20150522192012p:plain

ListView の子 Node の情報が記載されており、それぞれの Node の CSS スタイルクラスの情報が記載されています。 ScrollBar には .scroll-bar というスタイルクラスが設定されていることが分かりますね。これで ScrollBar のインスタンスを取得することができますね!

というわけで、ListView から ScrollBar のインスタンスを取得するためのコードスニペットを以下に示します。水平スクロールバーと垂直スクロールバーの 2 種類があることに注意してください。

private ScrollBar getScrollBar(ListView<String> listView, boolean vertical) {
    Set<Node> nodes = listView.lookupAll(".scroll-bar");
    for (Node node : nodes) {
        if (node instanceof ScrollBar) {
            ScrollBar scrollBar = (ScrollBar) node;
            if (vertical && scrollBar.getOrientation() == Orientation.VERTICAL) {
                return scrollBar;
            } else if (!vertical && scrollBar.getOrientation() == Orientation.HORIZONTAL) {
                return scrollBar;
            }
        }
    }
    throw new IllegalStateException("Not found!");
}

ScrollBar のインスタンスを取得できてしまえれば、後は value プロパティをバインドするだけです。次のようにバインドします。

@FXML
ListView<String> leftListView;

@FXML
ListView<String> rightListView;

private void bindScrollBar() {
    ScrollBar leftVerticalScrollBar = getScrollBar(leftListView, true);
    ScrollBar rightVerticalScrollBar = getScrollBar(rightListView, true);
    leftVerticalScrollBar.valueProperty().bindBidirectional(rightVerticalScrollBar.valueProperty());
    ScrollBar leftHorizontalScrollBar = getScrollBar(leftListView, false);
    ScrollBar rightHorizontalScrollBar = getScrollBar(rightListView, false);
    leftHorizontalScrollBar.valueProperty().bindBidirectional(rightHorizontalScrollBar.valueProperty());
}

これで垂直、水平スクロールが同期されます。

まだ罠がある

これで解決...と思いたいところですが、重大な注意点があります。それは ScrollBar インスタンスの生成タイミングです。 ListView や TableView はコンストラクタで new されたタイミングでは ScrollBar のインスタンスを生成しない のです。

JavaFXコンポーネント ( javafx.scene.control.Control を継承して作ったコンポーネント) はそれがシーングラフに追加され、実際にレンダリングされるタイミングまで見た目の部分を構築する処理を遅延するようになっています。
カスタムのコンポーネントを作成された方はご存じだと思いますが、Control には layoutChildren というフックメソッドがあり、コンポーネントのレイアウトを構築する処理はこのメソッドで行うように指示されています。

JavaFX のグラフィクス描画は保持 (retained) モードと呼ばれる仕組みになっており、描画命令は即座に実行せずまとめておき、まとまったタイミング (これを実行しているのが Pulse と呼ばれる、タイマー起動のイベントです) で一斉に実行します。
この方式は描画処理をまとめて実行するので効率が良い、ディスプレイのリフレッシュと同期できるのでなめらかな動きになるといった利点があり、WPFFlex など最近の GUI ツールキットは大抵この方式になっているようです。 *2

そのような考え方になっているため、コンポーネントのレイアウト処理についてもフックメソッドを用意して、まとめて処理するようになっているということです。

話を戻しますが、このように ListView (TableView) が保持する ScrollBar のインスタンスは生成が遅延されるので、例えば FXML コントローラーの initialize メソッドの処理で ScrollBar インスタンスを取り出そうとしても、この時点ではシーングラフに追加されていないので、null が返ってくることになります。

今回のコード例では次のように ScrollBar を取り出してバインドする処理をくくり出しておきます。 Platform.runLater で処理をくるんでいるのは、次の Pulse 実行後 (そこではレンダリング処理が終わっているので) に呼び出されるようにするためです。

public void requestBind() {
    Platform.runLater(this::bindScrollBar);
}

このメソッドを、FXML 読み込み側で次のように呼び出してもらうようにしました。

FXMLLoader loader = new FXMLLoader(getClass().getResource("sample.fxml"));
Parent root = loader.load();
primaryStage.setTitle("Scroll Test");
primaryStage.setScene(new Scene(root));
primaryStage.show();
Controller controller = loader.getController();
controller.requestBind();

これで、最初に紹介したスクリーンショットのように、2 つの ListView のスクロールを縦も横も同期できるようになります。
全体のコードは Gist にアップしているので、そちらをご覧ください。

https://gist.github.com/aoetk/b771a82d7069cba547b7

まとめ

まとめると次のようになります。

  • JavaFX でスクローラブルなコンテンツのスクロール状態を取得、更新する場合は、Swing と同様、スクロールバーオブジェクトが保持するステートを利用すれば良い。
  • JavaFX の ListView、TableView が保持するスクロールバーを取得するためには、現状 CSS セレクタ経由で取得するしか方法がない。
  • 上記方法でスクロールバーを取得する場合、レイアウト処理が一度行われた状態で取得するよう注意する必要がある。

スクロールバーあるいはスクロール状態を取得するための公開 API がないので、ややアクロバティックな方法になってしまっていますね。スクロール状態の取得、あんまり需要がないのかなあ?

さて、これはある目的を達成するための前調査だったんですよねえ。それについてはまた後ほど。

おまけ

Pulse をはじめとする、JavaFX の描画の仕組みについての話は、以前の JavaFX 勉強会で私の発表でも触れています。

また、「パーフェクトJava」の JavaFX のパートでもコンパクトかつ丁寧に説明されているので、こちらもおすすめです。

改訂2版 パーフェクトJava

改訂2版 パーフェクトJava

*1:というかほとんどの GUI ツールキットではそれが普通ですが...。

*2:反面、細かいチューニングが難しい、描画命令をキャッシュするのでメモリ消費が多くなるというデメリットもあります。