追うしょぼちむ

このエントリーは しょぼちむ Advent Calendar 2014 の 8 日目です

昨日は @shiget84 さんの しょぼちむさんをきっかけに価値を届けることについて考えた でした。

明日は @cocoatomo さんです。


はるか昔、neko というアプリケーションがあったのを皆さんごぞんじでしょうか?

wikipedia:Neko (ソフトウェア)

一瞬かわいいのですが、使っているとうざくてしかたないアプリケーションです。

なぜ、こんなアプリケーションを思い出したかというと、うらがみさんにかまって、かまってと言い続けるしょぼちむの存在があったからこそです。

そこで、かつての neko を現代によみがえらせた syobo を作成してみたいと思います。

もちろん、そこは櫻庭が作るものですから、JavaFX です。

ところが、JavaFX では 1 つだけ問題があります。マウスカーソルの位置を検出できないという問題です。

アプリケーションを表示している領域であれば、マウス移動のイベントが取得できるので、マウスカーソルの位置も取得できるのですが、何も表示していない場所ではこのイベントが発生しないのです。

しかたないので、AWT です ><

AWT には MouseInfo というクラスがあり、いつでもマウスカーソルの位置を取得することができます。そこで、タイマで定期的にカーソル位置を取得し、しょぼちむの位置を更新するということを行っていきます。

JavaFX で Swing を扱うのですが、ここでは直接 Swing のコンポーネントを扱うわけではないので、Swing の EDT を使用するだけで大丈夫です。

Swing の Timer クラスで 50 ミリ秒ごとにマウスカーソルの位置を検出するには次のように記述します。

    private void startSwingEDT() {
        // AWTでマウスの位置を 50 秒ごとに検出
        SwingUtilities.invokeLater(() -> {
            Timer timer = new Timer(50, e -> {
                PointerInfo info = MouseInfo.getPointerInfo();
                Platform.runLater(() -> updateLocation(info.getLocation().getX(), info.getLocation().getY()));
            });
            timer.start();
        });
    }

SwingUtilities.invokeLater メソッドで Swing の EDT を起動し、そこでタイマを生成して 50 ミリ秒ごとに処理を行わせています。

MouseInfo クラスの getPinterInfo メソッドマウスカーソルの位置が取得できるので、後は JavaFX のスレッドで位置を更新します。JavaFX のスレッドで処理するためには Platform クラスの runLater メソッドを使用します。

Swing と JavaFX を両方使っていると、スレッドの違いがうざいのですが、しかたありません。

updateLocation メソッドではカーソルの位置に追従して、しょぼちむを表示させます。たいした数学ではないので、分かるでしょう。

    private void updateLocation(double mx, double my) {
        cursor.setTranslateX(mx);
        cursor.setTranslateY(my);

        double tx = syobochim.getTranslateX();
        double ty = syobochim.getTranslateY();

        // 近傍であれば位置の更新を行わない
        double d = (mx - tx) * (mx - tx) + (my - ty) * (my - ty);
        if (d < DISTANCE * DISTANCE) {
            return;
        }

        // カーソルとsyobochimの角度を算出
        double theta = Math.atan2(my - ty, mx - tx);

        // 移動分を算出
        double dx = DISTANCE * Math.cos(theta);
        double dy = DISTANCE * Math.sin(theta);
        syobochim.setTranslateX(tx + dx);
        syobochim.setTranslateY(ty + dy);

        // 角度に応じて回転
        // カーソルの右側にsyobocimが位置している場合は反転
        syobochim.getTransforms().removeIf(trans -> trans.equals(rotate));
        if (theta > PI / 2.0 || theta < -PI / 2.0) {
            syobochim.getTransforms().add(rotate);
            syobochim.setRotate(theta * 180.0 / PI - 180.0);
        } else {
            syobochim.setRotate(theta * 180.0 / PI);
        }
    }

