読者です 読者をやめる 読者になる 読者になる

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

主要IDEのJavaFXプロパティへの対応状況

はじめに

JavaFX 開発を行ったことがある人はご存じだと思いますが、JavaFX ではプロパティに対して既存の JavaBeans のプロパティとは異なる新しい API を導入しています (以降、JavaFX で導入された新しいプロパティ構文のことを「JavaFX プロパティ」と呼ぶことにします) 。
それについては自分のブログでも以前に「 JavaFX Advent Calendar 2012 26日目 GroovyのVetoableを使ったサンプルをJavaFXのバインディングを使って実装してみる 」というエントリを書いていました。

このエントリでも解説していますが、JavaFX では従来の getter、setter とは異なるプロパティの定義を行います。
NetBeansEclipseJavaFX 開発環境である e(fx)clipse では少し前からこのプロパティに対応していたのですが、つい最近、 IntelliJ IDEA でもしれっと JavaFX プロパティの生成に対応していたことに気付きました。
ということで、主要 3 大 IDEJavaFX プロパティへの対応状況についてまとめてみます。

JavaFX プロパティの記述について

まず、IDE がどのように JavaFX プロパティの記述をするべきかについて示します。

String 型の name という名前の読み書き両方が可能なプロパティは次のように記述します。

private StringProperty name = new SimpleStringProperty();

public StringProperty nameProperty() {
    return name;
}

public final String getName() {
    return name.get();
}

public final void setName(String name) {
    this.name.set(name);
}

JavaFX のプロパティは XXProperty 型 ( XX にはラップする値のクラスが入る) でラップします。そしてこの Property 型の get/set メソッドで値のやり取りをします。
外部に公開するときは "(プロパティ名)Property()" という名前のメソッドを宣言します。最低限これができていれば JavaFX の範囲では OK です。
さらに、既存の JavaBeans 仕様に合わせる必要があるときは getter/setter も上記コードのように実装します。サブクラスで置き換えられるのを防ぐために final 宣言するのが一般的です。

リードオンリーなプロパティの定義については、 上に挙げたエントリでも解説したように 二通りの方法がありますが、多くの場合は ReadOnlyXXWrapper 型を使って次のように記述します。

private ReadOnlyStringWrapper name = new ReadOnlyStringWrapper();

public ReadOnlyStringProperty nameProperty() {
    return name.getReadOnlyProperty();
}

public final String getName() {
    return name.get();
}

IntelliJ IDEA

まず、自分が最近実装に気付いた IDEA から見てみましょう。

IDEA の場合、先にフィールドの定義を行ってから、コード生成の Getter and Setter を実行します。
f:id:aoe-tk:20150428011717p:plain

getter/setter 生成の対象が JavaFX プロパティに相当する型であった場合... f:id:aoe-tk:20150428011902p:plain

次のようなコードが生成されます。 f:id:aoe-tk:20150428012414p:plain

ちゃんと JavaFX プロパティの形式に合わせたメソッドが生成されていますね!いつの間にこの機能を実装したんだろう?
ヘルプにはこの機能についての解説は一切ありませんでしたし、今までのリリースノートをひっくり返しても特にこの機能に関する記述は見当たりませんでした。
この機能の存在については 某社のあの人 も、 IntelliJ IDEA エバンジェリストのあの人 も知らないに違いない!

次にリードオンリープロパティについて見てみます。次のようなフィールドを宣言した状態でコード生成をしてみます。 f:id:aoe-tk:20150428013019p:plain

その結果は次の通り。 f:id:aoe-tk:20150428013048p:plain

ああ、残念。対応できてませんね...。本当はこうなって欲しいんです。 f:id:aoe-tk:20150428013201p:plain

ちなみに、フィールドを Wrapper ではなく、ReadOnlyXXProperty 型で宣言した場合は getter のみ生成します。これはバグでしょうね。後で YouTrack に起票しておきますかね。

NetBeans

次は NetBeans です。IDEA の時は予めフィールドを宣言しましたが、NetBeans の場合はフィールドの生成からウィザード形式で作るようになっています。 JavaFX プロジェクトの場合、コード生成リストに「Java FXプロパティの追加」というメニューが追加されています。*1
f:id:aoe-tk:20150428013719p:plain

これを選択すると次のようなダイアログが表示されます。 f:id:aoe-tk:20150428013920p:plain

