いろふさん絵描き歌 by JavaFX

このエントリはいろふ Advent Calendar ではなくて、JavaFX Advent Calendar の 12/14 担当分になります。

昨日は mike_neck さんの JavaFXでGroovyのVetoableが機能するか試してみた です。

ということで、いろふさんのアイコンを描いてみましょう!!

というのも、ゆきーんさんが HTML 5 の Canvas でいろふアイコンを描いていたので、これは JavaFX でも描かなくてはということなわけです。

JavaFX で描画といったら Shape ですよね。最近になって HTML 5 と同じように Canvas も導入されましたけど...

ということで、描いてみました。

まずは枠線。単純に四角形です。

        Group root = new Group();

        // 枠線
        Rectangle rectangle = new Rectangle(0, 0, 300, 300);
        rectangle.setStrokeWidth(5.0);
        rectangle.setStroke(Color.BLACK);
        rectangle.setFill(Color.WHITE);
        root.getChildren().add(rectangle);

次に輪郭。輪郭は Arc クラスをつかうこともできますが、口の部分とつなげたいので Path クラスで描いていきます。Path クラスは XXXXTo クラスで線をつなげていきます。

始点は MoveTo で、円弧は ArcTo です。ArcTo のプロパティが HTML 5 の Canvas と違うので、ちょっと苦労しました ^ ^;;

        // 輪郭
        Path path = new Path();
        path.setStrokeWidth(10.0);
        path.setStroke(Color.BLACK);
        // 枠線を越えないようにクリッピングする
        path.setClip(new Rectangle(0, 0, 300, 300));

        // 輪郭の始点
        path.getElements().add(new MoveTo(126.5, 267));
        // 円弧
        ArcTo arc = new ArcTo();
        arc.setX(146); arc.setY(184.5);
        arc.setRadiusX(117); arc.setRadiusY(117);
        arc.setLargeArcFlag(true);
        path.getElements().add(arc);

そして、口。口は直線なので、LineTo です。最後に閉じたパスにするため CloseTo を使います。

        // 口の部分の直線
        path.getElements().add(new LineTo(210, 255));
        // パスを閉じる
        path.getElements().add(new ClosePath());
        root.getChildren().add(path);

次に吹き出し。ここも Path を使ってます。吹き出しの飛び出ている部分はベジェ曲線 CubicCurveTo を使ってます。

        // 吹き出し
        path = new Path();
        path.setStrokeWidth(10.0);
        path.setStroke(Color.BLACK);
        path.getElements().add(new MoveTo(50, 30));
        path.getElements().add(new LineTo(153, 30));
        arc = new ArcTo();
        arc.setX(153); arc.setY(90);
        arc.setRadiusX(30); arc.setRadiusY(30);
        arc.setSweepFlag(true);
        path.getElements().add(arc);
        path.getElements().add(new LineTo(105, 90));
        // 吹き出しの飛び出ている部分はベジェ曲線で記述
        path.getElements().add(new CubicCurveTo(105, 90, 90, 105, 129, 141));
        path.getElements().add(new CubicCurveTo(90, 135, 66, 120, 81, 90));
        path.getElements().add(new LineTo(57, 90));
        arc = new ArcTo();
        arc.setX(50); arc.setY(30);
        arc.setRadiusX(30); arc.setRadiusY(30);
        arc.setSweepFlag(true);
        path.getElements().add(arc);
        root.getChildren().add(path);

吹き出しの中の点は Circle です。

        // 吹き出しの点
        Circle circle = new Circle(51, 60, 5, Color.BLACK);
        root.getChildren().add(circle);
        circle = new Circle(84, 60, 5, Color.BLACK);
        root.getChildren().add(circle);
        circle = new Circle(120, 60, 5, Color.BLACK);
        root.getChildren().add(circle);
        circle = new Circle(154, 60, 5, Color.BLACK);
        root.getChildren().add(circle);

最後に目。これも Circle。

        // 目
        circle = new Circle(255, 204, 15);
        circle.setFill(null);
        circle.setStroke(Color.BLACK);
        circle.setStrokeWidth(10);
        root.getChildren().add(circle);

これで完成!!

PixelReader/PixelWriter

このエントリは JavaFX Advent Calendar の 12/4 担当分になります。

最近、いろいろなところで JavaFX 2.2 の新機能について話をしているのですが、今日はその中の 1 つである PixelReader/PixelWriter クラスについて使い方を説明していきます。

JavaFX 2.2 ではビットマップに関する機能がやっと導入されました。その中でも中心になるのが、ビットマップのピクセルを読み込む PixelReader クラスと、ピクセルを書き込む PixelWriter クラスです。

PixelReader クラスは Image クラスのピクセルを読み込むことができます。PixelWriter クラスが書き込むのは WritableImage クラスです。WritableImage クラスは Image クラスのサブクラスで、Java 2D でいうところの BufferedImage クラスのようなクラスです。

PixelReader/PixelWriter クラスではいくつかメソッドが定義されていますが、まずは 1 ピクセルの読み書きから。

たとえば、あるイメージをコピーしたイメージを作ってみましょう。

    Image src = new Image(...);
    PixelReader reader = src.getPixelReader();

    int width = (int)src.getWidth();
    int height = (int)src.getHeight();

    WritableImage dest = new WritableImage(width, height);
    PixelWriter writer = dest.getPixelWriter();

    for (int x = 0; x < width; x++) {
        for (int y = 0; y < height; y++) {
            // srcのイメージのピクセルを読み込んで、destに書き込む
            Color color = reader.getColor(x, y);
            writer.setColor(x, y, color);

            // こちらでも OK
//            int argb = reader.getArgb(x, y);
//            writer.setArgb(x, y, argb);
        }
    }

PixelReader オブジェクトは Image オブジェクトの getPixelReader メソッドで取得できます。

WritableImage オブジェクトは縦横を指定して生成します。そして、PixelWriter オブジェクトは getPixelWriter メソッドで取得します。

後は、PixelReader#getColor(x, y) で (x, y) 座標の Color オブジェクトを取得できます。同様に、PixelWriter#setColor(x, y, color) で、(x, y) 座標を Color オブジェクトが示す色にします。

同じように PixelReader#getArgb メソッド、PixelWriter#setArgb メソッドというのもあります。これは色を int で扱います。int の先頭 8 ビットが alpha、次の 8 ビットが赤、同様に 8 ビットずつ緑、青となります。

さて、イメージをコピーするのだけでは味気ないので、ここではイメージをぼかしてみます。ここではあまりキレイなぼかしにならないボックスブラーを扱います。