後は、透明ステージにするとか、常にトップにもってくるなどを行ってから表示を行っています。

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        initImage(root);

        // Scene をスクリーンと同サイズに設定
        Screen screen = Screen.getPrimary();
        Scene scene = new Scene(root, screen.getBounds().getWidth(), screen.getBounds().getHeight());
        // 背景を透過にする
        scene.setFill(null);
        // カーソルを表示しない
        scene.setCursor(Cursor.NONE);

        stage.setScene(scene);
        // 透明ステージにする
        stage.initStyle(StageStyle.TRANSPARENT);
        stage.setAlwaysOnTop(true);
        stage.show();

        // Swing の EDT でタイマ処理を行う
        startSwingEDT();
    }

さて、これで完成です。実行してみましょう。

f:id:skrb:20141208211357p:plain

これで、ずっとうらがみさんを追い続けるしょぼちむのできあがりです。

ただ、しょぼちむのイメージはもっと大きくてもよかったなぁ。

それに今回は手抜きなのでちゃんとアニメーションしていないし。時間があったら手や足を動かして、追うようにします。

ついでに、カーソル動かなかったら、寝てしまうというのも取り込みたいなぁ...

ソースは GitHub で公開しています!

GitHub syobo

JavaFX Night でバインドの発表をしてきた

このエントリーは、2014 年の JavaFX Advent Calendar の 1 日目のエントリーです。

明日は alchemicalogic さんです。

blog も半年ぶりなわけですが、それよりも久しぶりの JavaFX の勉強会である JavaFX Night を 11/25 に開催しました。ほぼ 1 年ぶりです ^ ^;;

雨という悪コンディションの中、約 50 人の方にご参加いただきました。もっとキャンセルが多いかと思っていたのですが、うれしい誤算でした。

櫻庭は、バインドについての発表を行いました。

実をいうと資料は使いまわし。7/11 に大阪で よなよな JavaFX というイベントを開催したのですが、そこで話した内容と同じです。

資料はこちら。

Property

バインドは JavaFX 1.x 時代の JavaFX Script では言語でサポートされた機能でした。その当時のバインドは簡単に使えるものだったのですが、JavaFX 2 で JavaFX Script が廃止されたため JavaAPI として書き直されています。

そのため、簡単に使えたバインドが、結構めんどくさくなってしまいました。

その一番のめんどくさいところが、Property。

JavaFX 2 からはバインドできる対象を Property としたのです。Property は値を 1 つだけもてるコレクションのようなものです。Property を使うと、保持している値が変更されたときにイベントが発生します。

このイベントの受け取り方は 2 種類あり、一方が ChangeListner、もう一方が InvalidationListener です。

ChangeListener は値が変更すると、即座にコールされます。それに対して、InvalidationListener は値が変更しても、その値を使わなければコールされません。ようするにイベントの発生が遅延されます。

そして、バインドはこちらの InvalidationListener を使用して実装されています。

バインド

バインドは bind メソッドで行い、両方向のバインドは bindBiDirectional メソッドで行います。

バインドを行うことで、Property 同士の値を自動的に同期させることができます。

その他、Bindings クラスが提供しているさまざまな演算を組み合わせることで複雑な演算や、論理演算もバインドで可能になります。

バインドのユースケース

最後にバインドのユースケースを 4 つ紹介しました。

  • 知らないうちにバインドを使用している
  • View と Controller や、Controller と Model をバインドさせる
  • バリデーションや制約
  • アニメーション

最初の知らないうちというのは TableView などのクラスに関してです。作法に則って記述していれば、いつの間にバインドを使っているはずです。

そして、一番多く使われるのが、2 番目の使い方だと思います。まぁ、普通の使い方ですね。

3 番目の使い方が、意外に便利です。テキストフィールドになにか入力がないとボタンを押せないとか、数値しか入力させないようにするとか、レイアウトにつかうなどです。この用途は、ほんとに使い道いろいろなので、自分でも発掘していこうと思ってます。

