前回のダイアログの話の続きです。前回はダイアログを表示するための機構を作りました。今回はそのダイアログに表示する中身を作っていこうと思います。
単純なダイアログって、「ダイアログのタイトル」「メッセージの詳細」「Cancel,No,Yesボタン」から構成されます。気が向いたら「画像」があるぐらいかな。(!とか▲みたいな画像が多いですよね)
それを表示するのに、毎回JavaFX Scene Builderを起動してFXMLを作ってって、さすがに面倒くさい。一つのモジュールというか、クラスというか、とにかく簡単に扱いたい。
そこで、今回は「ダイアログのタイトル」「メッセージの詳細」「Cancel,No,Yesボタン」「画像」これらの要素を持つControlを作ってみました。
MVCモデル
まずはControlを作る前に、ModelとViewとController(頭文字を取ってMVC)のことについてお話しします。でもその前に、正直ControlだとかControllerだとか似た名前で何が何だか………とか私は思いました。JavaDocによると、Controlはユーザーが操作できるシーングラフ中のノードと定義されています。MVCのControllerとControlは関係ないので混合しないようにしましょう。
Controllerはという単語は、JavaFX Scene BuilderでFXMLを作成したとき、なんか知らないけどチュートリアルで〜Controllerクラスを作れって言われたからMyControllerクラスを作って、んで@FXMLが〜onActionが〜、とかやった時に聞いたことあると思います。そのControllerです。
MVCモデルはGUIのプログラム構成を、動作やデータを定義するModel、実際に画面に出力するView、Viewからのアクション(マウスクリックなど)を受け取り処理をするControllerからなります。最も重要なのはModelで、こいつがいろいろ計算したり、データ保持したり書き換えたりと言った重要な仕事をします。次に重要なのはViewで、Modelから表示するべき内容等のGUI処理の基本を担います。Controllerは、ほんっとどーでもいいやつで、何も仕事しません。Controllerの役割はユーザーからの入力の処理に相当するModelのメソッド、もしくはViewのメソッドを呼び出すために、入力を適切な形に変換することにあります。よくあるFXMLのControllerのチュートリアルって、やたらControllerが機能持ってて、処理はControllerですれば良いんだーって感じだけど、あれおかしいと思います。何かするのはModelです。
じゃぁ、そのModelはJavaFXでは何になるのかというと、Nodeになります。特に、ユーザーが何らかの操作ができて、複雑なデータを保持するようなものはControlが行います。
で、最後にViewはどこにあるのかっていうと、たぶんJavaFXには我々がいじれる範囲ではほとんど見えてないんだと思います。Canvasを使って自前でレンダリングするというならViewになるかも。CSSやSkinがViewだっていう説明をどっかで読んだのですが(出典不明)、それっておかしくない?だって、CSSはViewの動作を間接的に定義しているからまだしも、Skinが返すのはNodeだから、SkinがViewならNodeはGUIになっちゃうよ。Nodeが保持するデータやNodeそれ自体は、ViewがGUIを構築するためのデータと考えるのが良いのかも。私はSwingの癖でNode=GUIと考えていたので、最初どうもJavaFXが受け入れられなかったです。
Controlを作る
MVCモデルを説明したところで、ようやくControlを作っていきましょう。実装しなくてはいけないことは以下の二つ。
Model:ダイアログのタイトル、メッセージの詳細、画像などのデータの保持、キャンセル、yes、noの動作、Nodeの構築
Controller:Cancel,Yes,Noボタンのアクションを受け取り、Modelのメソッドをよぶ。
まずはModelから見ていきましょう。
ModelはNDialogというクラスに実装することにしました。
package nodamushi.jfx.scene.dialog; public class NDialog extends Control{ public NDialog(){ getStyleClass().add("ndialog");//CSSのクラスはndialogにしました。 //TODO データの初期化 //TODO 子要素のNodeの作成(LabelやらButtonやら) } public void cancel(){ //TODO cancelの実装 } public void yes(){ //TODO yesの実装 } public void no(){ //TODO noの実装 } //--------------data field------------------- //TODO データの作成 //--------------end data--------------------- }
データや振る舞いの実装
保持するデータをPropertyで保持しておくと、Ovserbalパターンを使ったバインディングの仕組みにより、Modelの変更をViewやそのほかのNodeに通知する必要がなくなり便利です。一方、毎回以下の様に
private IntegerProperty numberprop = new SimpleIntegerProperty(this,"number"); public int getNumber(){return numberprop.get();} public void setNumber(int value){prop.set(value);} public IntegerProperty numberProperty(){return numberprop;}
長ったらしいコードを書かないといけないので滅茶苦茶面倒くさいです。読み込みだけで、他クラスからはsetできないようなReadOnly〜Propertyはさらに記述が面倒くさいです。
私は愛用しているEclipseたんにProperty作成の便利機能が揃っていないので、PowerShellで「fxprop int writable number」とコマンドを実行すると、上記の内容を作成し、クリップボードにコピーするようにしてプロパティを作っています。
ReadOnlyPropertyに関してはインナークラス作るの面倒くさいので、私の場合はnodamushi.jfx.beanにReadOnlyPropertyが簡単に作れるようなファクトリを作成しています。わざわざ自作しなくてもReadOnly〜PropertyWrapperクラスを使っても問題ありません。
private ReadOnlyStringPropertyFactory namepropfactory = new ReadOnlryStringPropertyFactory(this,"name"); private void setName(String value){namepropfactory.setValue(value);} public String getName(){return namepropfactory.getValue();} public ObjectProperty<String> nameProperty(){return namepropfactory.get();}
もち、「fxprop String readonly name」だけで上記の内容がクリップボードにコピーされるようにしています。
なお、わざわざ私がファクトリを作った理由は、マルチスレッドに対応させることと、わざわざPropertyをベースにしてWrapするのって無駄と思ったからです。本当はSimple〜Propertyのマルチスレッド版も作ろうかと思ったけど、面倒くさかったなりぃ。
とにかく、上記のようなコードを何個も書いてデータを作成していきます。データは、表示する文字や結果、ボタンが有効か無効かのプロパティ等々。無駄に長いのでコードは省略。(NDialog.javaに全て書いてあります。)
つぎに、cancelやらのメソッドを実装をします。cancelやyesなどのメソッドが呼ばれたら、結果を格納する他にも、前回話したようにダイアログを閉じる必要があります。前回、その作業はDialogModelというインターフェースにDialogCloseFunctionを渡すことで実装するとしたので、NDialogにDialogModelを実装します。
public class NDialog extends Control implements DialogModel{ private DialogCloseFunction func; @Override public void setDialogCloseFunction(DialogCloseFunction func){ if(this.func!=null)throw new RuntimeException( "setDialogCloseFunction:closeDialogが呼ばれる前に再設定は出来ません"); this.func = func; } @Override public void closeDialog(){ if(func!=null){ func.closeDialog(); func=null; } } public void cancel(){ //※結果を格納する為にResultという列挙型を定義しています setResult(Result.CANCELLED); closeDialog(); } public void yes(){ setResult(Result.YES); closeDialog(); } public void no(){ setResult(Result.NO); closeDialog(); }
Skinの実装
後は子要素の作成をすればModelは完成です。頑張りましょう。
子要素の作成はここまでのModelのデータや振る舞いの実装と違って、見た目を構築していくかなりView寄りの話です。なのでこのままNDialogの中にその実装までしてしまうと、かなりちぐはぐな印象のクラスができあがってしまいます。そこで、Controlには子要素の構築に関してはSkinを実装したクラスに委任する仕組みがあります。Skinとして実装するメリットはすっきりするという以外にも、Skinを変えるだけでModelの変更はしなくても見た目を変えることが出来るなどがあります。ここではNDialogSkinクラスで実装することにします。
private static final String SKINCSS = String.format("-fx-skin:'%s';", NDialogSkin.class.getName()); //↓コンストラクタ public NDialog(){ getStyleClass().add("ndialog"); //SkinのFQCNをCSSで登録しておきます。 setStyle(SKINCSS); }
Skinの設定にはCSSを使います。ただ、JavaFX8からはcreateDefaultSkin()というメソッドが定義されているので、そちらを使った方が良いと思います。なお、setSkinClassNameというメソッドもありますが、これは使わない方が良いです。Skinを作るにはどうしてもSkinに自分を渡す必要があります(setSkinClassNameで指定したSkinはコンストラクタがSkinnableを受け取れる必要がある)。ということは、コンストラクタ内でSkinを作ろうとするとどうしても、コンストラクタが完了していない未完成なオブジェクトをSkinに渡すことになってしまいます(setSkinClassNameは呼び出したときにSkinインスタンスを作ります)。コンストラクタからthisを漏らすってバグの温床です。色々怪しいですね。一方CSSで指定した場合は、表示されてない段階でCSSの解析が行われないようなので、コンストラクタ中でSkinが作られることはありません。後は、クラスを継承したときに、無駄にSkinインスタンスを作られないというメリットもありますね。
で、メインのSkinの実装ですが、今回は面倒くさぁいということで、全部JavaFX Scene Builderのダイアログテンプレートを、ちょっとIDとかclass名とか変更して作ったFXMLに任せてしまいました。FXMLを使わずに実装するとなると結構面倒だね。その面倒を解消する為か、JavaFX8からはSkinBaseってのが提供されるみたいです。
public class NDialogSkin implements Skin<NDialog>{ private NDialog skinnable; private Parent contents; private static String CSS=NDialogSkin.class.getResource("ndialog.css").toExternalForm(); //コンストラクタの引数はSkinnable(=Controlとか)です。 public NDialogSkin(NDialog nd){ skinnable = nd; FXMLLoader loader = new FXMLLoader(NDialogSkin.class.getResource("ndialog.fxml")); //Controllerの作成 FXMLで設定するとFQCNが変わったときに面倒くさいからこっちの方が良いかと loader.setController(new NDialogController(nd)); try { contents = (Parent)loader.load(); } catch (IOException e) { e.printStackTrace(); } nd.getStylesheets().add(CSS); } @Override public NDialog getSkinnable(){ return skinnable; } @Override public Node getNode(){ return contents; } @Override public void dispose(){ contents = null; } }
Controllerの実………そ………う?
最後に、FXMLLoaderで作ったParentのイベントを受け取る為のControllerを作れば自作Controlは完成だ!
JavaFX1からの流れなのか、Controllerではなく、〜Behaviorというクラス名で実装されていることが多いです。
今回は内容が非常に単純なので、キャンセルボタンが押されたらNDialogのcancel()を、イエスボタンが押されたらyes()を、ノーボタンが押されたらno()を呼びだすということを実装するだけです。本来は。
public class NDialogController implements Initializable{ @FXML public Label messageLabel,titleLabel; @FXML public Button cancelButton,noButton,yesButton; @FXML public ImageView imageView; private NDialog ndialog; public NDialogController(NDialog d){ndialog=d;} @Override public void initialize(URL location ,ResourceBundle resources){ // 本来はSkinで実装すべき内容 messageLabel.textProperty().bind(ndialog.messageProperty()); titleLabel.textProperty().bind(ndialog.titleProperty()); cancelButton.textProperty().bind(ndialog.cancelTextProperty()); //……こんな感じでbind,bind,bindしまくっていきます(省略) // Skinの内容終わり cancelButton.setOnAction(new EventHandler<ActionEvent>(){ public void handle(ActionEvent event){ ndialog.cancel();} }); noButton.setOnAction(new EventHandler<ActionEvent>(){ public void handle(ActionEvent event){ndialog.no();} }); yesButton.setOnAction(new EventHandler<ActionEvent>(){ public void handle(ActionEvent event){ndialog.yes();} }); } }
うん、何故かControllerでBindの設定をしまくっていますねー。おかしいですねー。でもFXMLLoaderを使うんだったら、こうならざるを得ないと思いますねー。面倒くさいですからねー。妥協ですねー。
あと、onActionの設定をFXMLでしていないのは、単にメソッド名を考えるのが面倒くさいからです。リファクタリングすると使えなくなるし。
CSSの拡張
JavaFX2.2の段階では公開になっていないので使えませんが、CssMetaDataやStyleablePropertyを使えば独自にCSSの拡張が行えるみたいです。(現在はcom.sunパッケージに)
これで、自作Controlクラスの完成です。まとめると、Controlを作るときはデータや振る舞いを定義する基本Modelクラスを作り、Modelの子要素を構築するSkinを作り、Skinに対応するControllerを作るという流れになります。こう書くと、確かにSkinがViewっぽいけど、やっぱりど〜〜も納得いかない。Nodeの木構造の構築はModelの操作なのか、Viewなのか、はっきりして欲しいぜよ。
まぁ、そんな細かいことはどーでもいいとして、この記事で一番重要なことは、Controllerにコードいっぱい書くな!みっともない!って事です。(え?マジで?)
それでは、ここまで読んでくださった方お疲れ様でした。今回のコードはnodamushi.jfx.scene.dialogで全て公開してあります。
↓適当なテストクラスを作ってNDialogを使ってみた結果です。