ブラーはあるピクセルの周りのピクセルも一緒に読み込んで、色の平均をとって、そのピクセルの色とするという処理で実現します。

周りのピクセルの取り方を四角にするとボックスブラーになります。

では、書いてみましょう。

    private void blur() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                double red = 0;
                double green = 0;
                double blue = 0;
                double alpha = 0;
                int count = 0;
                for (int i = -kernelSize; i <= kernelSize; i++) {
                    for (int j = -kernelSize; j <= kernelSize; j++) {
                        if (x + i < 0 || x + i >= width 
                           || y + j < 0 || y + j >= height) {
                            continue;
                        }
                        Color color = reader.getColor(x + i, y + j);
                        red += color.getRed();
                        green += color.getGreen();
                        blue += color.getBlue();
                        alpha += color.getOpacity();
                        count++;
                    }
                }
                Color blurColor = Color.color(red / count, 
                                              green / count, 
                                              blue / count, 
                                              alpha / count);
                writer.setColor(x, y, blurColor);
            }
        }
    }

ちょっと面倒くさいのが、縁の部分です。ここは周りのピクセルを全部読み込めないので、読み込めるところだけ読み込んで平均しています。

さて、せっかくなので、スライダでぼかし量を決められるようにしてみます。

        slider.valueProperty().addListener(new InvalidationListener() {
            @Override
            public void invalidated(Observable o) {
                DoubleProperty value = (DoubleProperty)o;
                int intValue = (int) value.get();
                if (intValue != kernelSize) {
                    kernelSize = intValue;
                    blur();
                }
            }
        });

Slier クラスの value プロパティは double なので、int に変換しています。実際に blur メソッドをコールしているのは、value プロパティが double なので 0.0 から 0.1 に変化しても invalidated メソッドがコールされてしまうのですが、int としてみれば変化していないとみなせるからです。

実行すると、下のようになります。


ところが、この方法だととてもパフォーマンスが悪いのです。特にぼかし量を増やした時は、顕著にパフォーマンスが落ちます。

まぁ、当たり前ですね。1 ピクセルを書き込むのに、何度も読んでいるわけですから。パフォーマンスを向上させるには、もっと読み込み回数を減らす必要があります。

じゃあ、どうするか。まとめて読み込めばいいのです。

複数ピクセルの読み込み

PixelReader クラスには getPixels というまとめてピクセルを読み込むメソッドがあります。これを使えば読み込み回数を減らすことができます。

getPixels は読み込んだピクセルの格納方法により 3 種類のオーバーロードがあります。byte、int そして Buffer を使う 3 種類です。

ここでは int を使ってみます。

int を使うには、ピクセルのフォーマットを指定する必要があります。フォーマットは WritablePixelFormat クラスで指定します。WritablePixelFormat クラスのスーパークラスである PixelFormat の static メソッドで WritablePixelFormat オブジェクトのファクトリメソッドがあるので、それを使用します。

int が使えるのは getIntArgbInstance メソッドか getIntArgbPreInstance メソッドのどちらかです。しかし、getIntArgbPreInstance メソッドの Pre がよく分からないんですよね。PixelFormat.Type という enum にpremultiplied と記述されているのですが、どういう意味なんだろう?

ということで、getIntArgbPreInstance メソッドを使用します。

getPixels メソッドの引数は x, y, width, height, writablepixelformt, buffer, offset, scalingStride となります。buffer が int で、offset が buffer のオフセットを指定します。最後の scalingStride は列から次の列までの幅を示すのですが、width でいいと思うんですけどねぇ。

ということで、blur メソッドを書き換えた blur2 メソッドです。

    private void blur2() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                int centerX = x - kernelSize;
                int centerY = y - kernelSize;
                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                if (centerX < 0) {
                    centerX = 0;
                    kernelWidth = x + kernelSize;
                } else if (x + kernelSize >= width) {
                    kernelWidth = width - centerX;
                }

                if (centerY < 0) {
                    centerY = 0;
                    kernelHeight = y + kernelSize;
                } else if (y + kernelSize >= height) {
                    kernelHeight = height - centerY;
                }

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(centerX, centerY, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }

                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                writer.setArgb(x, y, blurColor);
            }
        }
    }

かなり長くなっているのは端の処理が面倒くさくなっているからです。

それでも、1 ピクセルごとに読み込んでいたのに比べると、かなり処理が軽くなっています。

比較できるように blur メソッドを使うか、blur2 メソッドを使うかをラジオボタンで指定できるようにしてみました。

複数ピクセルの書き込み

PixelReader クラスが複数ピクセルの読み込みがあるのですから、もちろん PixelWriter にも複数ピクセルをまとめて書き込むメソッドがあります。

これを使ってモザイク処理をしてみましょう。

ぼかしの時と同じようにピクセルの平均を取るのですが、それをそのピクセルすべての色にしてしまえばモザイクになります。

PixelWriter#setPixels メソッドは、PixelReader#getPixels メソッドと同じように、複数のオーバーロードがあります。byte、int、Buffer の 3 種類に加えて、PixelReader オブジェクトから読み込んだものを書き込むというオーバーロードもあります。

もちろんここでは int[] を使うものを使用します。

    private void mosaic() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = kernelSize; 
             x < width - kernelSize * 2; 
             x += kernelSize * 2 + 1) {
            for (int y = kernelSize; 
                 y < height - kernelSize * 2; 
                 y += kernelSize * 2 + 1) {

                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }
                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                Arrays.fill(buffer, blurColor);
                writer.setPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);
            }
        }
    }

同じようにラジオボタンで選択できるようにしてみました。

ということで、PixelReader クラスと PixelWriter クラスの使い方でした。

明日は @halcat0x15a さんです。

import java.nio.IntBuffer;
import java.util.Arrays;
import javafx.application.Application;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.DoubleProperty;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Slider;
import javafx.scene.control.ToggleGroup;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.image.WritablePixelFormat;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;

public class WritableImageDemo extends Application {

    private Image src;
    private WritableImage dest;
    private int kernelSize = 1;
    private int width;
    private int height;
    
    private RadioButton blurButton;
    private RadioButton blur2Button;
    private RadioButton mosaicButton;

    @Override
    public void start(Stage stage) {

        AnchorPane root = new AnchorPane();

        initImage(root);

        Scene scene = new Scene(root);

        stage.setTitle("WritableImage Demo");
        stage.setResizable(false);
        stage.setScene(scene);
        stage.show();
    }

