ActivityTransitionって何

Google Developersの「アクティビティ遷移をカスタマイズする」によれば、

マテリアル デザイン アプリの Activity transitions (アクティビティ遷移)では、共通する要素の間での動作や変化を通じて、状態の切り替えに視覚的なつながりを持たせます。

つまり、前後のActivityで共通する要素がある場合などには、スムーズな切り替えを提示してあげよう!ということでしょうか。

注意しなければならないのが、これらを使えるのがAPIレベル21(5.0 Lollipop)以上からであること。バックポートライブラリも提供されてないので、minSdkVersionによっては処理を分岐させる必要がありますね。

ActivityTransitionの種類

アクティビティ遷移にもいくつか種類があります。

Enter

どのようにして画面に登場するか。
Activityが表示される際のアニメーションをつけたい場合に使います。

Exit

どのようにして画面から退場するか。
画面遷移の発生などでActivityから離れる際のアニメーションをつける場合に使います。

Shared Elements

画面上にある共通の要素(例えばImageViewとか)をどのようにアニメーションさせるか。

PlayStoreの例がわかりやすいでしょうか。
playstore_animation.gif
アプリアイコンが前後のAcitivityで共通要素で、画面遷移時にはアイコンが動くようなアニメーションが入ってます。

このようにちょっとしたアニメーションが加わるだけでも、見た目が大きく変わります:muscle:
ガイドに沿って上手に活用すれば、ユーザの注視点をうまく誘導させることができたりと、効果的であることは間違いなさそう:tada:

Shared Elements Transitionの実装

Lv.1: 一番簡単な導入

導入そのものはとても簡単です!

1.WINDOW_CONTENT_TRANSITIONを有効にする

サンプルを実装していて気がついたのですが、これする必要がなさそうなのです:thinking:

参考までに、有効にするには

  • style.xmlなどでwindowContentTransitionstrueにする
<item name="android:windowContentTransitions">true</item>
  • activityからrequestFeature()で有効にする
getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS);

2.アニメーション対象viewを指定する

Shared Element Transitionでは、transitionNameというviewのアトリビュートを使ってどのviewが共通要素なのかを指定しています。このtransitionNameの指定は、レイアウトxmlに直接記述する方法コードから動的に指定する方法があります。

  • レイアウトxmlに直接記述する方法

android:transitionNameに識別可能な文字列を指定する

activity_simple_shared_first.xml
<ImageView
  android:id="@+id/image"
  android:layout_width="96dp"
  android:layout_height="96dp"
  android:src="@mipmap/ic_launcher"
  android:transitionName="image" <-ここが大事
  />
activity_simple_shared_second.xml
<ImageView
  android:id="@+id/image"
  android:layout_width="96dp"
  android:layout_height="96dp"
  android:layout_alignParentEnd="true"
  android:src="@mipmap/ic_launcher"
  android:transitionName="image" <-ここが大事
  />
  • コードから動的に指定する方法

View#setTransitionName()を使う

SimpleSharedCodeFirstActivity.java
//add transition name (= android:transitionName)
binding.image.setTransitionName("image_code");

3.Intentに必要な情報を詰めて遷移先に渡す

アニメーションさせるviewの指定が済んだら、必要な情報をIntentに乗っけます。

必要な情報とはActivityOptionsのことで、ActivityならActivityOptionsAppCompatActivityならActivityOptionsCompatを使います。各クラスに実装されているstaticメソッドmakeSceneTransitionAnimation()ActivityOptionsインスタンスが取得できます。

SimpleSharedCodeFirstActivity.java
ActivityOptionsCompat compat =
    ActivityOptionsCompat.makeSceneTransitionAnimation(this, binding.image,
        binding.image.getTransitionName());

ActivityOptionsCompatの準備ができたらstartActivity(Context,Bundle)で遷移を開始します。

SimpleSharedCodeFirstActivity.java
startActivity(new Intent(this, SimpleSharedCodeSecondActivity.class), compat.toBundle());

ActivityOptionsCompattoBundle()でBundle型にして第2引数に渡します。

サンプル例

下の2つが簡単な導入例です。
transitionsamples/simple_sharedではアニメーション対象viewをレイアウトXMLで指定しています。
transitionsamples/simple_shared_codeではアニメーション対象viewをコードから指定しています。

simple_shared_transition.gif

Lv.2: custom transitionを定義して使う

ここからが本番。
今回、上にあるPlayStoreのgifのようなアニメーションを実現したかったのだけど、問題発生。

適用したいシチュエーション

  • PlayStoreのような一覧から詳細に遷移する場面
  • 遷移元と遷移先には同じ画像が使われている
  • ただし、解像度が違う(遷移元:低画質、遷移先:高画質)ため、遷移時に取得する必要がある
  • 画像はアプリ内にresourceとしてあるのではなく、Glideを用いてサーバから取得する

起きてしまった問題

  • 遷移アニメーション中に画像サイズがおかしくなる
    • じゃあ画像の取得が終わるまで待てばいいじゃん
      • 画像のロードが終わるまで遷移が始まらなくて固まってるように見える
        • かといって低画質画像を使うわけにはいかない
          • うーん困った:innocent:

対策案: PlayStoreをマネてみよう

ストアを見ていると、こんなトランジションを発見。
animation_playstore.gif

あくまで推測ですが、

  • 遷移元のアイコンと遷移先のヘッダー画像は別物で、かつネットワークからの取得が必要
  • 一旦丸い図形(ピンクの丸)に変形させることで、操作への反応速度を保証
  • ヘッダー画像が取れ次第ヘッダー画像をフェードインさせることで、スムーズな遷移を演出