最後のアニメーションはおまけです。おととしの Advent Calendar で書いた、irof さんの絵描き歌 を紹介しました。

講演後、バインド使ってみますという感想をいただけたので、よかったのではないかと思います。まだ、バインド使いこなしている人はすくないようなので、ぜひみなさんも使ってみてください!!

ページめくりのアニメーション

福岡でのプレゼンではいつも通り JavaFX のプレゼンツールで行いました。

Slideshare の表紙を見ていただければ分かるかもしれませんが、今回は本をテーマにしたプレゼン資料を作成しました。

なので、資料のページ送りも、紙のページをめくったようなアニメーションで行いました。で、ページ送りの部分だけ独立させてみました。

この動画だと、ページを 3D で動かしているように見えるかもしれませんが、全部 2D です ^ ^;;

そのせいで、いろいろとめんどうなことやってます。

ページのクリッピングのアニメーション

ページは重ねて表示してあり、上に現在表示しているページ、下に次に表示するページとなっています。

まず、行うのは上の現在表示しているページをクリッピングすること。

クリッピングというのは任意のノードで表示領域を制限することです。

単にクリッピングしただけではしかたないので、クリッピングした領域をアニメーションで左に移動させています。こうすることで、ページの右側からだんだんと見えなくなるということが可能になります。

一番上の四角がクリッピング領域でその下のイメージがそれによって右側がクリッピングされます。そして、このクリッピング領域を左の方に移動させるわけです。

このアニメーションは簡単。単に TranslateTransition を使うだけです。

        Node present = group.getChildren().get(group.getChildren().size() - 1);

        // 現在のページのクリップ
        Rectangle presentClip = new Rectangle(0, 0, width, height);
        present.setClip(presentClip);

        // 現在のページのクリップの移動アニメーション
        TranslateTransition presentClipAnim 
            = new TranslateTransition(Duration.millis(DURATION), presentClip);
        presentClipAnim.setToX(-width);
        presentClipAnim.setInterpolator(Interpolator.EASE_IN);

group はページを表示しているコンテナです。

レイアウトされるといろいろと支障があるので、今回のページ送りは Group 限定です。ページの中身は何でもいいので、そちらは適当にレイアウトでも何でもやってください。

で、現在表示されているページが present です。

present をクリップするのは四角系である Rectangle オブジェクト presentClip で、present と同じ大きさで、初期位置は present と同じ場所です。

それを、イメージの幅分だけ左に移動させます。

ポイントは補間方法を EASE IN にすること。

ページをめくる時、めくりはじめはゆっくり動きます。でも、ちょうどページが消える時は、ページが上を向いている時なので、一番速度が速くなっているはず。

なので、はじめゆっくり、後は速くという EASE IN にします。

これを EASE BOTH にしてしまうと、終わりもゆっくりになってしまって、ちぐはぐした印象になります。

これで上のイメージが徐々に消えて、下のイメージが表示されるようになります。

ページの裏

次にやるのは、ページにすけたイメージを表示させること。

ページにすけているので、鏡像になっています。

JavaFX Script の頃は、ノードを複製する機能があったので、簡単に鏡像ができたのですが、JavaFX 2.x になったらなくなってしまいました。

でも、Reflection のエフェクトでは鏡像ができているので、どうにかすればできるはず。

で、どうやったら鏡像ができるかというと、スナップショットを撮りました。それまではノードを複製することばかり考えていたのですが、別にノードでなくてもよくて、イメージさえとれればいいことに気がついたわけです。

スナップショットは Node の snapshot メソッドで取得します。Node クラスのメソッドなので、任意のノードのスナップショットが取得可能です。

戻り値が WritableImage で、引数はスナップショットを撮る時のパラメータと、WritableImage。パラメータは何もしていせず、WritableImage は null で大丈夫です。

        ImageView flipImage = new ImageView(present.snapshot(new SnapshotParameters(), null));

スナップショットをページの裏を作っていきます。

