ブログトップ 記事一覧 ログイン 無料ブログ開設

プログラムdeタマゴ

2014/10/12

JavaFXのInvalidationListenerやChangeListenerやObservableListやBindingについて

 いい加減、何となく動作してるからいいやじゃなくって、中で何やってるのか理解しておくかと、ソースコード読んできたのでまとめておくよ。



InvalidationListenerとChangeListener

 ついさっきまで挙動の差とか、全く理解していなかったほど、違いがよく分からんこの二つ。ソースコードを読んでも差が分からなかったので櫻庭さんに解説していただいた。



 というわけで、どうやらInvalidationListenerとChangeListenerの明確な違いは以下の所にある模様。

//DoublePropertyBaseのmakrInvalidとgetメソッド
    private void markInvalid() {
        if (valid) {//←ここと
            valid = false;//←ここと
            invalidated();
            fireValueChangedEvent();
        }
    }

    @Override
    public double get() {
        valid = true;//←これ
        return observable == null ? value : observable.get();
    }



 つまり、getを呼び出さない限り、2回目以降のfireValueChangedEventは呼び出されない

 で、私がInvalidationListenerとChangeListenerの違いが分からなかった理由は次のコード。



//ExpressionHelper.GenericのfireValueChangedEventメソッドの一部抜粋

//InvalidationListenerの呼び出し
for (int i = 0; i < curInvalidationSize; i++) {
  curInvalidationList[i].invalidated(observable);
}

//ChangeListenerの呼び出し
if (curChangeSize > 0) {
  final T oldValue = currentValue;//currentValueはフィールド変数

  //***↓ここでvalidがTrueになる**
  currentValue = observable.getValue();

  final boolean changed = (currentValue == null)? (oldValue != null) : !currentValue.equals(oldValue);
  if (changed) {//値が変更されたときだけ呼び出す
    for (int i = 0; i < curChangeSize; i++) {
      curChangeList[i].changed(observable, oldValue, currentValue);
    }
  }
}

 櫻庭さんの言うChangeListenerを登録しているからInvalidationListenerも呼び出されるというのは、ChangeListenerを呼び出す前にgetを呼び出すからということみたい。

 違いが細けぇっ( ゚д゚)

 ということは、InvalidationListenerのみの場合でも、毎回getをしてしまえば二つの呼び出しに差がなくなる。テストするときなんかにSystem.out.println(get)なんてやっていると私のように何の違いがあるのかさっぱり分からん!となる。「変化した」というフラグだけ残しておいて、実際に処理するのは描画直前とか、そういう使い方をしたい場合はInvalidListenerの方が効率的という事の模様。

 ちなみに、InvalidationListenerもしくはChangeListenerのどちらか一つしかないような場合は、GenericじゃなくてSingle〜っていう名前のクラスで特別に場合分けされている。一つのリスナしか登録しないなんて場面は多々あるから、合理的だね。



ObservableListとInvalidationListenerとListChangeListener

 JavaFXで使う機会の多いのがFXCollections.observable(Array)Listかな。observableListでは、渡されたListをラップするOvservableListを返す。従って、渡したリストの中身を変更すると、ObservableListの中身も変わる。作られるObservableListの種類は、Listがランダムアクセスに対応しているかどうかで場合分けされている。

 さて、ObservableListの変更がリスナに通知されるのはやっぱり、fireValueChangedEventメソッドだ。ただし、今回はListListenerHelperというクラスのfireValueChangedEventメソッドだが。あと、追加削除等の変更から、実際に通知を行うところまでソースコードを読もうとすると結構長い道のりを辿る。割と面倒くさかったけど、ざっくりというと、配列のいったいどこからどこまでが変更されたのかを記録するクラス(ListChangeBuilder)があって、そいつにここ変更したよとか、終わったよと通知することで、今までの変更を勝手に処理してくれてChangeを作成し、fireValueChangedEventを呼び出してくれる。で、そのfireValueChangedEventの中身を以下に示す。