    private void initImage(AnchorPane root) {
        src = new Image("macaron.jpg");
        ImageView srcView = new ImageView(src);
        root.getChildren().add(srcView);
        AnchorPane.setTopAnchor(srcView, 0.0);
        AnchorPane.setLeftAnchor(srcView, 0.0);

        width = (int) src.getWidth();
        height = (int) src.getHeight();
        root.setPrefSize(width * 2.0, height + 50);

        dest = new WritableImage(width, height);
        ImageView destView = new ImageView(dest);
        destView.setTranslateX(width);
        root.getChildren().add(destView);
        AnchorPane.setTopAnchor(destView, 0.0);
        AnchorPane.setRightAnchor(destView, (double) width);

        Slider slider = new Slider(0, 10, kernelSize);
        slider.setPrefSize(width, 50);
        slider.setShowTickLabels(true);
        slider.setShowTickMarks(true);
        slider.setSnapToTicks(true);
        slider.setMajorTickUnit(1.0);
        slider.setMinorTickCount(0);

        slider.valueProperty().addListener(new InvalidationListener() {
            @Override
            public void invalidated(Observable o) {
                DoubleProperty value = (DoubleProperty) o;
                int intValue = (int) value.get();
                if (intValue != kernelSize) {
                    kernelSize = intValue;
                    if (blurButton.isSelected()) {
                        blur();
                    } else if (blur2Button.isSelected()) {
                        blur2();
                    } else {
                        mosaic();
                    }
                }
            }
        });

        root.getChildren().add(slider);
        AnchorPane.setBottomAnchor(slider, 0.0);
        AnchorPane.setRightAnchor(slider, 10.0);

        HBox hbox = new HBox(10);
        hbox.setAlignment(Pos.CENTER);
        hbox.setPrefWidth(width);
        hbox.setPrefHeight(50);
        root.getChildren().add(hbox);
        AnchorPane.setBottomAnchor(hbox, 0.0);
        AnchorPane.setLeftAnchor(hbox, 10.0);

        ToggleGroup group = new ToggleGroup();
        blurButton = new RadioButton("Blur");
        blurButton.setToggleGroup(group);
        blurButton.setSelected(true);
        hbox.getChildren().add(blurButton);
        blur2Button = new RadioButton("Blur2");
        blur2Button.setToggleGroup(group);
        hbox.getChildren().add(blur2Button);
        mosaicButton = new RadioButton("Mosaic");
        mosaicButton.setToggleGroup(group);
        hbox.getChildren().add(mosaicButton);

        blur();
    }

    private void blur() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                double red = 0;
                double green = 0;
                double blue = 0;
                double alpha = 0;
                int count = 0;
                for (int i = -kernelSize; i <= kernelSize; i++) {
                    for (int j = -kernelSize; j <= kernelSize; j++) {
                        if (x + i < 0 || x + i >= width 
                           || y + j < 0 || y + j >= height) {
                            continue;
                        }
                        Color color = reader.getColor(x + i, y + j);
                        red += color.getRed();
                        green += color.getGreen();
                        blue += color.getBlue();
                        alpha += color.getOpacity();
                        count++;
                    }
                }
                Color blurColor = Color.color(red / count, 
                                              green / count, 
                                              blue / count, 
                                              alpha / count);
                writer.setColor(x, y, blurColor);
            }
        }
    }

    private void blur2() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                int centerX = x - kernelSize;
                int centerY = y - kernelSize;
                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                if (centerX < 0) {
                    centerX = 0;
                    kernelWidth = x + kernelSize;
                } else if (x + kernelSize >= width) {
                    kernelWidth = width - centerX;
                }

                if (centerY < 0) {
                    centerY = 0;
                    kernelHeight = y + kernelSize;
                } else if (y + kernelSize >= height) {
                    kernelHeight = height - centerY;
                }

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(centerX, centerY, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }
                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                writer.setArgb(x, y, blurColor);
            }
        }
    }

    private void mosaic() {
        PixelReader reader = src.getPixelReader();
        PixelWriter writer = dest.getPixelWriter();
        WritablePixelFormat<IntBuffer> format 
            = WritablePixelFormat.getIntArgbInstance();

        for (int x = kernelSize; x < width - kernelSize * 2; x += kernelSize * 2 + 1) {
            for (int y = kernelSize; y < height - kernelSize * 2; y += kernelSize * 2 + 1) {
                int kernelWidth = kernelSize * 2 + 1;
                int kernelHeight = kernelSize * 2 + 1;

                int[] buffer = new int[kernelWidth * kernelHeight];
                reader.getPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);

                int alpha = 0;
                int red = 0;
                int green = 0;
                int blue = 0;

                for (int color : buffer) {
                    alpha += (color >>> 24);
                    red += (color >>> 16 & 0xFF);
                    green += (color >>> 8 & 0xFF);
                    blue += (color & 0xFF);
                }
                alpha = alpha / kernelWidth / kernelHeight;
                red = red / kernelWidth / kernelHeight;
                green = green / kernelWidth / kernelHeight;
                blue = blue / kernelWidth / kernelHeight;

                int blurColor = (alpha << 24) 
                              + (red << 16) 
                              + (green << 8) 
                              + blue;
                Arrays.fill(buffer, blurColor);
                writer.setPixels(x, y, 
                                 kernelWidth, kernelHeight, 
                                 format, buffer, 0, kernelWidth);
            }
        }
    }

    public static void main(String[] args) {
        launch(args);
    }
}

第 7 回 JavaFX 勉強会

7/2 に JavaFX 勉強会 を開催しました。

当日はいつものように Oracle に会場を貸していただきました。ありがとうございます。

また、Ustream 職人の @yusukey さんにストリーミング、Togetter は @kimukou2628 さんにまとめていただきました。ありがとうございます!!

Togetter http://togetter.com/li/330960

前半、片貝さんが NetBeans、私が Scene Builder という JavaFX の 2 大ツールについて説明をおこないました。

NetBeans はもうすぐ 7.2 がリリースされるので、7.2 の話題がメインでした。7.2 ではパフォーマンスの改善と、エディタ周りが強化されるようです。

JavaFX に関する部分だと、Scene Builder との連携ができるようになったことや、CSSJavaFX 対応ができたことなどがあります。

後半は、5 人の方に LT をしていただきました。

まず、 @aoetk さんは Mac の話。

特にネイティブバンドルは情報がまだ少ないだけに貴重です。JDK が全部入るというのも、Jigsaw が入ればどうにかなると思うんですけどね。

Jigsaw はすでに Developer Preview が出ているので、ぜひ確かめて欲しいなぁ。

次は @mike_neck さんの JavaFXJavaScript のテストの話。

LT は 5 分なのに 75 枚というのは...

私も blog で書きましたけど、JavaScript のテストに JavaFX は結構使えそうです。