まず、鏡像にするために y 軸を中心に 180 度回転させ、透明度も調整します。透明度を設定しているため、そのままだと下の絵が透けてしまうので、白い四角の上に描画するようにしています。

        // ページ裏
        Group flip = new Group();

        // ページ裏の下地
        Rectangle flipRect = new Rectangle(0, 0, width, height);
        flipRect.setFill(Color.WHITE);
        flip.getChildren().add(flipRect);

        // ページを反転したイメージ
        ImageView flipImage = new ImageView(present.snapshot(new SnapshotParameters(), null));
        flipImage.setRotationAxis(new Point3D(0.0, 1.0, 0.0));
        flipImage.setRotate(180.0);
        flipImage.setOpacity(0.4);
        flip.getChildren().add(flipImage);

        // ページ裏用のクリップ
        Rectangle flipClip = new Rectangle(0, 0, width, height);
        flip.setClip(flipClip);

        group.getChildren().add(flip);

ページの裏のアニメーション

ページ裏が準備できたので、次はページの裏をめくるアニメーションです。

でも、実際はめくっているわけではなくて、単に右から左へ移動させているだけです。しかし、単に移動させるだけでは、めくっているように見えないので、ここでもクリッピングと合わせて移動させます。

つまり、裏返したページの移動 + クリッピング領域の移動 の 2 つのアニメーションでめくっているように見せます。

ポイントは移動の速度が違うこと。裏返しページはクリッピング領域の 2 倍の速度で動かします。

といのも裏返しページの初期位置が異なっているためです。クリッピング領域は始め x 座標が 0 の位置にあります。それに対し、裏返しページの x 座標は width です。

アニメーション後はどちらも、-width の位置になります。つまり、クリッピング領域は width だけ移動しますが、裏返しページは 2×width 移動するわけです。

一番上の四角がページ裏のクリッピング領域、その下がページ裏です。

ページ裏のクリッピング領域は、元のページのクリッピング領域と同じ速度で移動するので、見切れた部分が折り返しているように見えるわけです。

2 つのアニメーションはどちらも TranslateTransition です。

        // ページ裏のアニメーション
        TranslateTransition flipAnim = new TranslateTransition(Duration.millis(DURATION), flip);
        flipAnim.setFromX(width);
        flipAnim.setToX(-width);
        flipAnim.setInterpolator(Interpolator.EASE_IN);

        // ページ裏のクリップのアニメーション
        TranslateTransition clipAnim = new TranslateTransition(Duration.millis(DURATION), flipClip);
        clipAnim.setFromX(-width);
        clipAnim.setToX(0);
        clipAnim.setInterpolator(Interpolator.EASE_IN);

ページ裏のアニメーションは特に問題ないはずです。右側から左側に抜けるので、fromX が width で、toX が -width になります。

問題はクリップ領域のアニメーションです。

fromX が -width で、toX が 0 なのがくせものです。どういうことかというと、クリップする対象のページ裏からの相対位置になるからです。

このアニメーションだけだと右方向に移動しますが、ページ裏が左に移動しているので、クリッピング領域も左に移動します。

さて、ここまででとりあえずページをめくったアニメーションはできました。

もちろん、これで終わりにしてしまってもいいのですが、よりリアリティを持たせるために影を作りましょう。ページとページの間にできる影です。

影は端が透明、真ん中がグレーになっている四角を用意して、それをページの境目に描画することで実現しています。

これは Rectangle と LinearGradient で描画します。

        // ページの間にできる影
        Rectangle shadow = new Rectangle(width - 30, 0, 60, height);
        LinearGradient gradient = new LinearGradient(0, 0, 1, 0, true, CycleMethod.NO_CYCLE,
                new Stop(0.0, Color.rgb(0, 0, 0, 0.0)),
                new Stop(0.5, Color.rgb(0, 0, 0, 0.5)),
                new Stop(1.0, Color.rgb(0, 0, 0, 0.0)));
        shadow.setFill(gradient);
        group.getChildren().add(shadow);