for (int i = 0; i < curInvalidationSize; i++) {
   curInvalidationList[i].invalidated(change.getList());
}
for (int i = 0; i < curChangeSize; i++) {
    change.reset();
    curChangeList[i].onChanged(change);
}

 

 結論としてはListChangeListener使っとけって話ですね。change.reset()は何もしないか、単に内部のカウンタを-1にセットするだけなので重たい処理ではありません。なので、色々情報取得できるListChangeListenerを使っておけば何の問題もないと言うことですね。普通のObservableとどっちも同じリスナで監視したいときなんかはInvalidationListenerの方が良いのかな?

 あと、注意点としてはfireValueChangedEventはaddやaddAllを呼び出すたびに実行される。中身のArrayListは十分な容量取ってるから一個一個追加していってもメモリ的に無駄がないはずと思っていても、結構無駄が多い。基本的には、他のArrayListや配列にいったん保存しておいてから、addAllで一発で全部追加するのが効率が良さそうだ。(個人的には、ObjectListenerにbeginChangeとendChageを追加してくれれば良いと思ってる。)


Binding

 Bindingって何となく使ってるけど、いったい何で変化を検出してるのか、いつ計算するのか、GCの動きとかどうなってるのかよく理解してないよね。

 まず、イベントの検出の仕方はInvalidationListenerを使っている。つまり、値が変化したかどうかは見ていない。さて、気になるメモリ管理だが、このInvalidationListenerではWeakReferenceを用いてインスタンスの保持をしている。つまり、Bindしている状態であっても、GCの動作を阻害しない

//BindingHelperObserverの一部抜粋
private final WeakReference<Binding<?>> ref;
public void invalidated(Observable observable) {
  final Binding<?> binding = ref.get();
  if (binding == null) {
     observable.removeListener(this);
  } else {
     binding.invalidate();
  }
}



 refの中にバインドされている対象が入っている。(=refの内容がobservableの値によって変化する。) バインドされている対象がGCで消されてしまったら、自動的にリスナの登録を解除してくれる。つまり、Bindしたものに関しては基本的に自分でunbindしなくても大丈夫と言うことだね。

 で、observableの内容が変化した(可能性がある)場合には、上のソースコードで見て分かるように、invalidateメソッドを呼び出す。これが何をするかというと、変更されたというフラグを立てるだけで、値の再計算は実行されない。実際に値が再計算されるのはgetメソッドを呼び出したときになっている。

//DoubleBindingのgetメソッド
    @Override
    public final double get() {
        if (!valid) {//変化フラグが立っているかどうか
            value = computeValue();//値の再計算及び保持
            valid = true;//フラグを消す
        }
        return value;
    }

 実際にgetメソッドを呼び出さない限りはcomputeValueは呼び出されないので、無駄な計算コストを支払うことはない。つまり、いつgetを呼び出すのかということが重要になってくる。変更されるたびに必要もないのにとりあえずgetしていると、無駄な計算コストを支払うことになる。描画直前とか、本当に必要なときだけ呼び出すようにすると良さそう。


 JavaFXの何かよく分からんな〜と放置していたブラックボックスがだいぶん見えてきました。やっぱり、中の挙動が分かってる方が、プログラム書いてるときも安心できるよね。

2014/10/11

JavaFXのChartでMinorTickでも線を描く

 JavaFXのChartって使いにくいなこんちきしょう。グラフ内部のマウス座標とか、全然取れないし 。(グラフ内部というのは、本当にグラフの表示領域内部だけでの座標ね。あと、根性で取れるようにしたよ、ちきしょう。Javaのバージョンアップとかで、もしかしたら駄目になるかも。)

 そんななか、私を丸1日悩ませたのは、MinorTickにおける背景の線を描けないこと。ちょっと特殊な目的でMinorTickでも線が欲しい。(下図参照。通常では線が描画されるのはMajorTickだけ。しかし、MajorTickの数は増やしたくない。)

 

 f:id:nodamushi:20141011104337j:image



 まず、前提条件として、AxisはValueAxisを拡張したクラス(NumberAxis)を利用するとします。また、たぶんどのChartでも同じように出来ると思いますが、今回はLineChartを対象とします。

 最初に、MinorTickの線を引くには、MinorTickがどこに表示されているのか知る必要がある。それはValueAxisのcalculateMinorTickMarksで取得が可能だ。

 しかし、calculateMinorTickMarksは可視性がprotectedで見えない。さらに困ったことに、NumberAxisはfinal宣言されてて、可視性の上書きも出来ない。

 というわけで、こうしました↓。