Googleが導いた解を信じ、このアニメーションの実現を目指します。

Transitionのカスタム実装

Google Developersの解説を参考にしながら実装します。

Transitionを継承して自作するときには、少なくとも以下の3メソッドをoverrideします。

snippet
public class CustomTransition extends Transition {

    @Override
    public void captureStartValues(TransitionValues values) {}

    @Override
    public void captureEndValues(TransitionValues values) {}

    @Override
    public Animator createAnimator(ViewGroup sceneRoot,
                                   TransitionValues startValues,
                                   TransitionValues endValues) {}
}

でもこれだけではさっぱりわからないので、Fadeクラスを覗いてみます。

fade.java
...
    @Override
    public void captureStartValues(TransitionValues transitionValues) {
        super.captureStartValues(transitionValues);
        transitionValues.values.put(PROPNAME_TRANSITION_ALPHA,
                transitionValues.view.getTransitionAlpha());
    }
...
    private Animator createAnimation(final View view, float startAlpha, final float endAlpha) {
        if (startAlpha == endAlpha) {
            return null;
        }
        view.setTransitionAlpha(startAlpha);
        final ObjectAnimator anim = ObjectAnimator.ofFloat(view, "transitionAlpha", endAlpha);
        if (DBG) {
            Log.d(LOG_TAG, "Created animator " + anim);
        }
        final FadeAnimatorListener listener = new FadeAnimatorListener(view);
        anim.addListener(listener);
        addListener(new TransitionListenerAdapter() {
            @Override
            public void onTransitionEnd(Transition transition) {
                view.setTransitionAlpha(1);
            }
        });
        return anim;
    }
...

どうやら、captureStartValuesに渡ってきた値を保存して、createAnimationで最終的なアニメーションを生成して返しているっぽいです。

実装したコード全体はgithubで見ていただければと思いますが、重要なのはcreateAnimation内でのアニメーション生成部分。

PlayStoreTransition.java
...
//fadeするアニメーション
Animator fadeOutStartView = ObjectAnimator.ofFloat(startView, View.ALPHA, 1, 0);
Animator fadeInStartBallView = ObjectAnimator.ofFloat(startBallView, View.ALPHA, 0, 1);
Animator fadeInEndView = ObjectAnimator.ofFloat(endView, View.ALPHA, 0, 1);
Animator fadeOutEndBallView = ObjectAnimator.ofFloat(endBallView, View.ALPHA, 1, 0);
...
//移動するアニメーション
//はじめとおわりの位置を計算(対象viewの中央)
Animator moveStartView =
    ObjectAnimator.ofFloat(startView, View.TRANSLATION_X, View.TRANSLATION_Y,
        getPathMotion().getPath(transStartX, transStartY, transEndX, transEndY));
Animator moveStartBallView =
    ObjectAnimator.ofFloat(startBallView, View.TRANSLATION_X, View.TRANSLATION_Y,
        getPathMotion().getPath(transStartX, transStartY, transEndX, transEndY));
...
//step1: 移動元Viewを丸に変形しながら、移動先Viewの中央まで移動しつつ、画像はフェードアウト、背景フェードイン
transitionStep1.playTogether(revealStartView, revealStartBallView, moveStartView,
    moveStartBallView, fadeOutStartView, fadeInStartBallView);
...

ここでの最終的な返り値はAnimator型です。
アニメーション対象のviewや一時的に登場するviewに対してAnimatorでアニメーションを個別に定義してあげ、それらをうまく組み合わせることで一連のアニメーションを実現することができます。個々のAnimatorの使い方については説明を省きますが、viewなどの移動、変形、透過などができます。

今回実装したPlayStoreTransitionは、3つのアニメーションを逐次実行しています。

  1. 遷移元viewを円形の図形に変形&フェードアウトさせながら、遷移先viewの中心へ移動
  2. 円形の図形を遷移先view全体が隠れるまで拡大させる
  3. 遷移先viewをフェードインさせる

実際にアニメーションさせてみます。
playanim_ok.gif
とてもいい感じです。
あとはこれを画像取得のタイミングとうまくリンクさせれば良さそうです。

番外: ステータスバーをアニメーション対象外にしたい

excludeTargetを利用することで、指定したviewを対象外とすることができます。
サンプルでは、XMLで除外定義しています。

exclude_transition.xml
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android">
  <targets>
    <!- ここから ->
    <target android:excludeId="@android:id/statusBarBackground" />
    <target android:excludeId="@android:id/navigationBarBackground" />
    <!- ここまで ->
  </targets>
  <fade />
</transitionSet>

この状態だと、ステータスバーやナビゲーションバーに変化がつかなくなります。
gifのステータスバー(一番上の通知とか出る部分)とナビゲーションバー(一番下のホームキーとかの部分)に注目して見てもらえれば違いがわかると思います
exclude_shared_transition.gif

サンプルコードとか

実装中に、PlayStoreっぽいトランジションの先人がいることに気がつきました。
だいぶ参考にさせていただいてます。
https://github.com/naman14/PlayAnimations

また、https://github.com/satsukies/AnimationSamplesに自分のサンプルコードもあります。

最後に

これだけ説明しておいてアレですが、実はこれではまだ解決しませんでした。

というのも、アニメーションの継ぎ目でviewがちらついてしまったり、隠しているはずの部分が見えてしまったりしていて、このままではバグっぽく見えるのです。
animation_ng2.png
OMG...:innocent:

RAMに余裕のある端末やCPUパワーのある端末では起きにくいですが、実用するとなればそれなりのスペックの端末も存在するので、最適化なり別の方法なりを考える必要がありそう。

参考リンクなど

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.