もちろん、ページが動くのと同期して影も動かします。

また、ページのめくり始めは影はないはずです。なので、影を細くしておいて徐々に太くし、さらにページめくりが終わりそうになると再び細くするというアニメーションも行っています。

        // 影が太るアニメーション
        ScaleTransition shadowAnim1 = new ScaleTransition(Duration.millis(DURATION / 5), shadow);
        shadowAnim1.setFromX(0.0);
        shadowAnim1.setToX(1.0);
        shadowAnim1.setInterpolator(Interpolator.EASE_IN);

        // 影の移動アニメーション
        TranslateTransition shadowAnim2 = new TranslateTransition(Duration.millis(DURATION), shadow);
        shadowAnim2.setToX(-width);
        shadowAnim2.setInterpolator(Interpolator.EASE_IN);

        // 影が痩せるアニメーション
        ScaleTransition shadowAnim3 = new ScaleTransition(Duration.millis(DURATION / 5), shadow);
        shadowAnim3.setFromX(1.0);
        shadowAnim3.setToX(0.0);
        shadowAnim3.setInterpolator(Interpolator.EASE_OUT);
        SequentialTransition seqAnim
                = new SequentialTransition(new PauseTransition(Duration.millis(DURATION * 4 / 5)),
                        shadowAnim3);

移動は、今までと同じように TranslateTransition で行います。

影を太らせたり、細くするのは ScaleTransition を使います。ScaleTransition は拡大・縮小を行うアニメーションですが、軸ごとに拡大率・縮小率を設定できます。そのため、ここでは x 軸方向だけ拡大・縮小するということをやっています。

また、影を細くするのはページ送りの最後の方なので、はじめにポースしておいて、その後に細くなるアニメーションを行います。これは指定したアニメーションをシーケンシャルに行う SequentialTransition を使います。

アニメーションの制御

最後にこれらのアニメーションを同時に行うように設定し、アニメーションを開始します。

        ParallelTransition animation = new ParallelTransition(
                presentClipAnim,
                flipAnim,
                clipAnim,
                shadowAnim1,
                shadowAnim2,
                seqAnim);
        
        // アニメーションが終了した時の処理
        animation.setOnFinished(e -> {
            group.getChildren().remove(flip);
            group.getChildren().remove(shadow);

            present.setTranslateX(0);
            present.setClip(null);
            group.getChildren().remove(present);

            group.getChildren().add(0, present);
        });

        animation.play();

アニメーションを同時に行わせるのは ParallelTransition を使用します。コンストラクタで指定したアニメーションを同時に行います。

また、setOnFinished メソッドでは、アニメーションの終了時にコールされるコールバックメソッドをセットできます。

ここでは、ページ裏と影を削除します。

また、現ページも削除します。その際に、移動量を 0 にクリアしておき、またクリップ領域もクリアしておきます。これは削除したページを再利用するためです。再利用しないのであれば、移動量やクリップ領域をクリアする必要はありません。

最後に play メソッドでアニメーションを開始します。

これでページがめくれるわけです。

コードは gist で公開してあります。

https://gist.github.com/skrb/1c62b77ef7ddb3c7adf4


Page Flipping Demonstration

8 True Stories about JavaFX

福岡JavaFX のプレゼンをしてきました。

いまさらですが、8 個は多かった。時間オーバーしてしまいました ><

でも、8 はゴロがいいんですよね。Java SE 8 勉強会だし。

はじめは JavaFX を知らない人向けでだんだんとコード多くする方向で説明しました。でも、最後の Lambda Ready はとってつけたような感じでしたね。

そこそこ楽しんでいただけたようでよかったです。

さて、次は大阪

JavaFX 8 で 3D その 1

このエントリーは、2013 年の JavaFX Advent Calendar の 24 日目のエントリーです。