フィールド名やプロパティの型、初期値などを指定できます。さらに読み書き可能かリードオンリーかも選べます。読み書き可能を選ぶと次のようなコードが生成されます。 f:id:aoe-tk:20150428014039p:plain

リードオンリーにしたい場合はダイアログの内容を次のように入力します。 f:id:aoe-tk:20150428013833p:plain

すると次のようなコードが生成されます。 f:id:aoe-tk:20150428014157p:plain

さすが Oracle が開発を引っ張っている IDE だけあって、ちゃんと対応できていますね。ただ残念なことに、このウィザードは JavaFX プロジェクトでしか出現しません。そのため、Maven で作ったプロジェクトではこのウィザードが出現しないのです。JavaFX 形式のプロパティは JavaFX アプリケーション以外でも利用可能なので、このような余計な制限は撤廃して欲しいですね。

Eclipse (e(fx)clipse)

最後は最も利用者が多いであろう Eclipse です。厳密には EclipseJavaFX 開発機能を追加するプラグインである e(fx)clipse についてですが。

e(fx)clipse の場合、IDEA の場合と同じように、予めフィールドを作成しておいてからコード生成の Generate JavaFX Getters and Setters を選択します。 f:id:aoe-tk:20150428014904p:plain

すると次のようなウィザードが起動され、対象となるフィールドを選択します。 f:id:aoe-tk:20150428015018p:plain

OK を選択すると次のようなコードが生成されます。オプションで final にするかを選択できるのもいいですね。 f:id:aoe-tk:20150428015117p:plain

フィールドが ReadOnlyXXWrapper 型の場合、次のようにちゃんとリードオンリープロパティのためのコードが生成されます。完璧ですね。 f:id:aoe-tk:20150428015304p:plain

というわけで、主要 3 大 IDEJavaFX プロパティへの対応状況についてまとめてみました。IDEA のリードオンリープロパティの対応について問題がありますが、どの IDE も基本的な対応がされていることが分かりますね。
JavaFX プロパティは記述がちょっと面倒だな、と思っていた方もいらっしゃったかも知れませんが、このように IDE のサポートも得られるようになったので、ご安心 (?) ください。

JavaFX で新しい形式のプロパティが導入されたことの意義についてはまた別の機会にまとめたいと思っています!

*1:Java と FX の間にスペースが入っているのはどういうことだ...

JSR-377 Desktop|Embedded Application APIなんてのが始まっているようです

最近知ったのですが、 JSR-377 Desktop|Embedded Application API というものがスタートしているそうです。
英語圏でもごく狭い範囲でしか話題になっていないようで、もちろん日本語でこの情報について触れている人は自分の観測範囲では見当たりませんでしたので、ちょっと紹介してみたいと思います。

JSR-377 の Web ページから引用すると、次の API を定めようとしているようです。

* dependency injection via JSR330.
* common application structure.
* application life-cycle.
* localized resources.
* resource injection.
* localized configuration.
* decouple state from UI (binding).
* persistence session state (preferences).
* action management.
* component life-cycle.
* light-weight event bus.
* honor threading concerns (specific to UI toolkit).
* application extensibility via plugins (implies modularity).

要は比較的大規模な GUI アプリケーションを開発する際に、大体いつも作られる機能を共通化してしまおうというものです。アプリケーションフレームワークを作ろうとしているのですね。

実は過去にも JSR-296 Swing Application Framework というものがありました。これは Swing 開発のためのアプリケーションフレームワークを作ろうというもので、確か Java SE 7 に含めることを目標としていたように記憶しています。
ですがその後、Sun が JavaGUI 開発プラットフォームの主軸を Swing から JavaFX に移すという方針転換を行った影響で、この JSR はお蔵入りになってしまいました。
なので、JSR-377 は Swing App Framework のリブート版のように見受けられます。そういう意味でも個人的にとても注目しています。

Spec Lead は Groovy の GUI フレームワークである Griffon の開発者 Andres Almiray さんです。 Expart Group には Hendrik Ebbers さんや Johan Vos さんなど JavaFX 界で有名な方々が名を連ねています。

この JSR で目を引くのが、Swing、JavaFXSWT といった UI ツールキットを特に限定していない点です。特定のツールキットに依存しない、抽象的な API にすることを目指しているようです。
こういう変に抽象化した API を作ろうとするとうまく行かないことが多いので、個人的には少し引っ掛かります。
ただ、メンバーには JavaFX 系の人が多い (JavaFX でのデータ取得を抽象化するフレームワークである DataFX の開発メンバーが多い) ので、実際には JavaFX 中心で話が進むと見ています。