package javafx.scene.chart;//←これ
import java.util.List;

public class CallProtectedMethod{
  
  public static <T extends Number> List<T> calculateMinorTickMarks(ValueAxis<T> axis)
  {
    return axis.calculateMinorTickMarks();
  }
}

 javafx.scene.chartパッケージ下で新たにクラスを作成して、それを介して無理矢理呼び出します。

 今回はわかりやすい名前にしたけど、絶対に衝突しないような名前にしとくと良いでしょう。



 で、次に背景に線を引く処理ですが、困ったことにXYChartで実際に線を引いている背景の領域というのは取得できない。一応、内部の構造を理解した上で、子ノードを辿り、取得は出来るが、その座標系は表内部だけの座標系にされていないので、細かい調整がよく分からん。ていうか、本来ブラックボックスのはずの内部処理を理解した上での実装だから、バージョンアップでちょっと処理を書き換えられたらすぐに使えなくなる。それはしたくない。

 そこで、妥協としてグラフの値を描画する領域(Group)において、グラフの線を描く前に描画をします。線の更新処理はlayoutPlotChildrenの処理に追加することで実装します。layoutChildrenだと、Axisの大きさがまだ設定されていないので上手くいかない。いや〜、layoutPlotChildrenがfinal宣言されてなくて良かった。

 グラフの表示領域の大きさと、Axisの長さが同じに設定されているので、線の長さはAxisの長さと同じにすれば良い。次に、線の位置はAxisのgetDisplayPositionから取得できる。以上をまとめると次のようになる。

package nodamushi.jfx.chart;

import java.util.*;

import javafx.collections.ObservableList;
import javafx.scene.chart.*;
import javafx.scene.shape.*;

public class LineChart2<X extends Number,Y extends Number> extends LineChart<X, Y>{
  private Path xMinorPath,yMinorPath;
  
  public LineChart2(ValueAxis<X> xAxis,ValueAxis<Y> yAxis){
    super(xAxis,yAxis);
    init();
  }
  
  public LineChart2(ValueAxis<X> xAxis, ValueAxis<Y> yAxis,
      ObservableList<javafx.scene.chart.XYChart.Series<X, Y>> data){
    super(xAxis, yAxis, data);
    init();
  }
  
  private void init(){
    Path xMinorPath = new Path();
    Path yMinorPath = new Path();
    this.xMinorPath=xMinorPath;
    this.yMinorPath=yMinorPath;
    //グラフよりも先に描画されるように
    //0に挿入します
    getPlotChildren().addAll(0, Arrays.asList(xMinorPath,yMinorPath));
    //CSSの設定
    xMinorPath.getStyleClass().add("chart-vertical-grid-lines");
    yMinorPath.getStyleClass().add("chart-horizontal-grid-lines");

  }
  
  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Override
  protected void layoutPlotChildren(){
    super.layoutPlotChildren();
    final ValueAxis 
    xa = (ValueAxis)getXAxis(),
    ya = (ValueAxis)getYAxis();
    final Path 
    xpath = this.xMinorPath,
    ypath = this.yMinorPath;
    final List<Number> 
    xminor = 
    CallProtectedMethod.calculateMinorTickMarks(xa),
    yminor = 
    CallProtectedMethod.calculateMinorTickMarks(ya);
    
    xpath.getElements().clear();
    double h = ya.getHeight();
    for(Number o :xminor){
      double display = xa.getDisplayPosition(o);
      xpath.getElements().addAll(
          new MoveTo(display,0),
          new LineTo(display,h)
          );
    }
    
    ypath.getElements().clear();
    double w = xa.getWidth();
    for(Number o :yminor){
      double display = ya.getDisplayPosition(o);
      ypath.getElements().addAll(
          new MoveTo(0,display),
          new LineTo(w,display)
          );
    }
  }
}


 で、実際に使ってみると以下のように描画されました。問題なさそうですね。

