Touch で Animation

今日公開された ITpro の連載 で、タッチのジェスチャについて書きました。

記事の中では、イメージをスワイプで切り替えるビューアーのサンプルを書いたのですが、分量の問題でボツにしたサンプルもあります。そのままにするのももったいなので、ここで公開しておきます。

ちなみに、このエントリーは JavaFX Advent Calendar 向けに書こうと思っていたのですが、意外なことにすべて埋まってしまったので、普通のエントリーです ^ ^;;

みなさま、ゾエトロープ (Zoetrope) ってご存じですか?

アニメーションの元祖のような器具で、円筒の内側に連続した絵を描いておいて、回転させながらスリットからのぞき込むと絵が動いているように見えるものです。日本語だと回転のぞき絵というらしいです。

Wikipedia

Zoetrope は円筒を自分の手で回してアニメーションさせるわけですが、これをタッチというかジェスチャでやってみようかなと。

というわけで、作ってみました。

アニメーションさせるのは、おなじみの手を振る Duke です。Duke の画像は duke.kenai.com からダウンロードしてきました。

さて、できあがりの GUI を下に示します。Duke が並んでいますが、この画面を右側にスワイプさせると Duke がアニメーションするようにします。また、左側にスワイプすると、アニメーションが逆転します。

このアニメーションはスワイプのスピードによって速さが決まり、慣性スクロールで指が離れてもしばらくアニメーションが続きます。

Duke の画像を表示しているのは ImageView クラスです。アニメーションさせるには ImageView オブジェクトで表示する Image オブジェクトを切り替えるということをやっています。

ところで、Image オブジェクトは順番に呼び出して、終わりまでいったら最初からということを何度も繰り返すので、先にリングバッファを作ってしまいました。

リングバッファというのはコレクションの一種で、円環状に要素を取得できるコレクションです。これを作るのは簡単で、単純にインデックスを保持しておいて、最後までいったら 0 に戻すということをやっているだけです。

通常はこれでいいのですが、逆向きにも要素を取得できるメソッドを追加しました。

public class RingBuffer<T> {
    private List<T> buffer;
    private int index = -1;
    
    public RingBuffer(Collection<T> collection) {
        buffer = new ArrayList<>(collection);
    }
    
    // 次の要素を取得する
    // 最後まで到達したら、最初に戻る
    public T next() {
        index++;
        if (index >= buffer.size()) {
            index = 0;
        }

        T element = buffer.get(index);
        
        return element;
    }

    // 前の要素を取得する
    // 最初まで到達したら、最後に戻る
    public T previous() {
        index--;
        if (index < 0) {
            index = buffer.size() - 1;
        }

        T element = buffer.get(index);

        return element;
    }
}

さて、準備はできたので、メインクラスを作っていきます。

ちなみに、Stream と Lambda 式使いまくっているので、Java SE 8 必須ですww

まずは、タッチを考えずにイメージを水平方向に並べることにします。

public class Zoetrope extends Application {
    private List<ImageView> views;
    private RingBuffer<Image> images;
    
    @Override
    public void start(Stage stage) {
        HBox root = new HBox(10.0);
        root.setAlignment(Pos.CENTER);
        
        // ImageView オブジェクトを 10 作成して、リストに格納する
        views = IntStream.range(0, 10)
                         .mapToObj(i -> new ImageView())
                         .collect(Collectors.toList());
        root.getChildren().addAll(views);

        // 9個のファイルを読み込んでリストに格納し、それをリングバッファにする
        images = new RingBuffer(
            IntStream.range(1, 10)
                     .mapToObj(i -> new Image(getClass().getResource("resources/T" + i + ".gif").toString()))
                     .collect(Collectors.toList()));

        // ImageオブジェクトをImageViewオブジェクトにセットする
        views.forEach(view -> view.setImage(images.next()));

        Scene scene = new Scene(root, 800, 200);
        stage.setTitle("Zoetrope");
        stage.setScene(scene);
        stage.show();
    }
    
    public static void main(String... args) {
        launch(args);
    }    
}

Java SE 7 までは、for 文を何回も書かなくてはいけなかったのですが、Stream を使うことでとてもスッキリしました。

これからは、for (int i = 0; i < 10; i++) { ... } という文は、IntStream.range(0, 10).forEach(i -> { ... }); というように書くのが当たり前になるでしょうね。

これで実行すると、上の図のようになります。