とは言え、こういうアプリケーションフレームワークができてくると、大規模開発における敷居も低くなりますし、今後に注目していきたいと思っています。

JavaFX の WebView に文字列検索機能を付けてみる

このエントリは JavaFX Advent Calendar 2014 の 19 日目のエントリです。前日は id:yumix_h さんによる、「WebView(JavaFX)のズーム機能を使ってみました」でした。

はじめに

JavaFX には Web ブラウザコンポーネントである WebView が付いているのは皆さんよくご存じかと思います。
DOM へのアクセス、JavaScript 実行、ヒストリ情報へのアクセス、JavaScript イベントのフック、など様々な機能が提供されており、他の GUI ツールキットが提供するブラウザコンポーネントと比較しても遜色がないと思っています。

ただ、残念なことに WebView 上に表示されているコンテンツに対してテキスト検索を行うための API が用意されていないんですよね。
もちろん表示されているコンテンツの DOM にアクセス可能なので、その DOM を頑張ってパースするという手もあるのですが、中々辛い。

というわけで、既存の JavaScript ライブラリを使って手っ取り早く実装する方法を考えてみました。

作ってみたもの

次の図のように、WebView を使ったブラウザアプリケーションに検索ボックスを取り付け、検索ボックスに文字列を入力するとマッチする部分をハイライトするようにしました。

マッチした部分をハイライトするだけで、ハイライトした箇所にスクロールする機能はありません。

ちなみに、このスクリーンショットは、自分が作った Social Bookmark Viewer FX という、Diigo というソーシャルブックマークサービスのビューアアプリケーションの一部です。 *1

利用した JavaScript ライブラリ

この機能を実現するために次のライブラリを利用してみました。

JavaScript text higlighting jQuery plugin
http://johannburkard.de/blog/programming/javascript/highlight-javascript-text-higlighting-jquery-plugin.html

jQueryプラグインとして提供されているライブラリです。
使い方としては、予め以下の CSS クラスを定義しておき、

.highlight { background-color: yellow }

あとは次のようにこのプラグインjQuery オブジェクトに生える highlight() メソッドを、ハイライトしたい文字列を引数に渡して呼べば OK です。

$('body').highlight('foo');

WebView に検索機能を追加する

それではこれを WebView で使えるようにします。まず、WebView 上のドキュメントのロードに成功したら、jQuery 及び上で紹介した検索ライブラリをドキュメントに追加します。

final WebEngine webEngine = webView.getEngine();
final Worker<Void> loadWorker = webEngine.getLoadWorker();
loadWorker.stateProperty().addListener((observable, oldValue, newValue) -> {
    if (newValue == Worker.State.SUCCEEDED) {
        // 読み込みが成功した場合、検索用プラグインを読み込む
        Optional.ofNullable(webEngine.getDocument()).ifPresent(document -> {
            addScriptElement(document, "//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"); // (1)
            addScriptElement(document, "http://johannburkard.de/resources/Johann/jquery.highlight-4.closure.js"); // (2)
            addStyleElement(document, ".highlight { background-color: yellow; }"); // (3)
        });
    }
});

WebEngine の読み込み状態を観察し、成功した場合にのみ検索用プラグインを読み込むようにしています。
(1) で jQuery を、(2) で検索ライブラリを、(3) で検索ライブラリが要求するスタイルクラスを DOM に追加しています。
JavaScript ライブラリはどちらもネットワークから直接読み込んでいます。

addScriptElement() メソッド及び addStyleElement() の実装は次のようになっています。

private void addScriptElement(Document document, String url) {
    final Element scriptElm = document.createElement("script");
    scriptElm.setAttribute("type", "text/javascript");
    scriptElm.setAttribute("src", url);
    NodeList bodys = document.getElementsByTagName("body");
    if (bodys != null && bodys.getLength() > 0) {
        bodys.item(0).appendChild(scriptElm);
    }
}

private void addStyleElement(Document document, String styleContent) {
    final Element styleElm = document.createElement("style");
    styleElm.setAttribute("type", "text/css");
    styleElm.setTextContent(styleContent);
    NodeList bodys = document.getElementsByTagName("body");
    if (bodys != null && bodys.getLength() > 0) {
        bodys.item(0).appendChild(styleElm);
    }
}