f:id:nodamushi:20141011104430j:image

 もっと良い方法があるという場合は是非教えてください。

2013/10/12

JavaFXで柔軟なキーバインドを作ろうとしたけど挫折した話

動機と目的

 みなさんはキーボード何使ってますか?私は東プレのRealForceを………という話ではなくて、JISキーボードですか?USキーボードですか? US!US!なギーク(笑)には関係ない話ですが、私はJISキーボードです。前の研究室はUSキーボードだったので両方使えますが、JISのがしっくりきます。

 で、JISは世界の標準ではないので、色々問題起こります。

 「Ctrl+@」

 このキー操作を定義しようとしたときに問題が発生しました。我々JIS民は@って@を押せば出るんですよ。でも世界民はShift+2なんですよね。で、世界民はJIS民が@と呼ぶキーは[と呼ぶんですよね。

 なるほど、じゃぁ、JavaFXも@は[と呼ぶから困ると私は言いたいのか。残念ながらもっと面倒で、何故か`と判定されました。KeyCodeにATって定義されてるくせに…

 これだと、ユーザーが柔軟にキーバインドを定義するようなアプリケーション作れねーじゃん、ということで、emacsのキーバインドのように柔軟な表現で表せるようにしようと思ったわけです。柔軟な、というのは例えばJISで「C-S-@」または「C-`」は「Control+Shift+@」であり、USなら「C-S-@」は「Control+Shift+2」である等、柔軟な対応がしたいということです。



断念した実装

 キー入力「Ctrl+@」をKeyCodeという列挙型で判断しようとすると、Ctrl+バッククォートになる為、KeyCodeのみに頼ることは出来ない。しかし、KeyEventのgetText()を利用すると、このKeyCodeはバッククォートでも「@」という文字列が返ってくる。これとKeyCodeの二つ両方利用して何とかしようとした。

 これでかなり柔軟に対応するところまでは出来ました。しかし、「Ctrl+Shift+@」というショートカットを定義することが現状できないという結論に至りました。Shift+@によりgetText()の値は「`」になり、@かどうか判別することが出来ない為です。

 キーボードがJISなのか、USなのか(はたまた別の言語なのか)判断して処理を分けれればいいのですが、私にはやり方分からないし、っていうか分かっても全部やるとか面倒くさくてあり得ないし。

 というわけで、私のもくろみは散ってしまったとさ。


 でもまぁ、使い方を気をつければ、JavaFXのKeyCombinationよりは柔軟な物を作ることは出来たんじゃないかな。↓

package nodamushi.event;

import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;

public class MyKeyCombination implements Cloneable{
    
    //修飾キーの値
    private static final int CONTROL=1,ALT = 1<<1,META = 1 << 2,SHIFT = 1 << 3;
    //よく分からんキーのコード
    private static final int UndefinedKeyCode = -10000;
    
    
    private static int getModifiers(boolean control,boolean alt,boolean meta,boolean shift){
        return (control?CONTROL:0) | (alt ? ALT :0) | (meta?META:0) | (shift? SHIFT:0);
    }
    
    private int modifiers;
    private int keycode=UndefinedKeyCode;
    
    
    
    public boolean match(MyKeyCombination kc){
        return modifiers==kc.modifiers&&keycode==kc.keycode;
    }
    
    @Override
    public boolean equals(Object obj){
        return (obj==this)?true:(obj instanceof MyKeyCombination)? match((MyKeyCombination)obj):false;
    }
    
    //for clone
    private MyKeyCombination(int modifiers ,int keycode){
        this.modifiers=modifiers;
        this.keycode=keycode;
    }
    
    public MyKeyCombination(boolean control,boolean alt,boolean meta,boolean shift,String key){
        if(key.length()==1){
            int ch = key.charAt(0);
            keycode = ch;
            if('A'<=ch && ch<='Z'){
                shift = true;
                keycode = ch+'a'-'A';
            }
            modifiers = getModifiers(control, alt, meta, shift);
        }else{
            modifiers = getModifiers(control, alt, meta, shift);
            switch(key.toLowerCase()){
                case "enter":
                    keycode = 13;break;
                case "tab":
                    keycode = 9;break;
                case "space":
                case "sp":
                    keycode = 32;break;
                case "pageup":
                case "pgup":
                case "pup":
                    keycode = -33; break;
                case "pagedown":
                case "pgdown":
                case "pdown":
                    keycode = -34; break;
                case "end":
                    keycode = -35; break;
                case "home":
                    keycode = -36;break;
                case "left":
                    keycode = -37;break;
                case "up":
                    keycode = -38;break;
                case "right":
                    keycode = -39;break;
                case "down":
                    keycode = -40; break;
                case "f1":
                    keycode=-112;break;
                case "f2":
                    keycode=-113;break;
                case "f3":
                    keycode=-114;break;
                case "f4":
                    keycode=-115;break;
                case "f5":
                    keycode=-116;break;
                case "f6":
                    keycode=-117;break;
                case "f7":
                    keycode=-118; break;
                case "f8":
                    keycode=-119;break;
                case "f9":
                    keycode=-120;break;
                case "f10":
                    keycode=-121;break;
                case "f11":
                    keycode=-122;break;
                case "f12":
                    keycode=-123;break;
                case "f13":
                    keycode=-124;break;
                case "f14":
                    keycode=-125;break;
                case "f15":
                    keycode=-126;break;
                case "f16":
                    keycode=-127;break;
                case "f17":
                    keycode=-128; break;
                case "f18":
                    keycode=-129;break;
                case "f19":
                    keycode=-130;break;
                case "f20":
                    keycode=-131;break;
                case "f21":
                    keycode=-132;break;
                case "f22":
                    keycode=-133;break;
                case "f23":
                    keycode=-134;break;
                case "f24":
                    keycode=-135;break;
                default:
                    keycode = UndefinedKeyCode;
                    modifiers=0;
                    break;
            }
        }
    }
    
    public boolean match(KeyEvent event){
        KeyCode code = event.getCode();
        String text = event.getText();
        boolean isC=event.isControlDown();
        boolean isA=event.isAltDown();
        boolean isS=event.isShiftDown();
        boolean isM=event.isMetaDown();
        int modifiers,keycode=UndefinedKeyCode;

        if(code.isModifierKey() || code == KeyCode.UNDEFINED){//shiftなど
            return false;//常にfalse
        }
        
        if(code.isLetterKey()){//a-z
            modifiers = getModifiers(isC, isA, isM, isS);
            keycode = code.ordinal()-KeyCode.A.ordinal()+'a';
        }else if(code.isFunctionKey()){//F1,F2,F3....,F24
            keycode = -112 - (code.ordinal()-KeyCode.F1.ordinal());
            modifiers = getModifiers(isC, isA, isM, isS);
        }else if(code.isNavigationKey()){//homeや矢印など
            if(code.isArrowKey() && code.isKeypadKey()){//キーパッドの矢印
                keycode = -33 - (code.ordinal()-KeyCode.KP_UP.ordinal());
            }else{//それ以外
                keycode = -33 - (code.ordinal()-KeyCode.PAGE_UP.ordinal());
            }
            modifiers = getModifiers(isC, isA, isM, isS);
        }else if(code.isWhitespaceKey()){//enter,tab,space
            switch(code){
                case ENTER:
                    keycode = 13;break;
                case TAB:
                    keycode = 9;break;
                case SPACE:
                    keycode = 32;break;
            }
            modifiers = getModifiers(isC, isA, isM, isS);
        }else if(code.isMediaKey()){//音量上げ下げのボタンとからしい
            //よく分からんから現状放棄
            return false;
        }else if(code.isDigitKey()){//数値
            if(code.isKeypadKey()){
                keycode = '0'+(code.ordinal()-KeyCode.NUMPAD0.ordinal());
            }else{
                keycode = '0'+code.ordinal()-KeyCode.DIGIT0.ordinal();
            }
            modifiers = getModifiers(isC, isA, isM, isS);
            if(keycode==this.keycode && modifiers==this.modifiers)
                return true;
            
            //↓例外処理。例えばShift+1はjisでは「!」など数字ではない。
            if(text.length()==1 && keycode!=text.charAt(0)){
                isS=false;
                keycode = text.charAt(0);
            }
            modifiers = getModifiers(isC, isA, isM, isS);
            return keycode==this.keycode && (modifiers|SHIFT)==(this.modifiers|SHIFT);
        }else{//わからん
            
            //TODO ここどーしよう。
            //現状の問題
            //たとえば、jis配列でCtrl+Shift+@を判定したい場合
            //KeyCodeはback_quoteが返ってくるし、textは`になってしまっている
            //要するに、Shift+@は取得できない
            
            
            
            //(↑が実装できた上での話)
            //↓マッチしなかった場合、Shiftにより文字が変わっていることを考慮する
            int tlength = text.length();
            if(tlength == 1){
                char ch = text.charAt(0);
                keycode = (int)ch;
                modifiers = getModifiers(isC, isA, isM, isS);
                return keycode==this.keycode && (modifiers|SHIFT)==(this.modifiers|SHIFT);
            }else{
                //それでも判別できない物は扱わない。
                return false;
            }
        }
        
        
        return keycode==this.keycode && modifiers==this.modifiers;
        
        
    }
    
    public MyKeyCombination clone(){
        return new MyKeyCombination(modifiers, keycode);
    }
}

