プログラムdeタマゴ

nodamushiの著作物は、文章、画像、プログラムにかかわらず全てUnlicenseです

JavaFXのControlとSkinとControllerと

 前回のダイアログの話の続きです。前回はダイアログを表示するための機構を作りました。今回はそのダイアログに表示する中身を作っていこうと思います。
 単純なダイアログって、「ダイアログのタイトル」「メッセージの詳細」「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を使ってみた結果です。