普通に DOM API を用いて、script 要素や style 要素を作ってドキュメントに追加しているのが分かりますね。

続いて検索テキストボックスに入力があったら、ハイライトする処理です。

final TextField pageSearchBox = new TextField();
pageSearchBox.textProperty().addListener(
        (observable, oldValue, newValue) -> highlightpage(Optional.ofNullable(newValue)));

TextField の text プロパティの変化時に highlightpage() メソッドを呼び出しています。
highlightpage() メソッドの実装は次のようになっています。

private void highlightpage(Optional<String> word) {
    Optional.ofNullable(webEngine.getDocument()).ifPresent(document -> {
        final String keyword = word.orElse("");
        webEngine.executeScript("$('body').removeHighlight()");
        if (!keyword.isEmpty()) {
            webEngine.executeScript("$('body').removeHighlight().highlight('" + keyword + "')");
        }
    });
}

WebEngine#executeScript() メソッドを使って、ハイライトを行う JavaScript ライブラリのメソッドをコールしています。

実装は以上です。これで上のスクリーンショットのような機能を実現できます。
JavaScript ライブラリの手助けで割と簡単にできましたね。これも JavaFX の WebView/WebEngine が DOM へのアクセスや JavaScript の実行を行える仕組みを提供してくれているからです。
ただし、この方法だと script 要素を追加して余計な JavaScript ライブラリを読み込ませたり、新たなスタイル定義を追加したりしているので、元々コンテンツがロードしている JavaScript コードやスタイル定義とバッティングする危険性があることに注意してください。 *2
重要度の高いアプリケーションでは JavaScript のスコープなどをより慎重に設計する必要があります。

明日は id:kikutaro777 さんの予定です。

*1:そのうちこれ自身の紹介エントリも書きたいと思っています。

*2:実際いくつかのサイトで不具合を起こしているのを確認しました

JavaFXで丸型ボタンを作ってみる (FXML + CSS + ベクター画像縛り)

はじめに

VAIO Tap 11 を購入したこともあり、Windows8 をよく使うようになったわけですが、Windows8 の Store App を使っていると、次のようなタッチでの利用を意識した丸型で大きめのボタンをよく見かけるようになりました。

で、これと同じようなものを JavaFX でも作ってみようとしました。その際に次のような条件を課すことにしました。

  • FXML と CSS の範囲だけで作る。
    • Java コード側にデザインに関するコードを含めないようにする。
  • アイコンにはビットマップではなくベクターの画像を使う。
    • スケールできるようにしたい。

以下、手順について順に示していきます。

ボタンの作成

まず、ボタンの外形については CSS を使えば簡単に円形にすることができます。
またアイコンについては、JavaFX の Button クラスには graphic プロパティというものがあり、このプロパティに任意の Node を設定することでボタンに画像を表示できるようになります。

というわけでまずは CSS の準備です。ボタンに適用するクラスを作成します。

.circle-button {
    -fx-pref-width: 4.0em;
    -fx-pref-height: 4.0em;
    -fx-background-radius: 2.0em;
    -fx-background-color: null;
    -fx-border-radius: 2.0em;
    -fx-border-color: black;
    -fx-border-width: 2.0px;
}

ポイントは次の通り。

  • background (背景) と border (境界) の両方を調整する必要があります。
  • ボタンの幅、高さと、background、border の角の丸みを調整する radius プロパティを揃えることで円形にします。

さらにマウスオーバー、クリック、無効化の状態に対応するため、hover、pressed、disabled 疑似クラスのスタイルも設定します。

.circle-button:disabled {
    -fx-border-color: rgb(0, 0, 0, 0.7);
}
.circle-button:hover {
    -fx-background-color: #dcdcdc;
}
.circle-button:pressed {
    -fx-background-color: black;
    -fx-border-color: white;
}

無効化の場合は半透明にし、マウスオーバーの場合はうっすらと背景に色をつけるようにしています。クリックされた時には色を反転させています。

このクラスをボタンに適用することで次のような見た目のボタンになります。

アイコンの作成

続いてアイコンに使うベクター画像についてです。JavaFXjavafx.scene.shape パッケージにある各種クラスを利用することでベクター画像を作ることができます。