2013/09/11

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を使ってみた結果です。

f:id:nodamushi:20130912052643j:image

2013/09/10

JavaFXでのダイアログ作成

 だいぶJavaFXが分かってきたので、ちょいとまとめ。

 JavaFXでダイアログ表示するのって面倒くさいな〜って思って、自分で使う分には簡単に作れるようなAPIを整えていました。単にダイアログって言っても、ウィンドウ形式でポップアップするダイアログの他に、モダンブラウザで実装されているJavaScriptのalertがウィンドウ内部だけで表示されるタイプのダイアログなどがあります。その違いをなるべく気にしないでいいように実装してみました。全部のソースコードはGithubにおいています。https://github.com/nodamushi/NodamushiJFXUtilities

 言葉よりも図を示した方が良いですね↓

f:id:nodamushi:20130910103418j:image:w360

 左はウィンドウ形式のダイアログ、右はインナーウィンドウ形式のダイアログです。



 ウィンドウ形式はStageを、インナーウィンドウ形式は渡されたStackPaneの一番上に表示させる形で表示します。ダイアログなので、閉じるまで待機するという処理がしたいので、作るプログラムのおおよその形は以下の様になります。(importは常に省略します)

//↓次以降のコードはpackageも省略します。
package nodamushi.jfx.scene.dialog;