3 番手が @taiz77 さんの JavaFX での画面遷移に関して。

画面遷移のライブラリとして、JFXFLow を紹介され、その後 @taiz77 さんが開発されている JavaFX アプリケーションフレームワーク Creamy について。

時間が足りなくて (というか JFXFLow に時間をかけすぎましたね) 十分に紹介できなかったのが残念でした。ぜひ、どこかでちゃんと紹介して欲しいです。

4 番手が @kimukou2628 さんの Griffon での JavaFX について。

Griffon10 in groovy_fx
View more presentations from kimukou_26   

JavaFX Script 世代の櫻庭にしてみると、GroovyFX はかなりいいですね。FXML も扱えるので、FXML の補完的に使うのがいいかもしれません。

さて、最後の寺田さん。

エンタープライズアプリケーションのクライアントとしての JavaFX について。

JavaFX Enterprise
View more presentations from Oracle Fusion Middleware

Application Client Container を使っていたのですが、その後 @nekop

といわれてしまって、寺田さんかたなしですよ ^ ^;;

さて、私のセッションですが、Scene Builder がメインです。でも、ATND の参加者一覧を見ていると初心者の方も多いようでしたので、Scene Builder が扱う FXML についての説明をはじめにしました。

Scene BuilderでFXML
View more presentations from skrb

とはいいつつ、資料は Scene Builder についてはほとんど書いてません ^ ^;; ツールの説明は実際にやってみた方がいいと思って、ライブコーディングをしたのでした。

ところで、本題が Scene Builder だったので、あまりちゃんと説明しなかったのですが、FXML に対応するクラスを JavaFX ではコントローラ (Controller というクラスがあるわけではないです) と呼びます。

これがすごい違和感を感じるわけです。私自身は Java が登場する前に、SmalltalkMVC を覚えたくちなので、C はそこじゃないだろうと思うわけです。

コントローラと呼ぶことによって、いろいろと誤解されてしまうように思うんですが...

まぁ、それはそれとして、今回は参加者が多くて、ほんとによかったです。80 名が入る部屋がいっぱいになったのは、JavaFX 勉強会でははじめて。それだけ、JavaFX が注目されていることだと思います。

そういえば、来月から ITpro の連載 で、JavaFX 2.x を取りあげることにしました。現状、日本語の JavaFX の情報はかなり少ないのですが、その状況を打破すべくがんばります。

JavaFX でプレゼンツール その 3

その 1
その 2

プレゼンツールのその 3 です。

今回はプレゼンツールの中でのデモです。プレゼンツールが JavaFX で書かれていることの最大の利点は JavaFX のデモをシームレスに行えることです。

でも、シームレスにデモすることは、そんなに難しいことではないです。

まずは、用意しておいたデモを起動させることを考えます。ここでは、Duke のイメージを回転させるというデモを使います。

public class AnimationDemo {
    public void start(Stage stage) {
        Group root = new Group();
        Scene scene = new Scene(root, 600, 500);
        
        ImageView image 
            = new ImageView(new Image(this.getClass().getResourceAsStream("DukeWithHelmet.png")));
        image.setLayoutX(50); image.setLayoutY(20);
        root.getChildren().add(image);
        
        RotateTransition rotate
            = new RotateTransition(new Duration(3000));
        rotate.setNode(image);
        rotate.setToAngle(360);
        rotate.setAutoReverse(true);
        rotate.setCycleCount(Animation.INDEFINITE);
        rotate.setInterpolator(Interpolator.EASE_BOTH);
        rotate.play();
        
        stage.setScene(scene);
        stage.show();
    }
}

start メソッドは使っていますが、Application クラスのサブクラスというわけではないです。というのも、JavaFX では Application.launch メソッドは 1 度しかコールできないからです。

ProcessBuilder クラスで別プロセスとして JavaFX のアプリケーションを起動するのであればいざ知らず、同じプロセスの中で実行するのであればアプリケーションスレッドも同じものを使用します。

さて、FXML にはデモを起動するためのボタンを作っておきます。FXML の一部分をだけを示しておくと、こんな感じです。

    <Button fx:id="p2" onAction="#executeDemo" text="Execute">

onAction 属性で # がついている文字列はコントローラクラスのメソッドを示しています。ようするに、ボタンがクリックされると、コントローラクラスの executeDemo メソッドがコールされます。

executeDemo メソッドはこうなります。

    public void executeDemo(ActionEvent event) {
        // デモの実行
        AnimationDemo demo = new AnimationDemo();
        Stage stage = new Stage();
        demo.start(stage);
    }

先ほどの AnimationDemo クラスのオブジェクトを生成した後、Stage オブジェクトを生成して AnimationDemo クラスの start メソッドをコールしています。

すると、別ウィンドウが開いて Duke が回転します。

f:id:skrb:20120630011233p:image

その場でコンパイル & 実行

ここまではたいしたことありません。では、次にその場でプログラムをコンパイルして、実行できるようにしてみましょう。

ここで使うのが、Compiler API と Script API です。

Compiler APIJava のソースファイルをコンパイルするための API、Script APIJava からスクリプト言語を実行するための API です。

本当は Compiler API だけでいいのですが、Compiler API は使うのがとても面倒くさいのです。

普通のファイルであればそれほど面倒ではないのですが、今は文字列として保持しているコードをコンパイルしなくてはいけません。そうすると、文字列を対象とした仮想ファイルシステムを構築してと文字列をあたかもファイルのように扱ってやなければいけません。

でも、そんなの面倒なので、Script API を使うわけです。

ただし、デフォルトで使用できるスクリプトは JavaScript だけなので、Java 用のスクリプトエンジンが必要です。

Java 用のスクリプトエンジンの JAR ファイルは公開されていないので、自分で JAR ファイルを作る必要があります。Java 用のスクリプトエンジンのソースは java.net の Scripting Project にあります。

Java のスクリプトエンジンは http://java.net/projects/scripting/sources/svn/show/trunk/engines/java です。

make ディレクトリに Ant の build.xml が入っているので、これを使用してコンパイルし、JAR ファイルを作ります。できた JAR ファイルの java-engine.jar を NetBeans のクラスパスに含めます。

また、Script API を使用するには JAVA_HOME/lib/tools.jar が必要なので、これもクラスパスに含めておいてください。

なお、Compiler API については ITproJava 技術最前線で取りあげているので、くわしくはそちらをご覧ください。Script API を使った場合についても書いてあります。