昨日は id:tikemin さんの iOS7 + RoboVM + JavaFX のイロイロ でした。

明日のクリスマスは小出先生です。

12/13 に久しぶりに JavaFX 勉強会を開催しました。今回は櫻庭は 3D の話をしました。

JavaFX の 3D は Windows, Mac, Linux のいずれでも動作し、2D と 3D を同様にシームレスに扱うことも可能です。しかも、 Java 3D で弱かった、Maya などのツールのファイルの読み込みをサポートしています。

JavaFX の 3D は 2.x でも限定的に使えたのですが、本格的に使えるようになるのは JavaFX 8 からです。すでに 3D が動作する Java SE 8 の Early Access が公開されているので、すぐに試すことができます。

セッションでは 30 分しかなかったので、かなり話題を絞って以下の 3 つの話題にしました。

  • 座標
  • Model
  • Light

とりあえず、ここらへんを押さえておけば、なんとかなるでしょう。

座標

3D を行うのに欠かせないのが、座標です。もちろん、JavaFX でも 3 次元の座標を扱うのですが、ちょっとだけ注意が必要です。

というのも、通常の 3D の CG Tool は y 軸が数学と同じで上方向を向いています。ところが、JavaFX では 2D と 3D を区別なく使えるようにしているためなのか、y 軸が下を向いています。

ツールで作ったモデルをインポートしたら、なぜかモデルが下を向いていたということもあるので、注意が必要です。

もし、下向きの y 軸がいやなのであれば、z 軸を中心にして 180 度回転させます。

2D の座標は Point2D クラスで表しますが、3D は Point3D クラスで表します。

Node クラスのたとえば、移動の API には setTranslateX/setTranslateY/setTranslateZ というように 3 つが組になっているので、移動や回転、スケーリングなどは 2D と同じように行うことができます。

そして、もう 1 つ座標に関連して、3D で重要なのがカメラです。

カメラは、どこから空間に配置しているモデルを見るかということを表しています。

カメラには 2 種類あります。

光が広がっていかないカメラと、広がっていくカメラです。前者を直投影、後者を透視投影と呼びます。

直答英は 2D の世界です。奥行きがあっても、同じ大きさの物体は奥行き方向の位置が違っていても、同じ大きさになります。こちらは ParallelCamera クラスで表します。

一方の透視投影は、いわゆる一転透視法で遠いものは 1 点に修練していきます。こちらは PerspectiveCamera クラスで表します。

もちろん、デフォルトは ParallelCamera クラスです。

カメラをセットするには Scene クラスに対して setCamera メソッドで行います。たとえば、立方体を PerspectiveCamera クラスで見るには、次のようになります。

public class PerspectiveCameraDemo extends Application {

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        
        // 辺の長さが20の立方体
        Box box = new Box(20, 20, 20);
        // 立方体を y 軸を中心に 30 度回転させる
        box.setRotationAxis(new Point3D(0.0, 1.0, 0.0));
        box.setRotate(30.0);
        root.getChildren().add(box);
        
        Scene scene = new Scene(root, 400, 400);
        scene.setFill(Color.BLACK);
        
        // 透視投影カメラを設定する
        PerspectiveCamera camera = new PerspectiveCamera(true);
        scene.setCamera(camera);

        // カメラの位置を (0, 0, -100) にする
        camera.setTranslateZ(-100.0);
        
        stage.setScene(scene);
        stage.setTitle("Perspective Camera Demo");
        stage.show();
    }

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

PerspectiveCamera クラスのコンストラクタの引数に true を設定すると、カメラの位置が原点に配置されます。この時、カメラの向きは z 軸の正の方向になります。

カメラもノードのサブクラスなので、setTranslateX/setTranslateY/setTranslateZ メソッドで任意の位置に移動することができます。ここでは、z 軸方向に -100 移動させているので、カメラの位置は (0, 0, -100) になります。

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

一点透視になるので、手前が大きく表示されます。

これに対し、ParallelCame クラスで立方体を表示させてみましょう。