ですが、凝った画像を Java コードや FXML コードでごりごり書きたくないですよね。 *1
できれば IllustratorInkscape などのベクターグラフィックツールで作成した画像を使ったり、あるいは ネット上でフリーで配布されている素材とかを使いたいところです。

実は JavaFXSVG 形式のパスを取り扱うことができます。先ほど言及した javafx.scene.shape パッケージには SVGPath というクラスがあります。
また、CSS でも Region クラス (Control や Pane などのスーパークラス) には -fx-shape というプロパティがあり、SVG のパス文字列を指定することができます。

以上を踏まえて Scene Builder を使ってベクター形式のアイコン画像を準備する手順について示します。

まず、使いたい SVG 画像をテキストエディタで開き、そのパス文字列をコピーします。

Scene Builder で SVGPath オブジェクトを追加し、その Content プロパティに先ほどコピーしたパス文字列を貼り付けます。すると表示がそのアイコンに変わるはずです。

ボタンにアイコンを設定する

それではボタンに対して準備した画像を設定します。
FXML 上で Button オブジェクトの graphic プロパティに対して先ほど用意した SVGPath オブジェクトを次のように設定すれば OK です。

<Button layoutX="162.0" layoutY="51.0" mnemonicParsing="false" styleClass="circle-button">
   <graphic>
      <SVGPath content="M10,16 10,0 0,8z" styleClass="button-icon-shape" />
   </graphic>
</Button>

これを Scene Builder 上で簡単に行う方法があります。次のように、Hierarchy ビューで Button に対して Shape オブジェクト (ここでは SVGPath オブジェクト) をドラッグアンドドロップすることができ、こうすると、Buttton の graphic プロパティにその Shape オブジェクトが設定されるようになります。

ちなみに CSS では Button クラス (正確には基底クラスの Labeled クラス) に -fx-graphic というプロパティがありますが、このプロパティには URI しか指定できないため、ビットマップ画像のパスを指定するしかありません。

イコン画像もマウスクリックに反応させて色を変えるようにする

これで大体できあがりなのですが、先ほどボタンの疑似クラス設定で、マウスクリック時に色を反転させるように設定したことを覚えているでしょうか?
イコン画像についてもそれに追随させて色を反転させることを考えます。

ここは CSS の機能を利用して実現します。次のように子孫セレクタを使って、クリックされたボタンの子孫に当たる SVGPath に対して、色を反転させる設定が適用されるようにしています。

.circle-button:pressed SVGPath {
    -fx-fill: white;
}

できあがりの例

こんな感じで JavaFXWindows Store App にあるような丸型ボタンを作ることができました。
以下に完成例のスクリーンショットを載せておきます (アイコンの画像は このサイト で配布されているものを使わせてもらいました) 。

この作成例のソースコードは gist にアップしてあります。
https://gist.github.com/aoetk/b5b9a03e1033057224aa

*1:Scene Builder がもうちょっとお絵かきツールとしての機能も強化してくれるといいのですが...。

JavaFXのWindows環境におけるHiDPI対応について調べたことのメモ

先日のVAIO Tap 11レポートのエントリで、こんなことを書いていました。

ただ、高 DPI スケーリングに対応していないアプリケーションが多いことが分かりました。
(中略)
NetBeans はデフォルトだとコンソールの文字が豆粒のようになってしまい、とても悲しかったです (タスクバーの大きさに対するコンソールの文字の大きさに着目) 。

そのうち JavaFX で高 DPI に対応するにはどうすればいいか、調べてみるつもりです。

というでちょっと調べてみましたが、まだいい対応策が見つかっていないので、以下に現在分かっている点だけをとりあえず列挙します。

  • Windows 環境について言えば、JavaFXプログラム内でサイズ指定に使うピクセルは Device Independent ではない
    • つまり、JavaFXのプログラムで指定した数値がそのままスクリーン上のピクセル数になる。
    • なお、Mac では既に Device Independent になっているらしいが、手元に Retina Mac がないので確認できず。
  • Screen クラスには getDpi() メソッドがあり、これを用いて実行環境上の DPI を取得することは可能。これを使ってプログラム上でスケールの調整をすることは可能。
    • とは言え、シーングラフ上の各コンポーネントのサイズを一々設定し直すのは面倒。
    • Java コード上で指定しているピクセルはいいとして、外部 CSS についてはファイルを作り直す羽目になる。
    • ルートのレイアウトコンテナに対して、Scale を設定することでまとめて拡大すれば良いように思われるが、Transform による変形はレイアウト境界に対して影響を及ぼさないため、レイアウトコンテナ上の Node 達がレイアウトコンテナから「はみ出す」ことになってしまう。

