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

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

第 7 回 JavaFX 勉強会やります

2012/07/02 に久しぶりに JavaFX 勉強会を行います!!

ほんとは 2 月ぐらいにやりたかったのですが、なかなか時間ができなくて....

今回はツールの特集ということでやろうと思っています。JavaFX のツールといえば、NetBeans と Scene Builder。

NetBeans はもうすぐ 7.2 がリリースされる予定で、Scene Builder との連携など JavaFX についてもサポートが強化されています。そこらへんのことを NetBeans の第一人者である片貝さんに喋っていただくことにしました。

そして、Scene Builder の方は私が喋ります。Scene Builder は GUI をグラフィカルに構築するためにツールで、ユーザのターゲットはデザイナーです。

なるべく初心者の方から経験者の方まで楽しめるセッションにしたいと思っています。

これ以外に LT もあります。

@mike_neck さんは鹿駆動勉強会のリベンジで JavaFXJavaScript のテストについて、@aoetk さんは Mac での JavaFX について。

そして、なんと寺田さんが JavaFX について LT してくれます。これは期待できますね。

みなさま、ぜひご参加ください。登録は ATND のページ からお願いします。

また、懇親会もあるので、こちらにもぜひご参加ください。もちろん、懇親会だけというのもありですよww 懇親会の登録は 懇親会登録ページ からお願いします。

JJUG CCC 2012 Spring

JJUG CCC では Swing から JavaFXマイグレーションガイドという観点でプレゼンしてきました。

Swing と JavaFXJavaソースコードレベルではそれほど違いはありません。しかし、いくつかはまりポイントもあります。

たとえば、イベントやバインド。

イベントは、Swing/AWT ではイベントに対応したリスナが存在しましたが、JavaFX ではリスナというはハンドラが 1 つあるだけです。1 つだけのハンドラ (EventHandler インタフェース) ですべてのイベントに対応するために、ジェネリクスでイベントを指定します。

また、たとえば Swing の MouseListener では mouseClicked メソッドや、mousePressed メソッドなど、リスナに複数のメソッドが定義されていました。これに対し、JavaFX ではハンドラを登録する Node クラスに onMouseClicked メソッドや onMousePressed メソッドなどが定義されています。このため、EventHandler インタフェースには handle メソッドだけが定義されています。

EventHandler インタフェースにメソッドが 1 つだけ定義されているということは、Java SE 8 で導入されるラムダ式でも記述可能ということです。イベント処理の登録は冗長な記述になるので、ラムダで書けるのはうれしいところです。

バインドは Swing には無い概念です。

変数 x と変数 y があった時、x を y にバインドすると、y の値が変更すると x も自動的に同期して値が変更する機能をバインドと呼びます。

JavaFX ではこの機能を導入するために、Java Bean を拡張したプロパティという機能を導入しています。プロパティ同志であれば、バインドができるようになっています。

バインドを使う典型的な例が MVC のそれぞれを結びつける場面です。イベントやオブザーバパターンを使っていたのが、バインドだとかなりすっきり書くことができます。

さて、その後、3 つのシナリオで Swing から JavaFX への移行を考えてみました。

はじめが Swing のアプリケーション中に JavaFX を埋め込む場合、次が Swing から JavaFXJava を使って移行する場合、最後に FXML を使う場合です。

はじめのシナリオは、Swing にはない JavaFX の機能を使用したい場合に有効です。たとえば、Web ブラウザやグラフ (チャート) などがあります。

2 番目のシナリオは、Swing をかける人であれば、それほど苦労せずに JavaFX を書けるようになるはずです。ただし、レイアウトの用に Swing と JavaFX で作法が違うものや、テーブルのようにまったく書き方が違うものがあるので、注意は必要です。

やはりお勧めは FXML です。

Java で書くと、シーングラフのツリー構造がソースからは読みにくいですし、すぐに複雑になってしまいます。FXML であれば XML なので、ツリー構造を表すにはうってつけです。

ただし、FXML はツールを使わないと、書くのがつらいのも事実。ということで、最後は Scene Builder のデモでした。

Swing をやる人に伝わったかなぁ?

鹿駆動勉強会

JavaFX つながりで @hakurai さんが奈良で勉強会をするというので、すかさず参加すると宣言したのが 1 月のこと。それがあれよあれよという間に、能楽堂でやる 鹿駆動勉強会 ということになってしまいました。

まぁ、楽しければいいんですけどね。

他の人よりも少しだけ長めの 15 分をいただいて、JavaFX のことをしゃべってきました。

といっても、15 分しかないので、どういうことができるかを中心に、あまり内容に深入りせずに広く浅くという感じです。

今回は、最後の部分で BGM がかぶってしまってぐちゃぐちゃになってしまったのが失敗でした。普段は、1 時間ぐらいしゃべるのが普通なので、BGM を複数用意してあったとしても、その間の時間間隔は十分にあるため、かぶるということを気にしないでも大丈夫だったのですが...

時間が短い LT では、ちゃんとそのあたりまだプレゼンの設計をしなくてはダメですね。まだまだ修業が足りません。

JavaOne Tokyo 2012

JavaOne のレポートは本家の blog に書いたので、そちらをご覧ください (1 日目2 日目)。

で、JavaOne では 2 つのセッションを担当させていただきました。1 つが Java SE 7 の NIO.2 について、もう 1 つが JavaFX でした。

とはいっても、JavaFX の普通のセッションは絶対あるだろうということで、Oracle の人が話しそうにもない内容で Call for Papers に出したのでした。その思惑があたったのか、JavaOne で話すことになったわけです。