public class ParallelCameraDemo extends Application {

    @Override
    public void start(Stage stage) {
        StackPane root = new StackPane();
        
        // 辺の長さが20の立方体
        Box box = new Box(150, 150, 150);
        // 立方体を y 軸を中心に 30 度回転させる
        box.setRotationAxis(new Point3D(0.0, 1.0, 0.0));
        box.setRotate(30.0);
        root.getChildren().add(box);
        
        Scene scene = new Scene(root, 400, 400);
        scene.setFill(Color.BLACK);
        
        // 直投影カメラを設定する
        ParallelCamera camera = new ParallelCamera();
        scene.setCamera(camera);
        
        stage.setScene(scene);
        stage.setTitle("Parallel Camera Demo");
        stage.show();
    }

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

同じようなサイズになるように、立方体のサイズなどを若干調整しています。

直投影だと全然立方体に見えません。というより、真ん中がくびれているように見えてしまうのが不思議なところです。

長くなってしまったので、続きはまた今度。


つづく

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

シェイプのストローク

このエントリーは、2013 年の JavaFX Advent Calendar の 4 日目のエントリーです。

昨日はるーつにゃんの JavaFXでめくるエフェクト!!

明日は sipadan2003 さんです。

JavaFX でシェイプを扱っていると、ストロークに関連したプロパティがいろいろあるのに気がつきます。

色や太さはすぐに分かりますけど、それ以外はなじみがないものが多いのではないでしょうか。Illustrator とか使っている人であれば分かるとは思いますけど。

ということで、ここで少しまとめてみましょう。

ソースは gist にあげました。

まずは、こんなパスを描いておきましょう。

    private Path drawPath() {
        Path path = new Path(
                new MoveTo(40.0, 40.0),
                new LineTo(140.0, 40.0),
                new LineTo(40.0, 80.0)
        );

        return path;
    }

カタカナのフのような形です。このパスについては特に難しいところはないはずです。

では、プロパティをいろいろと設定していきましょう。まずは、線色と線の太さから。

  • Shape#setStrokeWidth(double) 線の太さ
  • Shape#setStroke(Paint) 線の色

これは簡単ですね。setStrokeWidth メソッドは引数が double ですが、もちろん正の値だけです。

setStroke メソッドは線色を設定します。線色の方は Paint クラスなので、グラデーションもできます。

ちなみに、デフォルトでは太さは 1、色は Color.BLACK です。

        // デフォルト (線幅: 1, 線色: 黒)
        Path path = drawPath();
        parent.add(path, 0, 0);
        
        // 線幅: 10
        path = drawPath();
        path.setStrokeWidth(10.0);
        parent.add(path, 1, 0);

        // 線幅: 10, 線色: 赤
        path = drawPath();
        path.setStrokeWidth(10.0);
        path.setStroke(Color.RED);
        parent.add(path, 2, 0);

この 3 種類を実行すると次のようになります。

さて、これからがよく分からなくなるところです。

まず、setStrokeLineJoin メソッドです。ストロークのラインがジョインするメソッドです。何のこっちゃという感じですが、角の部分をどのようにするかを指定するためのメソッドです。

setStrokeLineJoin メソッドの引数は enum の StrokeLineJoin です。この enum は 3 つの値をとります。

MITER はマイター接続は線をそのまま延ばしていって交わったところまで伸ばす接続です。ようするに、とんがっている接続ですね。これがデフォルトになります。

ROUND (ラウンド接続) は MITER とは逆に接続部が丸くなっている接続方法です。

BEVEL (ベベル接続) は直線の角と角を結んだような接続です。

これらの接続は線が細い時は目立たないのですが、太いと目立ちます。太い線が集まっていたりすると、MITER だと線がはみ出てしまうこともあるので、ROUND か BEVEL にする方がいいと思います。

ということで、ここでは太さ 10 の線を引いてみます。