というわけで現状 Windows 環境では HiDPI に対応したアプリケーションを作るのは容易ではない感じです。
一番やりやすいワークアラウンドは、CSS のピクセル指定部分をテンプレート化しておいて、まとめて置き換える方法ですかね。もしかしたら単位に em を使えばいいのかな?

とにもかくにも JavaFXWindows でも Device Independent Pixel (DIP) に対応してくれるのが一番なんですけどねえ。
JIRA にもこのようなチケットが上がっていますが、Windows の世界でも高解像度端末が増えてきていますし、早く対処してもらいたいところです。

現在分かっていることはこんなところです。もう少し調べて何か分かったらまたエントリをアップします。

ListViewやTableViewのセルをカスタマイズする方法 (JavaFX Advent Calendar2013 7日目)

このエントリは JavaFX Advent Calendar 2013 の 7 日目のエントリです。前日は蓮沼 (@) さんによる、「e(fx)clipseで作るJavaFXアプリケーション」でした。

はじめに

Twitter やブログなどで JavaFX に関するエントリを見ていると、ListViewTableView のセルの表示をカスタマイズする方法がよく分からずに苦戦している人をよく見かけます。
このようなリスト系コンポーネントの表示をカスタマイズする方法は、実はどの GUI ツールキットでも大体やり方が似ているのですが、HTML を使った UI の開発ばかりをやっている人には馴染みがないかも知れません。
そこで、少し地味ではありますがこの方法について解説します。また、セルのカスタマイズをする際には性能面で注意が必要なポイントがあるので、それについても触れたいと思います。
なお、ListView も TableView も基本的には同じような方法でセルのカスタマイズが行えるので、以降の説明は ListView に絞って行います。

ListView の仕組み

リストのセルの表示をカスタマイズする方法を理解するためには、まずは ListView の仕組みを知っておいた方がいいと思うので、まずはそこから説明します。
まずは次のようなシンプルなリストを見てみましょう。

このリストは 500 件のアイテムを表示しています。大量にアイテムがあるので、大半が隠れていて、スクロールを行う必要があります。
一見すると隠れている領域にもセルのインスタンスがあるかのように思えますね。
ところが、シーングラフの状態を確認することができる、Scenic View を使って見てみると...。

なんと表示されている範囲のインスタンスしか生成されていないのです! (ListCell インスタンスの数に注目してください)
ListView はこのように表示されている範囲のセルのインスタンスだけを作り、スクロールが発生するとセルの表示をスクロール位置に対応したデータに合わせて切り替えることで、あたかも隠れた範囲から新しいセルが入ってきたかのように見せかけているのです。
こうすることで、大量のデータを表示しても性能が落ちないようにしているのです。

セルのカスタマイズ方法

それではセルのカスタマイズ方法について説明しましょう。セルの表示をカスタマイズするには次の 2 つのことを行う必要があります。

  1. カスタムの Cell クラスを作り、表示したいレイアウトを実装する
  2. ListView に対して上で作ったカスタムの Cell クラスを使うように CellFactory を設定する

具体例を示しながら説明していきます。今、自分用にソーシャルブックマークサービスDiigo のビューアを JavaFX で作ろうと考えているのですが、それのブックマークリスト部分を先に作ってみることにします。
セルの表示を次のようにすることを考えてみます。

上から順にタイトル、コメント、タグを表示します。タグはハイパーリンクとして横に並べるようにします。

まずはリストのデータソースとなるモデルを用意します。次のような JavaFX 形式の JavaBean として作ります。

/**
 * ブックマークを表すモデル.
 */
public class BookmarkModel {
    private StringProperty title = new SimpleStringProperty();
    private StringProperty comment = new SimpleStringProperty();
    private ObservableList<String> tagList = FXCollections.observableArrayList();

    /**
     * コンストラクタ.
     * @param title タイトル
     * @param comment コメント
     * @param tagList タグのリスト
     */
    public BookmarkModel(String title, String comment, List<String> tagList) {
        this.title.set(title);
        this.comment.set(comment);
        this.tagList.addAll(tagList);
    }

    public StringProperty titleProperty() {
        return title;
    }

    public StringProperty commentProperty() {
        return comment;
    }