「Java SE 6完全攻略」第89回 プログラムからコンパイル - Compiler API その1
「Java SE 6完全攻略」第89回 プログラムからコンパイル - Compiler API その2
「Java SE 6完全攻略」第89回 プログラムからコンパイル - Compiler API その3
「Java SE 6完全攻略」第89回 プログラムからコンパイル - Compiler API その4
「Java SE 6完全攻略」第89回 プログラムからコンパイル - Compiler API その5

Script API を使った場合、スクリプトエンジンを取得して eval します。ただし、Java の場合はファイル名とメインクラスが必要なので、コンテキストで指定しておきます。

    public void initialize(URL url, ResourceBundle rb) {
        // スクリプトエンジンの取得
        ScriptEngineManager manager = new ScriptEngineManager();
        engine = manager.getEngineByName("java");

        ScriptContext context = engine.getContext();
        // メインクラスとファイル名を設定
        context.setAttribute("mainClass",
                "AnimationDemo",
                ScriptContext.ENGINE_SCOPE);
        context.setAttribute(ScriptEngine.FILENAME,
                "AnimationDemo.java",
                ScriptContext.ENGINE_SCOPE);

                <<省略>>

スクリプトエンジンは ScriptEngineManger クラスから取得します。そして、ファイル名などは ScriptContext クラスで指定します。

UI ではテキストエリアとボタンを用意しておきます。ボタンがクリックされると次のメソッドがコールされます。

    public void compileAndExecuteDemo(ActionEvent event) {
        try {
            // スクリプトの実行
            engine.eval(p3.getText());
        } catch (ScriptException ex) {
            System.err.println("スクリプトの実行に失敗しました");
            ex.printStackTrace();
        }
    }

TextArea オブジェクトから getText メソッドで表示している文字列を取得して、それを eval します。

これでその場でコンパイルして、実行できます。

デフォルトではテキストエリアに先ほどの AnimationDemo クラスがそのまま表示されています。なので、実行すると、先ほどと一緒に Duke が回転するアニメーションが表示されます。

f:id:skrb:20120630230520p:image

そこで、image.setScaleX(0.4); をテキストエリアに追加してから、ボタンをクリックして起動すると細長い Duke が回転します。

f:id:skrb:20120630230521p:image

ソースは GitHub にあげてあります。

https://github.com/skrb/SimplePresenter

ついでにポップアップメニューで終了できるようにするなど、チョコチョコと変更してあります。

追記

その場でコンパイルして、実行は Web Start や Applet だと実行できないことを書くのを忘れていました。

これをできるようにしてしまうと、任意のコードを埋め込んで、実行できるようにするわけですからセキュリティ的にかなりやばいです。まぁ、できないのが当然ですね。

もし、どうしてもやりたいという場合は、署名して、ポリシーを設定すればできますけど、お勧めはできないです。

JavaFX でプレゼンツール その 2

前回の続きです。

前回は、プレゼンツールに最低限な機能として、マウスクリック or キー入力によるページが切り替わることといいました。そして、イメージを切り替えるプレゼンツールを作ったところまで行いました。

しかし、これだけだと、ページを動的に変化させることができません。

そこで、今回はページを切り替えることに加え、ページ内での動きを実装していくことにします。

ところが、ここで困ったことが起きます。というのも、イメージだけでは動きを持たせることがなかなか難しいのです。

1 枚のページを複数のイメージで表すこともできますが、そのイメージをすべてプレゼンツールで保持させるのは本末転倒なような気がします。

前回、プレゼンツールとコンテンツを分けて考えるべきといいましたが、これはコンテンツ側で表さなくてはいけないことだと思うわけです。

じゃあ、コンテンツはどうやって表しましょう。これもいろいろとやり方があると思うのですが、今回は FXML を使ってコンテンツを表すことにします。

これ以外にも DSL を使ったり、独自の XML を使うなど、いろいろやり方はあると思います。たとえば、Prezi は独自の XML を使っているようです。

FXML でコンテンツを表す

FXML は JavaFX の UI を記述するための XML なので、当然プレゼンツールのような用途にも使用することが可能です。


たとえば、イメージを貼っただけの FXML の例を以下に示します。

<?xml version="1.0" encoding="UTF-8"?>

<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.image.*?>
<?import javafx.scene.layout.*?>

<AnchorPane id="AnchorPane" prefHeight="600.0" prefWidth="800.0" xmlns:fx="http://javafx.com/fxml">
  <children>
    <ImageView fitHeight="600.0" fitWidth="800.0" preserveRatio="true">
      <image>
        <Image url="@page1.jpg" preserveRatio="false" smooth="false" />
      </image>
    </ImageView>
  </children>
</AnchorPane>

イメージを貼るには ImageView クラスを使用します。Scene Builder で FXML を作成する場合、デフォルトのコンテナが AnchorPane クラスになるので、そこに ImageView クラスを貼っています。

で、これを読み込ませるためには、前回説明した goForward メソッドを改造するだけです。

    // ページを進める
    private void goForward() throws IOException {
        URL url = getClass().getResource(pages[pageIndex]);

        // 次のページのFXMLをロードする
        Node next = FXMLLoader.load(url);

        root.getChildren().add(next);

        // 前のページが存在していれば、presentに代入
        Node present = null;
        if (root.getChildren().size() > 1) {
            present = root.getChildren().get(0);
        }
        
        // ページ遷移のアニメーションを行う
        translatePage(next, present);
        
        // ページインデックスを進める
        // 最後までいったら最初に戻す
        pageIndex++;
        if (pageIndex >= pages.length) {
            pageIndex = 0;
        }
    }

前回は Image クラスと ImageView クラスで直接イメージをロードしていましたが、FXML の場合は FXMLLoader クラスの load メソッドを使用します。

これで前回のプレゼンツールと同様のことができます。

コントローラを使って、動きをだす

FXML を使ったとしても、ロードの部分が変わるだけです。しかし、これでは前回と同じ事しかできません。

じゃあ、どうするか。

答えはコントローラです。

FXML は基本的には GUI の構造だけを表すだけで、そこでの動きやモデルとのつなぎはコントローラに Java で記述するわけです。

で、ここでもページの動きをコントローラに記述することにしましょう。

マウスクリックはメインとなる SimplePresenter クラスでイベント処理するので、ページの動きは SimplePresenter クラスがコントローラに対してキックするという流れにしたいと思います。

ここではコントローラクラスを PageController インタフェース、ページを動きを行わせるメソッドを doAction メソッドとします。

そして、もしページに新たな動きがない場合は doAction メソッドの戻り値を false にするということにします。

これをシーケンス図で表したのが下の図です。

f:id:skrb:20120616013754p:image

このへんはいろいろと、設計が考えられると思うのですが、今回はこれで行きましょう。

コントローラクラスは特にインタフェースを実装しているわけでもないので (実際は Initializable インタフェースを実装している場合が多いのですが、必須ではないです)。ここでは SimplePresenter クラスからコールできるように PageController インタフェースを使用します。

package net.javainthebox.jfx.simplepresenter;

public interface PageController {
    public boolean doAction();
}

では、ここでは例としてデフォルトでイメージが表示されているページに後から、文字を浮きだたせるということをやってみます。

まず FXML の page1.fxml です。

<?xml version="1.0" encoding="UTF-8"?>

<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.image.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.text.*?>

<AnchorPane id="AnchorPane" prefHeight="600.0" prefWidth="800.0" 
            xmlns:fx="http://javafx.com/fxml" 
            fx:controller="net.javainthebox.jfx.simplepresenter.page1">
  <children>
    <ImageView fx:id="p0" fitHeight="600.0" fitWidth="800.0" preserveRatio="true">
      <image>
        <Image url="@page1.jpg" preserveRatio="false" smooth="false" />
      </image>
    </ImageView>
    <Text id="p1" fx:id="p1" fill="WHITE" layoutX="564.0" layoutY="525.0"
                  stroke="WHITE" text="海へ...">
      <font>
        <Font size="64.0" />
      </font>
    </Text>
  </children>
</AnchorPane>

コントローラクラスは fx:controller 属性で指定します。

コントローラとやりとりを行う要素は fx:id 属性で名前をつけておきます。ここでは ImageView と、後から表示する Text に名前を付加しました。イメージが p0、テキストを p1 としています。

では、コントローラクラスの page1.java です。

package net.javainthebox.jfx.simplepresenter;

import java.net.URL;
import java.util.ResourceBundle;
import javafx.animation.FadeTransition;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.image.ImageView;
import javafx.scene.text.Text;
import javafx.util.Duration;

public class page1 implements PageController, Initializable {
    @FXML
    ImageView p0;
    
    @FXML
    Text p1;
    
    @Override
    public void initialize(URL url, ResourceBundle rb) {
        p1.setOpacity(0.0);
    }    

    @Override
    public boolean doAction() {
        if (p1.getOpacity() == 0.0) {
            FadeTransition fadein = new FadeTransition(new Duration(1000L));
            fadein.setNode(p1);
            fadein.setToValue(1.0);
            fadein.play();

            return true;
        } else {
            return false;
        }
    }
}

FXML でシーングラフに貼られると、FXML のすべての要素がシーングラフに追加され、表示されます。

ここでは、後から表示を行うため、デフォルトでは p1 を透明にしておきます。このために起動時にコールされる、initialize メソッドで p1.setOpacity(0.0); をしています。

でも、描画要素がいっぱいあったら、いちいちこうやって書くのは結構めんどう。描画要素がコレクションで管理できれば、こういうベタなことを書かずループで回せるんですけどねぇ。

マウスクリックがあったら doAction メソッドがコールされます。

onAction メソッドでは、FadeTransition クラスを使って、透明な状態から非透明に文字列を表示させます。

そして、SimplePresenter クラスは、今までマウスがクリックされた時、goForward メソッドでページを遷移させていました。これに対し、変更点はマウスクリックされたら、まずは PageController クラスの doAction メソッドをコールする、戻り値が false だったらページを切り替えすることです。

では、SimplePresenter クラスにも doAction メソッドを作りましょう。マウスクリックされたら、この doAction メソッドがコールされます。

    private void doAction() throws IOException {
        // コントローラ側でページ内の動きをつける
        // これ以上アクションがなければ、falseが戻るので
        // ページを進める
        if (!presentController.doAction()) {
            goForward();
        }
    }

コントローラの presentController 変数は FXML ロード時に取得するようにします。

    // 現在表示しているページのコントローラ
    private PageController presentController;

    // ページを進める
    private void goForward() throws IOException {
        // 次のページをロードして、表示する
        URL url = getClass().getResource(pages[pageIndex]);
        FXMLLoader loader  = new FXMLLoader(url);
        Node next = (Node)loader.load();
        root.getChildren().add(next);

        // ページのコントローラを取得
        presentController = loader.getController();
        
        // 前のページが存在していれば、presentに代入
        Node present = null;
        if (root.getChildren().size() > 1) {
            present = root.getChildren().get(0);
        }
        
        // ページ遷移のアニメーションを行う
        translatePage(next, present);
        
        // ページインデックスを進める
        // 最後までいったら最初に戻す
        pageIndex++;
        if (pageIndex >= pages.length) {
            pageIndex = 0;
        }
    }

コントローラは FXMLLoader オブジェクトから取得できるのですが、そのためには static メソッドの FXMLLoader#load メソッドでは取得できません。

そのために、まず FXMLLoader オブジェクトを生成してから、load メソッドをコールするようにしています。

さて、これで実行してみましょう。実行結果を次に示します。

ここまでくるとかなり普通のプレゼンツールに近くなりますね。

ちなみに 3 ページ目に、ちょっと違う動きを入れてみました。プレゼンの最後でツールが消えていくとアニメーションです。

page3.fxml は次のようになります。

<?xml version="1.0" encoding="UTF-8"?>

<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.image.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.text.*?>

<AnchorPane id="AnchorPane" fx:id="pane" prefHeight="600.0" prefWidth="800.0" xmlns:fx="http://javafx.com/fxml" fx:controller="net.javainthebox.jfx.simplepresenter.page3">
  <children>
    <ImageView fx:id="p0" fitHeight="600.0" fitWidth="800.0" preserveRatio="true">
      <image>
        <Image url="@page3.jpg" preserveRatio="false" smooth="false" />
      </image>
    </ImageView>
    <Text fx:id="p1" fill="WHITE" layoutX="551.0" layoutY="321.0" stroke="WHITE" text="日は西に">
      <font>
        <Font size="64.0" fx:id="x1" />
      </font>
    </Text>
    <Text fx:id="p2" fill="WHITE" font="$x1" layoutX="405.0" layoutY="395.0" stroke="WHITE" text="そして日は沈む" />
  </children>
</AnchorPane>

そして、コントローラは...

public class page3 implements PageController, Initializable {

    @FXML
    AnchorPane pane;

    @FXML
    ImageView p0;
    
    @FXML
    Text p1;
    
    @FXML
    Text p2;
    
    private int index = 0;

    @Override
    public void initialize(URL url, ResourceBundle rb) {
        p1.setOpacity(0.0);
        p2.setOpacity(0.0);
    }

    @Override
    public boolean doAction() {
        switch (index) {
            case 0:
                FadeTransition fadein 
                    = new FadeTransition(new Duration(1000L));
                fadein.setNode(p1);
                fadein.setToValue(1.0);
                fadein.play();
                index++;

                return true;
            case 1:
                FadeTransition fadein2 
                    = new FadeTransition(new Duration(1000L));
                fadein2.setNode(p2);
                fadein2.setToValue(1.0);
                fadein2.play();
                
                index++;

                return true;
            case 2:
                Rectangle rect = new Rectangle(0.0, 0.0, 
                                               SimplePresenter.WIDTH,
                                               SimplePresenter.HEIGHT);
                rect.setFill(null);
                pane.getChildren().add(rect);
                
                FillTransition fill
                        = new FillTransition(new Duration(5000L), rect,
                                             Color.rgb(0, 0, 0, 0.0),
                                             Color.web("#000033"));
                
                FadeTransition fadeout 
                    = new FadeTransition(new Duration(1000L));
                fadeout.setNode(pane);
                fadeout.setToValue(0.0);
                
                SequentialTransition sequential 
                    = new SequentialTransition(fill, fadeout);
                sequential.play();
                
                index++;
                
                return true;
            default:
                return false;
        }
    }
}

1 回目と 2 回目のマウスクリックでは、文字列をフェードインさせるアニメーションを使っています。
3 回目のマウスクリックでは全体を覆うような黒い四角形をフェードインさせています。そして、その後、フェードアウトですべて見えなくなるというものです。

このために、最後に何もなくなってしまっているわけです。

ここでは四角形は FXML に記述せずに、コントローラで生成してコンテナに貼っています。FXML にすべてを書かなくてもコントローラに書いてもいいわけですから、ここらへんは柔軟にすればいいと思います。

あっ、でも表示が全部フェードアウトしても、プロセスは残っているので、キルしてあげなくちゃいけませんねww

さて、今回は FXML でやりましたけど、私が実際にプレゼンで使うツールでは SVG を使用しています。

というのも、やっぱり Scene Builder はお絵かきツールにはならないんですよね。せめて、Flash Professional ぐらいの機能があればいいのですが.... 今は Illustrator で書いて、SVG に変換し、それを読み込むということをしています。

ここまでのプレゼンテーションツールは GitHub で公開しました。

https://github.com/skrb/SimplePresenter

JavaFX でプレゼンツール

なぜか急に TwitterJavaFX のプレゼンツールが話題になっていたのでした。で、参考になればということで、自分のプレゼンツールについて書いてみたいと思います。

まずはじめに重要なのが、プレゼンツールとコンテンツは切り離して考えるべきということ。

コンテンツ側で表現しなくてはいけないことと、プレゼンツールで実装しなくてはいけないことをはっきりさせないといけません。

たとえば、文字を整列して表示することなんかはコンテンツ側でやればいいことです。たとえば、@kyon_mm さんは DSL でコンテンツを表現しようとしていますけど、文字列を整列させるのは DSL でやればいいんです。

DSL 書けなければ、最悪イメージでもいいわけですよね。お絵かきツールで文字並べればいいんですから。

@kyon_mm さんがプレゼンツールの要件 を書いてますけど。そこに書かれているほとんどの項目はコンテンツ側の要件であって、プレゼンツールの要件ではないと思うのです。

じゃあ、何がプレゼンツールに必要なのか。

最低限の要件は、「マウスクリックやキーボード入力でページを切り替える」ということだと思います。

後は、プレゼンツールで DSL を使うのであれば、その DSL をパースして表示するという処理を追加すれば OK です。

ということで、まず最低限のプレゼンツールを作ってみましょう。

イメージを切り替えるプレゼンツール

イメージを切り替えるにはどうすればいいのかを考えて見ましょう。

JavaFX はすべての描画要素はシーングラフにで表されます。シーングラフは起動時にすべて生成しておくのでもかまわないのですが、動的に描画要素を追加・削除することもできます。

ということは、マウスクリックされたら、イメージをシーングラフに追加して、その前のイメージを削除すればいいはずです。

たとえば、イメージを pages という配列に、今表示しているページの番号を pageIndex に保持させるとさせましょう。

ここでは page1.jpg、page2.jpg、page3.jpg の 3 つのイメージを切り替えます。

public class SimpleImagePresenter extends Application {
    // 表示するページ
    private String[] pages = {
        "page1.jpg",
        "page2.jpg",
        "page3.jpg"
    };

    // 現在表示しているページ番号
    private int pageIndex;
    
    // シーングラフのルート要素
    private Group root;

後は、ページの切り替えの部分だけです。マウスクリックされたら、goForward メソッドがコールされるとしましょう。

    // ページを進める
    private void goForward() throws IOException {
        // 次のページをロードして、表示する
        String imageURL 
            = getClass().getResource(pages[pageIndex]).toString();
        Image image = new Image(imageURL);
        ImageView page = new ImageView(image);
        root.getChildren().add(page);
        
        // 前のページが存在している場合は、削除する
        if (root.getChildren().size() > 1) {
            root.getChildren().remove(0);
        }
        
        // ページ番号を進める
        // 最後までいったら最初に戻す
        pageIndex++;
        if (pageIndex >= pages.length) {
            pageIndex = 0;
        }
    }

クリックされたら、イメージをロードして、シーングラフに追加します。そして、現在表示しているページがあれば、シーングラフから削除します。if 文で表示しているページがあるかないかを調べているのは、一番始めは表示しているページがないからです。

そして、最後にページ番号を進めます。

これで、基本的な部分はできました。後は Scene オブジェクトなどを生成したりする部分だけです。クラス全体を次に示しておきます。

package net.javainthebox.jfx.simplepresenter;

import java.io.IOException;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.stage.Stage;
import javafx.stage.StageStyle;

public class SimpleImagePresenter extends Application {
    // 表示するページ群
    private String[] pages = {
        "page1.jpg",
        "page2.jpg",
        "page3.jpg"
    };
    
    // 現在表示しているページ番号
    private int pageIndex;
    
    // シーングラフのルート要素
    private Group root;
    
    @Override
    public void start(Stage stage) throws Exception {
        // ステージを透明にする
        stage.initStyle(StageStyle.TRANSPARENT);
        
        root = new Group();
        root.setOnMouseClicked(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                try {
                    // マウスクリックされたら、次のページへ
                    goForward();
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
            }
        });
        
        Scene scene = new Scene(root, 800, 600);
        scene.setFill(null);
        stage.setScene(scene);
        stage.show();
        
        // 最初のページを表示する
        goForward();
    }
    
    // ページを進める
    private void goForward() throws IOException {
        // 次のページをロードして、表示する
        String imageURL 
            = getClass().getResource(pages[pageIndex]).toString();
        Image image = new Image(imageURL);
        ImageView page = new ImageView(image);
        root.getChildren().add(page);

        // 前のページが存在している場合は、削除する
        if (root.getChildren().size() > 1) {
            root.getChildren().remove(0);
        }
        
        // ページインデックスを進める
        // 最後までいったら最初に戻す
        pageIndex++;
        if (pageIndex >= pages.length) {
            pageIndex = 0;
        }
    }
    
    public void main(String... args) {
        launch(args);
    }
}

たかだか、80 行でプレゼンツールができてしまいました。

もちろん、例外処理はいい加減ですし、ページを戻ったりすることもできませんが、これでも十分使えます。

ページ遷移に動きをつける

単にページを切り替えるだけというのはちょっと寂しいので、ページ切り替えの時にアニメーションを付け加えてみましょう。

ここでは、新しいページが左からスライドしてきて、今表示しているページは右にスライドするようにしてみます。

新しいページを next、今のページを present として、アニメーションを行うメソッドを作成しました。

    // ページ遷移アニメーション
    private void translatePage(Node next, final Node present) {
        // 新しいページを右からスライドさせるアニメーション
        TranslateTransition slidein 
                = new TranslateTransition(new Duration(1000));
        slidein.setNode(next);
        slidein.setFromX(WIDTH);
        slidein.setToX(0);
        slidein.play();
        
        if (present != null) {
            // 現在表示しているページがあれば、
            // 左にスライドさせる
            TranslateTransition slideout 
                    = new TranslateTransition(new Duration(1000));
            slideout.setNode(present);
            slideout.setToX(-WIDTH);
            slideout.setOnFinished(new EventHandler<ActionEvent>() {
                @Override
                public void handle(ActionEvent event) {
                    // アニメーションが終了したら、
                    // シーングラフから削除する
                    root.getChildren().remove(present);
                }
            });
            slideout.play();
        }
    }

ページを移動させるのは TranslateTransition クラスを使っています。1 秒で、ページの幅分の移動を行います。

意外と思われるかもしれませんが、アニメーションを同時に行うには ParallelTransition クラスを使わなくても、個々のアニメーションを play() すれば大丈夫です。

もちろん、ちゃんと同期させたい場合は ParallelTransition クラスを使うべきですが、ここではそこまで厳密なアニメーションではないので、これで十分です。

現在表示しているページは、左側にアニメーションした後、シーングラフから削除しています。これを行うために、アニメーションが終了した時にコールされるコールバックメソッドを setOnFinished メソッドで設定します。

present が final なのは、この無名クラスの内部でアクセスするためです。

では、goForward メソッドで translatePage メソッドを呼ぶようにしましょう。

    // ページを進める
    private void goForward() throws IOException {
        // 次のページをロードして、シーングラフに追加する
        String imageURL 
            = getClass().getResource(pages[pageIndex]).toString();
        Image image = new Image(imageURL);
        ImageView next = new ImageView(image);
        root.getChildren().add(next);
        
        // 前のページが存在していれば、presentに代入
        Node present = null;
        if (root.getChildren().size() > 1) {
            present = root.getChildren().get(0);
        }
        
        // ページ遷移のアニメーションを行う
        translatePage(next, present);
        
        // ページインデックスを進める
        // 最後までいったら最初に戻す
        pageIndex++;
        if (pageIndex >= pages.length) {
            pageIndex = 0;
        }
    }

実行すると下のようになります。

どうですか? 結構いけていると思いませんか。

ただ、これでは静的なページしか扱うことができません。つまり、ページの中で動きがあるようなことはできないのです。

たとえば、箇条書きをマウスクリックで順々に表示するということもできません。

これについては、次回やることにしましょう。

複雑なアニメーション

JavaFX はアニメーションを簡単に書けるのですが、いざやってみるとどうやって書けばいいか分からないことも多々あります。意外に多いのが、複数のアニメーションを組み合わせること。

複数のアニメーションを同時に行うのであれば ParallelTransition クラス、1 つ 1 つシーケンシャルに行いたいのであれば SequentialTransition クラスを使うことができます。

ところが、全部同時とか、シーケンシャルというのは少なくて、下の絵のように個々のアニメーションがばらばらなことが多いわけです。

f:id:skrb:20120612210726p:image

こういう場合も、SequentialTransition クラスと ParallelTransition クラスを組み合わせれば書けないことはないのですが、それなりにめんどうです。

じゃあ、どうすればいいかというとメインの時間軸を設定しておいて、そこに各アニメーションをくっつけていくような感じにします。

Flash Professional を使っている人はすぐ分かると思うのですが、これって Flash のタイムラインにレイヤーを作ってムービークリップをそこに設定したり、トゥイーンを設定するのと同じです。

f:id:skrb:20120612212250p:image

では実際にやってみましょう。たとえば、500ms たったら animation1、1000ms たったら animation2 をキックするようにしてみます。

        // 基本となる時間軸
        Timeline timeline = new Timeline(
            new KeyFrame(
                new Duration(500),
                new EventHandler<ActionEvent>() {
                    @Override
                    public void handle(ActionEvent event) {
                        // アニメーション1を開始
                        animation1.play();
                    }
                }             
            ),
            new KeyFrame(
                new Duration(1000),
                new EventHandler<ActionEvent>() {
                    @Override
                    public void handle(ActionEvent event) {
                        // アニメーション2を開始
                        animation2.play();
                    }
                }     
            )
        );

        timeline.play();

こんな感じです。

JavaFX Script の頃はアニメーションの入れ子ができたので、もうちょっと簡単に書けたのですが、しかたありません。でも、Java 8 の Lambda 式を使えるようになれば、もうちょっとスッキリするはず。

実際に動作する例を出しておきましょう。四角と丸がバラバラにアニメーションする例です。

アニメーション自体はたいしたことがないので、どうやって組み合わせているかだけでも見ていただければと思います。

組み合わせているのは

  • 四角のフェードイン
  • 四角の左右の移動
  • 四角の色の変化
  • 丸のフェードイン
  • 丸のサイズの変化
  • 丸のフェードアウト

の 5 つです。

各アニメーションが final になっているのは、EventHandler インタフェースの無名クラスの中で起動しているからです。ここではベタに書きましたけど、もうちょっと工夫すれば final を書かない書き方もできますね。

f:id:skrb:20120612235636p:image

で、こういうようにアニメーションを組み合わせると、こんなのもできるようになるわけです。


JavaFX animation sample - Duke

実際に、このアニメーションをどうやって書いたかは、またいつか書きます。