public final class DialogFactory{
    /**
     * ウィンドウ形式のダイアログを表示
     * @param contents 表示する内容
     * @param parentWindow ダイアログの親ウィンドウ
     * @param x ダイアログの表示位置のx座標
     * @param y ダイアログの表示位置のy座標
     * @param isWait ダイアログが閉じるまで待機するかどうか
     */
    public static void showWindowDialog(Parent contents,
            Window parentWindow,double x,double y,boolean isWait){
        //TODO 表示の為にStageを作る
        //TODO isWaitがtrueの場合はStageが閉じるまで処理を中断する
    }

  /**
     * インナーウィンドウ形式のダイアログを表示
     * @param contents 表示する内容
     * @param parentPane ダイアログを表示させるStackPane
     * @param isWait ダイアログが閉じるまで待機するかどうか
     */
    public static void showInnerDialog(Node contents,
            StackPane parentPane,boolean isWait){
        //TODO 表示の為に、全てのイベントを奪取するPaneを作成し、contentsを入れる
        //TODO isWaitがtrueの場合はPaneをparentPaneから取り除くまで処理を中断する
    }

    //ユーティリティークラスなので、インスタンスは作りません。
    private DialogFactory(){}
}



 実際にはJavaFXのスレッド以外から呼ばれても正常に動作するようにしてたり、特定のNodeの上にウィンドウ形式のダイアログを表示できるようにしたりと色々してるのですが、今回は全部省略します。

 さて、まず困るのはダイアログを閉じるという処理です。通常、ウィンドウの×ボタン以外にも、OKボタンやキャンセルボタンなどを押してもダイアログは閉じます。今回、ウィンドウ形式もしくはインナーウィンドウ形式の二つで、どちらも閉じ方が異なります。それらの違いをコントローラに任せるのは酷いというものです。なので、閉じるという処理はこちらでメソッドを実装し、そのメソッドを呼んでもらうことで違いを吸収します。

