FXEyes その 2

昨日のエントリーの FXEyes の続き。

昨日気になっていた部分を作り直しました。今回はプロパティやバインドを多用してみたので、バインドはかなり分かってきましたww

ドラッグしてステージを移動することや、タイトルバーを出すのをポップアップメニューで選択できるようにしました。タイトルバーを表示している時には、ステージのサイズを変更することもできます。

そういえば、ポップアップメニュー (JavaFX でいうところの ContextMenu) のバグも発見しました。

シーングラフに Control が 1 つも貼っていないと、ContextMenu が表示されないというバグです。以前、プレゼンツールを作っている時に、ContextMenu が表示されないという問題があったのですが、Control が貼っていないからのようです。

ここでは、空文字を表示するラベルをダミーで貼ってあります。

ソースは Gist にアップしました。

FXEyes - https://gist.github.com/1523258

少しだけ解説しておきます。まず、Eyes クラスの方から。

Eyes クラスは位置とサイズに関するプロパティを 4 つ保持しています。

public class Eyes extends Parent {

    // Eye Position
    private DoubleProperty locationX = new SimpleDoubleProperty();
    private DoubleProperty locationY = new SimpleDoubleProperty();
    private DoubleProperty width = new SimpleDoubleProperty();
    private DoubleProperty height = new SimpleDoubleProperty();

これらのプロパティはすべて double の値を表しているプロパティです。DoubleProperty クラスはアブストラクトなので、Read と Write の両方ができるのであれば SimpleDoubleProperty クラスを使用します。ちなみに、Read のみであれば ReadOnlyDoubleProperty クラスを使用します。

さて、これらのプロパティを返すためのメソッドも用意します。

    public DoubleProperty locationXProperty() {
        return locationX;
    }
 
    public DoubleProperty locationYProperty() {
        return locationY;
    }

    public DoubleProperty widthProperty() {
        return width;
    }

    public DoubleProperty heightProperty() {
        return height;
    }

後はステージを表示する時にこれらのプロパティにバインドをするようにします。これは FXEyes クラスで行っています。

        eyes = new Eyes();
        eyes.widthProperty().bind(scene.widthProperty());
        eyes.heightProperty().bind(scene.heightProperty());
        eyes.locationXProperty().bind(stage.xProperty());
        eyes.locationYProperty().bind(stage.yProperty());

幅と高さは Scene の幅と高さにバインドし、位置は Stage の位置にバインドするようにしています。こうすることで、ステージが移動した時やサイズが変更した時でも、Eyes クラスの方は自動的にそれに追従するようになります。

これで Eyes クラスの位置とサイズは自動的に変更されるようになりました。ついでにサイズに自動的に追従したいものに、目を描く時の線の太さがあります。

しかし、単にサイズに追従してしまうと、ステージのサイズが小さい時に、線の太さが 1 以下になってしまうかもしれません。このため、線の太さを決めるには少なからずロジックが必要になります。

このロジックは低レベルのバインド API を使用して実現しました。

低レベルのバインドを使用するには、javafx.beans.binding パッケージで定義されている XBinding クラスを使用します。X にはプリミティブ型、String もしくは Object が入ります。ここでは double を使用するので、DoubleBinding クラスを使用しました。

    // Eye Stroke Width is determined by scene size.
    private DoubleBinding strokeWidth = new DoubleBinding() {

        {
            super.bind(width, height);
        }

        @Override
        protected double computeValue() {
            // Strok Width is dicided by screen size.
            double stroke;
            if (width.get() < height.get()) {
                stroke = width.get() / SIZE_RATIO;
            } else {
                stroke = height.get() / SIZE_RATIO;
            }
            return (stroke < MINIMAM_STROKE) ? MINIMAM_STROKE : stroke;
        }
    };

イニシャライザでは、このオブジェクトがバインドするプロパティを bind しておきます。これらのプロパティが変更されると、computeValue メソッドがコールされます (本当はちょっと違うのですが、ちょっと単純化しています)。

computeValue メソッドではバインドしたプロパティに応じた新たな値を得るためのロジックを記述します。ここでは幅と高さに応じて太さを変化させ、細すぎる場合は最小値を設定するというロジックを記述してあります。

これでサイズが変化すると、線の太さも変わるようになりました。

では、目を描いてみましょう。目は楕円で描いてます。ここでは左の白目の部分を示しました。

