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

福岡でのプレゼンではいつも通り 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