    private Path drawThickPath() {
        Path path = drawPath();
        path.setFill(Color.PINK);
        path.setStroke(Color.BLACK);

        // 線の太さを10にする
        path.setStrokeWidth(20.0);

        return path;
    }

で、3 種類の接続を試してみましょう。分かりやすいように細い白い線を一緒に描画します。

        // 角のデフォルト (マイター接合)
        Group group = new Group();
        path = drawThickPath();
        path.setStrokeLineJoin(StrokeLineJoin.MITER);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(group);
        parent.add(group, 0, 2);

        // 角を丸くする (ラウンド接合)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineJoin(StrokeLineJoin.ROUND);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(group);
        parent.add(group, 1, 2);

        // 角を削る (ベベル結合)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineJoin(StrokeLineJoin.BEVEL);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(group);
        parent.add(group, 2, 2);

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

つづいて、線端です。線端も 3 種類あります。

線端は setStrokeLineCap メソッドで設定します。引数は、こちらも enum の StrokeLineCap です。

  • SQUARE
  • ROUND
  • BUTT

SQUARE はもともとの線の線端から線の太さの分だけ四角が飛び出ているような感じです。ROUND は同じように線端が円になっています。

最後の BUTT は端がブチッと切れてしまった感じの線端になります。

これも試してみましょう。

        // 線端のデフォルト (スクエア線端)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineCap(StrokeLineCap.SQUARE);
        group.getChildren().add(path);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(path);
        parent.add(group, 0, 4);

        // 線端を丸くする (ラウンド線端)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineCap(StrokeLineCap.ROUND);
        group.getChildren().add(path);
        
        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(path);
        parent.add(group, 1, 4);

        // 線端を四角くする (BUTT線端)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineCap(StrokeLineCap.BUTT);
        group.getChildren().add(path);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(path);
        parent.add(group, 2, 4);

ここでも分かりやすいように、太い線の上に線幅1の白い線を描画してあります。

最後は点線です。

点線はちょっと分かりにくいんですよね。

点線にするには、getStrokeDashArray メソッドを使います。あれっ、get と思いますよね。設定するのに、get を使うというと、JavaFX では ObservableList を返すことが多いのです。

ここでも、getStrokeDashArray メソッドでは ObservableList オブジェクトが帰ります。この ObservableList に要素を入れているいくと点線になるのです。

たとえば、10.0 の ObservableList だとします。この場合、線が 10 ピクセル、空白が 10 ピクセルの点線になります。

10.0 と 5.0 が要素の場合、線が 10 ピクセル、空白が 5.0 ピクセルになります。

10, 2, 5, 2 が要素の場合、線が 10、空白 2 線が 5、空白 2 の続きます。つまり、一点鎖線になるわけです。

        // 点線 (線とスペースの長さが同じ)
        path = drawPath();
        path.getStrokeDashArray().addAll(10.0);
        path.setStrokeWidth(5.0);
        parent.add(path, 0, 6);

        // 点線 (線とスペースの長さが異なる)
        path = drawPath();
        path.getStrokeDashArray().addAll(10.0, 5.0);
        path.setStrokeWidth(5.0);
        parent.add(path, 1, 6);

        // 一点鎖線
        path = drawPath();
        path.getStrokeDashArray().addAll(10.0, 2.0, 5.0, 2.0);
        path.setStrokeWidth(5.0);
        parent.add(path, 2, 6);

すると、このようになります。

要素数が偶数だと線の長さから始まって、空白の長さで終わるので分かりやすいですけど、偶数で活けないわけではありません。ただし、奇数だとちょっと分かりにくいのです。

たとえば、10, 2, 5 だと、線 10、空白 2、線 5、空白 10、線 2、空白 5 という繰り返しになります。というように、ちょっと分かりにくいので、要素が 1 つにするか、偶数という方が分かりやすいと思います。

ということで、ちょっと地味なエントリーでした。

最後にソース全体です。


gist7761966