    private void createEyes() {
        // Left Eye
        Ellipse left = new Ellipse();
        left.centerXProperty().bind(width.divide(4.0));
        left.centerYProperty().bind(height.divide(2.0));
        left.radiusXProperty().bind(width.divide(4.0).subtract(strokeWidth.divide(2.0)));
        left.radiusYProperty().bind(height.divide(2.0).subtract(strokeWidth.divide(2.0)));
        left.setStroke(Color.BLACK);
        left.strokeWidthProperty().bind(strokeWidth);
        left.setFill(Color.WHITE);

左目の楕円の中心は幅の 1/4、高さの 1/2 のところです。このため、単に width や height にバインドするわけにはいきません。とはいえば、先ほどの低レベル API を使うほどではないようです。

そこで、DoubleProperty クラスのスーパークラスである DoubleExpression クラスで定義している四則演算のメソッドを使用します。たとえば、 width.divide(4.0) というのは、プロパティ width を 4 で割った値を持つ DoubleBinding オブジェクトを生成します。

そして、楕円の中心はこの DoubleBinding オブジェクトとバインドするようにします。

ちなみに Bindings クラスというユーティリティクラスがあって、このクラスを使用してもプロパティ同士の四則演算を実現することができます。

こうやって描画すると、シーンのサイズに応じて楕円の位置が決まります。

サイズを変化させた FXEyes を 2 つ並べたのが、以下の図です。

f:id:skrb:20111227204817j:image

次は目玉の位置です。

目玉の位置は AWT の MouseInfo クラスで定期的に取得します。タイマーには Swing のタイマーを使っています。AWT/Swing で実行しているので、この処理は AWT/Swing のイベントディスパッチスレッド (EDT) で行う必要があります。そのために SwingUtilities クラスの invokeLater メソッドを使用します。

    private void startSwingEDT() {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                timer = new Timer(50, new ActionListener() {
                    @Override
                    public void actionPerformed(java.awt.event.ActionEvent event) {
                        final PointerInfo info = MouseInfo.getPointerInfo();

                        Platform.runLater(new Runnable() {
                            @Override
                            public void run() {
                                eyes.setMouseLocation(info.getLocation().getX() - scene.getX(),
                                                      info.getLocation().getY() - scene.getY());
                            }
                        });
                    }
                });
                timer.start();
            }
        });
    }

Swing の EDT で実行しているのですから、直接 JavaFX にアクセスすることはできません。そこで、Platform クラスの runLater メソッドを使用して、値を更新しています。runLater メソッドは SwingUtilities クラスの inovokeLater メソッドの JavaFX 版ですね。

JavaFX 側ではマウスの位置の更新されたら、目玉の位置を更新します。

    public void setMouseLocation(double mouseX, double mouseY) {
        double x = mouseX - locationX.get();
        double y = -mouseY + locationY.get() + height.get();
        double cx = width.get() / 4;
        double cy = height.get() / 2;

        // Update Left Black Eye Position
        Point2D left = calculateEyePosition(x, y, cx, cy); 
        leftX.set(left.getX());
        leftY.set(left.getY());

        // Update Right Black Eye Position
        cx = width.get() / 4 * 3;
        Point2D right = calculateEyePosition(x, y, cx, cy);
        rightX.set(right.getX());
        rightY.set(right.getY());
    }

leftX, leftY, rightX, rightY が目玉の中心の位置を示しています。calculateEyePosition メソッドで目玉の位置を計算しているのですが、ここは単なる計算なので省略。

目玉は円で描いています。もちろん、leftX などにバインドするようにしてあります。

        // Left Black Eye
        Circle leftBlackEye = new Circle();
        leftBlackEye.centerXProperty().bind(leftX);
        leftBlackEye.centerYProperty().bind(leftY);
        leftBlackEye.radiusProperty().bind(strokeWidth);
        leftBlackEye.setFill(Color.BLACK);

これでできあがりです。