    public ObservableList<String> getTagList() {
        return tagList;
    }
}

このオブジェクトを ObservableList にくるんで ListView にデータとして渡すことになります。

次はカスタムの Cell クラスを作りましょう。

public class BookmarkCell extends ListCell<BookmarkModel> {
    private VBox cellContainer;
    private Text txtTitle;
    private Text txtComment;
    private HBox tagContainer;
    private boolean bound = false;

    public BookmarkCell() { // (1)
        initComponent();
        initStyle();
    }

    private void initStyle() {
        txtTitle.setFont(new Font("System Bold", 18.0));
    }

    private void initComponent() { // (2)
        cellContainer = new VBox(5);
        txtTitle = new Text();
        VBox.setVgrow(txtTitle, Priority.NEVER);
        txtComment = new Text();
        VBox.setVgrow(txtComment, Priority.ALWAYS);
        tagContainer = new HBox(); // (3)
        VBox.setVgrow(tagContainer, Priority.NEVER);
        cellContainer.getChildren().addAll(txtTitle, txtComment, tagContainer);
    }

    @Override
    protected void updateItem(BookmarkModel bookmarkModel, boolean empty) { // (4)
        super.updateItem(bookmarkModel, empty);
        if (!bound) { // (5)
            txtTitle.wrappingWidthProperty().bind(getListView().widthProperty().subtract(25));
            txtComment.wrappingWidthProperty().bind(getListView().widthProperty().subtract(25));
            bound = true;
        }
        if (empty) {
            setText(null);
            setGraphic(null);
        } else { // (6)
            txtTitle.setText(bookmarkModel.titleProperty().get());
            txtComment.setText(bookmarkModel.commentProperty().get());
            tagContainer.getChildren().clear();
            List<String> tags = bookmarkModel.getTagList();
            for (String tagName : tags) {
                tagContainer.getChildren().add(createTagLink(tagName));
            }
            setGraphic(cellContainer);
        }
    }

    private Hyperlink createTagLink(String tagName) {
        Hyperlink hyperlink = new Hyperlink(tagName);
        hyperlink.setTextFill(Color.BLUE);
        return hyperlink;
    }
}

カスタムの Cell は javafx.scene.control.Cell を継承して実装しますが、ListView 向けには javafx.scene.control.ListCell という基底クラスが用意されているので、通常はこれを継承して実装します。 (TableView の場合は javafx.scene.control.TableCell を継承します)
型パラーメータには ListView のデータソースとなるクラスを指定します。今回の場合、先ほど上で作った BookmakrModel を指定することになります。

カスタムの Cell では updateItem() メソッドをオーバーライドします。
このメソッドは初期表示やスクロール時など、セルの表示を更新する必要があるときにコールバックとして呼び出されます。
第1引数には、このセルが表示を担当することになったデータソース (この例では BookmarkModel のインスタンス) が渡されます。第2引数にはこのセルが表示するデータが空であるかが渡されます。(空の場合に true)
第1引数に渡されたデータを使って、セルの表示内容を更新することになります。Cell クラスは Label や Button のスーパークラスである javafx.scene.contorol.Labeled を継承しており、setText() メソッドを使ってテキストを、setGraphic() メソッドを使って任意のグラフィックを表示するために設定することができます。
よって、任意のコンテンツをセルに表示したい場合、表示する Node を作って、この updateItem() メソッド内において、setGraphic() メソッドの引数にその Node を渡せばいいわけです。

ここで1つ注意点があります。それは、updateItem() メソッド内での Node オブジェクトの生成は極力避ける、ということです。
updateItem() メソッドはスクロールを行った場合など、高頻度で呼び出される可能性があります。そのため、このメソッド内で Node オブジェクトの生成を行うと、その負荷はかなりのものとなります。
従って、可能な限り Node オブジェクトの生成は Cell クラスの初期化時に行い、updateItem() メソッドではプロパティやレイアウトの変更にとどめるようにします。

以上を踏まえて今回の実装例について解説します。
まず、Cell クラスのコンストラクタで初期化可能な Node のインスタンスを全て作っています。(1)
実際に Node の生成を行っているのは initComponent() メソッドです。(2)
セルでは上からブックマークタイトル、コメント、そしてタグのリストを並べるのですが、これは VBox を作って配置します。
ブックマークタイトル、コメントの表示には javafx.scene.text.Text を使います。
タグについては BookmarkModel が保持するタグの数に応じて動的に変化するので、これは updateItem() メソッドで生成するようにしていますが、そのタグを水平に並べるための HBox だけをここでは生成して、コンテナとなる VBox に追加しています。(3)