//ダイアログを閉じることを定義するインターフェース
public interface DialogCloseFunction{
    /**
     * ダイアログを閉じます。
     * このメソッドを呼び出した後はこのオブジェクトは動作をしません。
     */
    public void closeDialog();
}

//ダイアログの動作を定義するインターフェース
public interface DialogModel{
    /**
     * ダイアログを閉じる操作を実装したfuncを保持してください。
     * このメソッドはDialogFactoryからダイアログが表示されるたびに呼び出されます。
     * キャンセルボタン、OKボタン等により、ダイアログを閉じるときはこのfuncを利用してください。
     * @param func ダイアログを閉じる操作を定義したオブジェクト。
     */
    public void setDialogCloseFunction(DialogCloseFunction func);

    /**
     * ダイアログをクローズします。
     * setDialogCloseFunctionで設定されたfuncを用いてクローズしてください。
     * setDialogCloseFunctionで設定されたfuncは破棄してください。
     */
    public void closeDialog();
}



 この二つを用いて、DialogFactoryを改善します。(JavaDoc省略)

public final class DialogFactory{
    public static void showWindowDialog(Parent contents,DialogModel model,
            Window parentWindow,double x,double y,boolean isWait){
        //TODO 表示の為にStageを作る
        //TODO ダイアログを閉じることを定義し、modelに通知する
        //TODO isWaitがtrueの場合はStageが閉じるまで処理を中断する
    }

    public static void showInnerDialog(Node contents,DialogModel model,
            StackPane parentPane,boolean isWait){
        //TODO 表示の為に、全てのイベントを奪取するPaneを作成し、contentsを入れる
        //TODO ダイアログを閉じることを定義し、modelに通知する
        //TODO isWaitがtrueの場合はPaneをparentPaneから取り除くまで処理を中断する
    }
}



 後は実際に実装していくだけです。まずは簡単なウィンドウ形式の方から。

 なお、ウィンドウダイアログを作るのに必要な情報(リサイズ可能かどうか、ウィンドウのアイコン、ウィンドウのタイトルなど)を取得できるようにDialogModelの定義を拡張してあります(省略)。

 public static void showWindowDialog(Parent contents,DialogModel model,
         Window parentWindow,double x,double y,boolean isWait)
                 throws NullPointerException{
     //親ウィンドウが表示されていないときはnull扱い
     if(!parentWindow.isShowing())parentWindow=null;

     //Stageを作成、初期化
     StageBuilder<?> builder=StageBuilder.create();
     StageStyle style = model.getStageStyle();
     if(style!=null)builder.style(style);
     builder
     .scene(new Scene(contents))
     .onCloseRequest(new WindowCloseEventHandler(model));
     Collection<? extends Image> icon  = model.getIcons();
     if(icon!=null)builder.icons(icon);
     Stage s = builder.build();
     if(parentWindow!=null){
         s.initOwner(parentWindow);
     }
     if(x>=0)
         s.setX(x);
     if(y>=0)
         s.setY(y);
     
     s.resizableProperty().bind(model.dialogResizableProperty());
     s.titleProperty().bind(model.dialogTitleProperty());
     Modality m = model.getDialogModality();
     if(m==null||(m==Modality.WINDOW_MODAL&&parentWindow==null))
         m=Modality.NONE;
     s.initModality(m);

     //ダイアログを閉じることを定義 WindowDialogCloseFunctionは後述
     WindowDialogCloseFunction f = new WindowDialogCloseFunction(s);
     model.setDialogCloseFunction(f);

     //表示
     if(isWait)
         s.showAndWait();//待機する
     else
         s.show();//待機しない
 }