では、タッチの処理を追加していきましょう。

ITpro の記事でも解説しましたが、タッチはマウスなどと同じようにイベントとして扱います。タッチは TouchEvent で表せますが、ここではジェスチャのイベントで扱うことにします。

ここで行っているジェスチャはスワイプなのですが、スワイプのイベントはスワイプ中に 1 度しか発生しません。ここでは指がどの程度動いたかによって、イメージを切り替えているので、1 度しか発生しないスワイプイベントは使えません。

そこで、代わりに使うのがスクロールイベント ScrollEvent です。

ScrollEvent はスクロールの開始時、スクロール中、スクロール終了時にイベントが発生します。スクロール中は定期的にイベントが発生します。さらに、スクロール終了した (指が離れた) 後にも、慣性でスクロールイベントが発生します。

また、ScrollEvent は都合のいいことに、前回のイベントからの移動分を取得できるようになっています。この移動分を使えば、イメージを切り替えるかどうかの判断に使用することができます。

ここでは、イメージの切り替えを、スクロールを始めてから、しきい値を超えるまで指を移動させたかで判断するようにします。このため、スクロール開始時に移動量を 0 にリセットし、スクロール中には前回からの移動分を移動量に加えていきます。

その移動量がしきい値を超えたら、イメージを切り替えます。

スクロールの開始は Node#setOnScrollStart メソッド、スクロール中は Node#setOnScroll メソッドで登録します。

イベント処理の登録は initScrollEventHandler メソッドでやることにしました。

    private void initScrollEventHandler(HBox root) {
        // スクロール開始時に移動量をリセットしておく
        root.setOnScrollStarted(e -> diff = 0.0);
        
        // 移動量がしきい値 CHANGE_THRESHOLD を超えたらイメージを切り替える
        root.setOnScroll(e -> {
            // 前回からの移動量の差分を加える
            diff += e.getDeltaX();
            
            if (diff >= CHANGE_THRESHOLD) { // 右方向のスクロール
                diff = 0;
                
                // イメージの切り替え
                views.forEach(view -> view.setImage(images.next()));
            }
        });
    }

前回からの水平方向の差分は ScrollEvent クラスの getDeltaX メソッドで取得できます。

Image オブジェクトは 9 個あり、ImageView オブジェクトは 10 個あるので、そのままリングバッファで取得し続けていくと、1 つずつずれるので、アニメーションしてくれます。

ただこれだと、右方向へのスクロールになってしまうので、左方向も加えましょう。

アニメーションを戻すには前のイメージに切り替えることが必要です。そこで、ImageView オブジェクトの右側のものから戻していきます。

    private void initScrollEventHandler(HBox root) {
        // スクロール開始時に移動量をリセットしておく
        root.setOnScrollStarted(e -> diff = 0.0);
        
        // 移動量がしきい値 CHANGE_THRESHOLD を超えたらイメージを切り替える
        root.setOnScroll(e -> {
            // 前回からの移動量の差分を加える
            diff += e.getDeltaX();
            
            if (diff >= CHANGE_THRESHOLD) { // 右方向のスクロール
                diff = 0;
                
                // イメージの切り替え
                views.forEach(view -> view.setImage(images.next()));
            } else if (diff <= -CHANGE_THRESHOLD) { // 左方向のスクロール
                diff = 0;
                
                // イメージを戻すように切り替える
                IntStream.iterate(views.size() - 1, i -> i-1)
                         .limit(views.size())
                         .mapToObj(i -> views.get(i))
                         .forEach(view -> view.setImage(images.previous()));
            }
        });
    }

IntStream#range が開始と最後だけでなく、差分も記述できれば、もっと簡単に書けるのですが、ないのでしかたありません。iterate メソッドだと無限に IntStream が続くので、limit メソッドで要素数を限定し、その後に ImageView オブジェクトを取得して、イメージをセットしていきます。

これで、左方向にスクロールするとアニメーションの逆回転ができます。とはいっても、この Duke のアニメーションは逆回転が分かりにくい ><

実行して、タッチでスクロールさせれば、下の図のようにアニメーションしますよ。タッチのスピードを変えてあげれば、アニメーションのスピードも変化します。

また、慣性スクロールしている間に、再びスクロールすればシームレスにアニメーションし続けます。

いかがですか? けっこう、いい感じにアニメーションしてますよね。

最後にソースの全体を貼っておきます。


Zoetrope: Animation using touch interface