続いて updateItem() メソッドの実装です。(4)
まず、ブックマークタイトルやコメントのテキストがセルの幅に合わせて折り返されるように、親となる ListView の width プロパティと、それぞれの wrappingWidth プロパティをバインドしています。(5)
コンストラクタの中で行わず、最初に updateItem() メソッドが呼び出されたときにこのバインドを実行している理由は、コンストラクタが呼び出される時点ではまだセルが配置される ListView と結びつけられていないからです。
そして、第2引数が false の場合に、引数として渡される BookMarkModel オブジェクトから値を取り出して、Text や HyperLink オブジェクトに設定しています。(6)
今回は少し手を抜いて、updateItem() メソッドの中で HyperLink を生成していますが、さらに性能を重視するのなら、コンストラクタである程度の数のインスタンスを作ってキャッシュしておいた方が良いでしょうね。

それでは、このカスタムのセルを使ってみることにします。まずは FXML から。

<BorderPane id="BorderPane" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="700.0" prefWidth="460.0" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2" fx:controller="aoetk.SampleListViewController">
  <center>
    <ListView fx:id="listView" prefHeight="200.0" prefWidth="200.0" />
  </center>
</BorderPane>

BorderPane の真ん中に ListView を貼っているだけです。続いて Controller の実装です。

public class SampleListViewController implements Initializable {
    public ListView<BookmarkModel> listView;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        listView.setCellFactory(new Callback<ListView<BookmarkModel>, ListCell<BookmarkModel>>() { // (1)
            @Override
            public ListCell<BookmarkModel> call(ListView<BookmarkModel> listView) {
                return new BookmarkCell();
            }
        });
        ObservableList<BookmarkModel> bookmarkModels = createBookmarkModels();
        listView.setItems(bookmarkModels); // (2)
    }

    private ObservableList<BookmarkModel> createBookmarkModels() { // (3)
        final String longComment = "長いコメントのサンプルです。長いコメントのサンプルです。長いコメントのサンプルです。"
                + "長いコメントのサンプルです。長いコメントのサンプルです。長いコメントのサンプルです。長いコメントのサンプルです。"
                + "長いコメントのサンプルです。長いコメントのサンプルです。長いコメントのサンプルです。長いコメントのサンプルです。";
        final String normalComment = "普通の長さのコメント。";
        ObservableList<BookmarkModel> bookmarkModels = FXCollections.observableArrayList();
        for (int i = 0; i < 500; i++) {
            String comment = "";
            if (i % 3 == 0) {
                comment = longComment;
            } else if (i % 3 == 1) {
                comment = normalComment;
            }
            BookmarkModel model = new BookmarkModel(
                    "ブックマークタイトル" + i, comment, Arrays.asList("tag1", "tag2", "tag3"));
            bookmarkModels.add(model);
        }
        return bookmarkModels;
    }
}

initialize() メソッドで ListView にセルの設定を行っています。ListView#setCellFactory() メソッドを使い、セルを生成する CellFactory を設定しています。(1)
CellFactory は、javafx.util.Callback インターフェースを実装したクラスとなります。call() メソッドで、使いたい Cell クラスのインスタンスを返すようにします。
後はデータの設定です。createBookmarkModels() メソッドにて、500個の BookmarkModel のインスタンスを生成して (3) 、そのリストを ListView のアイテムとして設定しています。(2)

これを実行すると次のようにカスタマイズしたセルを使った ListView が表示されます。

いかがでしたでしょうか。ちょっと回りくどいと感じる方もいるかもしれません。ですが、このような作りになっていることでモデルとビューが綺麗に分離できますし、リストの項目数が多くても描画性能へ与える影響を小さくすることができるというメリットがあります。
また上でも述べたように、JavaFX に限らず、GUI アプリケーションではリスト系コンポーネントの表示をカスタマイズしようとしたら、大体これと同じような方法を採ります。なので他の GUI プラットフォームで開発するときにも同じ考え方が通用するので、是非この機会に覚えてもらえたらと。

明日は@さんの予定です。

追記

ソースコード全体は gist にアップしています。ご参照ください。
https://gist.github.com/aoetk/7827455