//ダイアログを閉じることを定義
private static class WindowDialogCloseFunction 
implements DialogCloseFunction{
    private Stage stage;

    public WindowDialogCloseFunction(Stage stage){
        this.stage = stage;
    }

    @Override
    public void closeDialog(){
        if(stage!=null){
            stage.close();
            stage = null;
        }
    }
}




 次はインナーウィンドウ形式です。Stageを使った場合は、showAndWait()を使えば処理を待機させることが出来ましたが、今回はそうはいきません。でも、やっぱり同じように待機したいです。そこで、com.sun.javafx.tk.Toolkit#enterNestedEventLoop(Object)を利用します。

 com.sun以下のパッケージを利用して良いのかという問題がありますが、使わないとどうにもならんのだから仕方がない。

private static EventHandler<Event> ALL_CONSUME=new EventHandler<Event>(){
    public void handle(Event event){event.consume();}
};
public static void showInnerDialog(Node contents,DialogModel model,
         StackPane parentPane,boolean isWait){
     //格納するPane
     FlowPane back = new FlowPane();
     back.setAlignment(Pos.CENTER);//中央に配置
     back.getStyleClass().add("innerdialog");
     back.setStyle("-fx-background-color:rgba(60,60,60,0.5);");
     back.addEventHandler(Event.ANY, ALL_CONSUME);//イベント奪取

     //TODO 本当はウィンドウっぽくしたい(面倒くさかった)
     StackPane base = new StackPane();
     base.setStyle("-fx-background-color:white;");
     base.getStyleClass().add("innerdialog-base");
     base.getChildren().add(contents);
     back.getChildren().add(base);
     parentPane.getChildren().add(back);

     //TODO フォーカスの移動を制限したい場合ってどうすれば良いんじゃ?

     InnerDialogCloseFunction f = new InnerDialogCloseFunction(parentPane, back, base, contents);
     model.setDialogCloseFunction(f);

     //待機
     if(isWait)
         f._wait();
 }

private static class InnerDialogCloseFunction 
implements DialogCloseFunction{
    private StackPane parentPane;
    private FlowPane back;
    private StackPane base;
    private Node contents;
    private Object key = new Object();
    private Object obj;
    private volatile boolean wait = false;
    private boolean closed = false;
    public InnerDialogCloseFunction(StackPane parentPane,
            FlowPane back,StackPane base, Node contents){
        this.parentPane=parentPane;
        this.back = back;
        this.base = base;
        this.contents = contents;
    }

    //待機処理を行います。(waitフラグを格納する為にここで行っています)
    void _wait(){
        wait = true;
        obj=com.sun.javafx.tk.Toolkit.getToolkit().enterNestedEventLoop(key);
    }

    @Override
    public void closeDialog(){           
        if(!closed){
            closed=true;
            parentPane.getChildren().remove(back);
            base.getChildren().remove(contents);
            if(wait)//待機している場合は復帰作業をする
                com.sun.javafx.tk.Toolkit
                .getToolkit().exitNestedEventLoop(key, obj);

            parentPane=null;
            back = null;
            base = null;
            contents=null;
            obj =key= null;
        }
    }
}




 これで、Stage#showAndWaitと同等の動作をするインナーウィンドウ形式のダイアログを作成することが出来ました。出来たのは良いんですが、まだJavaFXでのフォーカスの制御の仕方やノードが表示されたときのタイミングの取得が分からないので、ダイアログの下のノードにフォーカスが残ったりしています。何とかしないとね。

 あと、面倒くさいから放置してしまった、ウィンドウっぽいノードも作りたいね。


 今回のDialog関連の実装はhttps://github.com/nodamushi/NodamushiJFXUtilities/tree/master/src/nodamushi/jfx/scene/dialogにあります。


 次回は根気があればControlの自作と、SkinとControllerあたりの話をしようかな。本当はこの記事で全部やるつもりだったけど、根気が持たなかった。


 作ってるときに参考にした記事:”JavaFX2.2でダイアログを作る方法”