ということで、JavaFX といいつつ FXML と CSS が中心のセッションを行いました。FXML: 8、CSS: 2 ぐらいの割合です。

なぜ XML なのかというのは、JavaFX 1.x の頃でいえばなぜ宣言的文法なのかと同じです。やっぱり Java だと GUI を書きにくいんですよね。そこが伝われば、櫻庭としては御の字です。

JavaFX + JUnit で JavaScript のユニットテストをする その 2

昨日のエントリー は、思っていたよりも反響を呼んでしまってビックリしている櫻庭です。

今日はちょっとだけ追加。

というのも、Jenkins の川口さん ( id:kkawa ) から次のようなことを聞かれたからです。


<p id="addtext"></p>

そして、HTML でボタンなどをクリックした時にコールされる関数が次のようなものだったとします。

function addText(arg) {
    var element = document.getElementById('addtext');
    element.innerText = arg;
}

タグにテキストを追加するという関数です。

これを WebEngine を使って、コールすることを考えます。基本的には昨日の手法と同じです。

では、DummyApplication クラスに addTextTest メソッドを追加します。

    public Object addTextTest(Object obj) throws InterruptedException {
        arguments.put(obj);        

        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                try {
                    Object obj = arguments.take();
                    engine.executeScript("addText('"+obj+"');");
                    
                    Document document = engine.getDocument();
                    Element element = document.getElementById("addtext");
                    results.put(element.getTextContent());
                } catch (InterruptedException ex) {}
            }
        });
            
        return results.take();
    }

昨日は WebEngine クラスの executeScript メソッドの戻り値をキューに入れていたのですが、今日はそうではありません。

その代わりに、DOM の要素を取得して、そのコンテンツをキューに入れています。

JUnit のテストコードでは次のように記述しました。

    @Test
    public void addTextTest() throws InterruptedException {
        System.out.println("addTextTest");
        
        String expected = "Hello, World!";
        String result = (String)dummy.addTextTest(expected);
        assertThat(result, is(expected));

        expected = "";
        result = (String)dummy.addTextTest(expected);
        assertThat(result, is(expected));
    }

このようにすれば、DOM のチェックもできるので、DOM を操作するタイプの JavaScript の関数でもテストできます。

そういえば、昨日は 1 つのテストしか実行していなかったので、 @Before アノテーションJavaFX の EDT を立ち上げて、@After アノテーションで EDT をシャットダウンするので動作していました。

でも、複数のテストを行いのであれば、@BeforeClass で EDT を立ち上げて、@AfterClass で EDT をシャットダウンしなくてはダメですね。

さて、川口さんには DOM のテストもできるとツィートしたのですが、その後再び川口さんから返信。


package test;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javafx.application.Application;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;

import static org.junit.Assert.assertThat;
import static org.hamcrest.CoreMatchers.is;

public class JSTest {
    private static DummyApplication dummy;
    private static ExecutorService service;
    
    public JSTest() {}

    @BeforeClass
    public static void setUpClass() throws Exception {
        service = Executors.newFixedThreadPool(1);
        service.submit(new Runnable() {
            @Override
            public void run() {
                Application.launch(test.DummyApplication.class);
            }
        });
        
        Thread.sleep(1000L);
        
        dummy = DummyApplication.getInstance();
    }

    @AfterClass
    public static void tearDownClass() {
        service.shutdown();
        dummy.shutdown();
    }

    @Test
    public void numberTest() throws InterruptedException {
        System.out.println("numberTest");
        
        Integer result = (Integer)dummy.numberTest(new Integer(3));
        assertThat(result, is(new Integer(4)));

        result = (Integer)dummy.numberTest("23");
        assertThat(result, is(new Integer(24)));
    }

    @Test
    public void addTextTest() throws InterruptedException {
        System.out.println("addTextTest");
        
        String expected = "Hello, World!";
        String result = (String)dummy.addTextTest(expected);
        assertThat(result, is(expected));

        expected = "";
        result = (String)dummy.addTextTest(expected);
        assertThat(result, is(expected));
    }
}

次に DummyApplication.java です。

package test;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.web.WebEngine;
import javafx.stage.Stage;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

public class DummyApplication extends Application {
    private static DummyApplication instance;
    
    private WebEngine engine;
    private BlockingQueue<Object> arguments = new LinkedBlockingQueue<>(); 
    private BlockingQueue<Object> results = new LinkedBlockingQueue<>(); 
    
    @Override
    public void start(Stage stage) throws Exception {
        instance = this;

        engine = new WebEngine();
        engine.load( [index.htmlのURL] );
    }
    
    public static DummyApplication getInstance() {
        return instance;
    }
    
    public void shutdown() {
        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                Platform.exit();
            }
        });
    }

    public Object numberTest(Object obj) throws InterruptedException {
        arguments.put(obj);        

        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                try {
                    Object obj = arguments.take();
                    Object result = (Integer)engine.executeScript("numberTest("+obj+");");
                    results.put(result);
                } catch (InterruptedException ex) {}
            }
        });
            
        return results.take();
    }

    public Object addTextTest(Object obj) throws InterruptedException {
        arguments.put(obj);        

        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                try {
                    Object obj = arguments.take();
                    engine.executeScript("addText('"+obj+"');");
                    
                    Document document = engine.getDocument();
                    Element element = document.getElementById("addtext");
                    results.put(element.getTextContent());
                } catch (InterruptedException ex) {}
            }
        });
            
        return results.take();
    }
}