はじめに


みなさん、初めまして。
株式会社AbemaTVでAndroidエンジニアをやっている竹田(@satsukies)です。

17新卒として6月に配属されて、最初に取り組んだタスクが画面遷移のアニメーションの改善検証でした。
AbemaTVでは、画面上に表示される番組コンテンツやビデオコンテンツのサムネイル画像などを、デバイスに最適な形で取得する仕組みを導入しています。
単にネットワークでやりとりするデータ量を削減するだけでなく、画面表示までの待ち時間を短くしてユーザ体験を良くする狙いもあります。

今回、アニメーションの改善検証を通して得た知識を基に、ユーザ体験を維持しながら遷移アニメーションを実装する方法についてお話ししたいと思います。

目次


遷移アニメーションとは


Google Developersのアクティビティ遷移をカスタマイズするを見ると、

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

というように解説されています。上記ページの例でも示されている、リスト画面から選択したアイテムの詳細画面に遷移するような場面などが該当しそうです。

多分Androidユーザが最も良く使うであろうPlayストアアプリには、もちろんこの遷移アニメーションが適用されており、実装に困った際に良く参考にしています。

Playストアアプリでの遷移アニメーションの様子

上記のGIF画像を見ていただくと、リストで選択したアプリケーションのサムネイル画像が、詳細画面でのサムネイルの表示位置へ移動するアニメーションが再生されながら画面遷移が行われていることが確認できると思います。また、詳細画面からリストへ戻る際も、同じようにサムネイル画像に対してアニメーションが付与されていることも確認できますね。このような遷移アニメーションを「Shared Element Transition」と呼びます。API Level 21から利用可能になりましたが、残念ながらSupportライブラリが提供されていません。場合によっては端末のAPI Levelに応じた分岐処理が必要になります。

AbemaTVにも、Playストアアプリと同じような構造を持った画面が存在しています。
そこで、Playストアのような遷移アニメーションを実際に導入できるのか、またアニメーションを適用するとどのような感じになるのか、検証しました。

基本的な実装


まずはShared Element Transitionの基本的な実装についてです。
様々なところで解説がされているので、ここでは簡単に述べて行きます。

windowSharedElementsUseOverlayを有効にする

SharedElementTransitionでは、windowSharedElementsUseOverlayを有効にする必要があります。

コードから

getWindow().setSharedElementsUseOverlay(true);

または、styles.xmlなどで定義されているスタイルに以下を追加します。

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
  <!-- Customize your theme here. -->
  <item name="colorPrimary">@color/colorPrimary</item>
  <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
  <item name="colorAccent">@color/colorAccent</item>

  <!-- 下記の1行を追加-->
  <item name="android:windowSharedElementsUseOverlay">true</item>
</style>

遷移時のアニメーション対象viewを決定

次に、アニメーションをさせたいviewを決めます。
アニメーションさせるviewには、transitionNameに一意に識別可能な文字列が設定されている必要があります。さらに、このtransitionNameは共通の要素で同じ文字列が設定されている必要があります。
どのviewが同じtransitionNameなのか
Playストアで例えると、図中赤枠で囲っているアプリアイコンが共通要素になります。この2つのアイコンが設定されているviewに、同じtransitionNameを設定しなくてはなりません。

transitionNameを設定する方法も2パターンあり、コードからは

//viewのインスタンスメソッド
targetView.setTransitionName("hogehoge");

//ViewCompatクラスのクラスメソッド
ViewCompat.setTransitionName(targetView, "hogehoge")

または、レイアウトxmlファイルでandroid:transitionNameで設定できます。

<ImageView
  android:id="@+id/content_image"
  android:layout_width="144dp"
  android:layout_height="81dp"
  android:layout_margin="8dp"
  android:src="@drawable/image_low"
  android:transitionName="hogehoge"
  />

ActivityOptionsを生成し遷移

transitionNameの設定が済んだら、ActivityOptionsを生成してあげます。生成にはActivityOptions#makeSceneTransitionAnimation()を利用します。

ActivityOptionsCompat compat =
  ActivityOptionsCompat.makeSceneTransitionAnimation(this, targetView,
    targetView.getTransitionName());

`ActivityOptions`が生成できたら、いつもの`startActivity()`の第2引数に渡してあげます。渡す際に、`toBundle()`でBundle型にしてあげる必要がありますので注意です。

startActivity(new Intent(this, DetailView.class), compat.toBundle());

ここまで実装すると、基本的な遷移アニメーションが実現できるはずです。
サンプルのSharedパッケージが、ここまでの手順を実装したものになります。

非同期通信に合わせたチューニング


ここからは、ありがちなシチュエーションとして、アニメーション対象のImageViewへセットする画像の取得を非同期に行う場合を想定してみます。

AbemaTVアプリでは、番組やビデオといったコンテンツごとのサムネイル画像などを弊社のhayabusa.ioというキャッシュサービスを用いて最適化しています。このサービスでは、指定した解像度や画質の画像をキャッシュしてくれます。AbemaTVアプリ内で、前述の解像度や画質などのパラメータを定数として用意し、各ImageViewに適した定数を渡すことで最適化を図っています。

そのため、遷移先に同じ要素(同一の画像)を含んでいるものの、渡されているパラメータが違うためにアプリケーションとしては異なる画像として認識され、画像の読み込みが発生してしまう状況がありました。

サンプルプロジェクトのSharedパッケージに実装されている遷移の例では、遷移先Activityでの画像読み出しが間に合わず黒い背景が見えてしまっています。

画像の読み込みが間に合わず背景の黒がユーザに見えてしまっている

これ以外にも、アニメーション中の画像表示がおかしくなるなどの問題も発生しかねません。実際に検証時には、前述のような挙動を示すことがありました。

これらを解決するためにいくつかのチューニングをしていきます。

画像の取得完了を待ってアニメーションを開始させる


Activityクラスには、アニメーション自動開始の抑止およびアニメーション開始を意図的にコントロールする機構が用意されています。
アニメーション自動開始の抑止はpostponeEnterTransition()を、アニメーションの開始はstartPostponedEnterTransition()を呼びます。

画像の取得完了を検知する方法は、画像の取得方法によって異なります。
一例として、よく採用される画像取得ライブラリであるGlideとPicassoの場合を以下に示します。

ImageView targetImageView = (ImageView)findViewById(R.id.image_header);

//Glideの場合(4.0-RC0)
Glide.with(this)
  .load("http://hogehoge.com/fugafuga")
  .listener(new RequestListener() {
    @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) {
      return false;
    }

    @Override public boolean onResourceReady(Object resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) {
      //遷移アニメーションを開始させる
      startPostponedEnterTransition()
      return false;
    }
  })
  .into(targetImageView);

//Picassoの場合
Picasso.with(this).load("http://hogehoge.com/fugafuga").into(targetImageView, new Callback() {
  @Override
  public void onSuccess() {
    //遷移アニメーションを開始させる
    startPostponedEnterTransition()
  }

  @Override
  public void onError() {
  }
});

このようにすることで、画像が取得されてImageViewにセットされた上でアニメーションが実行されます。結果として、画像が取得されるまで表示されていた黒い背景が表示されなくなりました。

一方で、新たな問題が発生します。
画像が読み込まれるまで遷移アニメーションが開始しないため、レスポンスが確実に悪くなり、場合によってはフリーズしたかのような挙動になってしまいます。
これではユーザ体験を維持しているとは言えないので、レスポンスを改善する必要があります。

一難去ってまた一難…

すでに取得済みの画像を活用する


次なる手のヒントは、キャッシュにありました。
前述したGlideやPicassoには、取得した画像をキャッシュする機能が備わっています。

遷移元のActivityで取得した画像は、ローカルキャッシュとしてメモリ上、もしくはディスク内に保存されているはずです。次に同じURLに対してリクエストが発生したときに、ネットワークへ取得しに行くことなくローカルキャッシュから画像を取り出す処理が行われています。

そこで、遷移元のImageViewに表示した画像の取得URLを保持しておいて、遷移先のImageViewでも再利用します。同じURLであればキャッシュが効いているはずなので、読み込み時間はほぼゼロにすることができます。結果、遷移アニメーションを即時実行できるようになるので、レスポンスが劇的に改善しました。

画像情報を渡す方法としては、IntentにExtraとして乗っけてあげて、遷移先Activityでパースする方法が手っ取り早いです。

private static final String EXTRA_URL = "extra_url";
...
public static void startActivityWithTransition(Context context, ActivityOptionsCompat optionsCompat, String thumbnailUrl) {
  Intent intent = new Intent(this, DetailView.class);

  //渡ってきたurlをextraに詰め込む
  intent.putExtra(EXTRA_URL, thumbnailUrl);
  context.startActivity(intent, optionsCompat.toBundle());
}
...
String imageUrl = getIntent().getStringExtra(EXTRA_URL);

左が改善前で、右が改善後になります。

改善前は遷移アニメーションが開始するまで結構待たされいる 改善後は遷移アニメーションがほぼ即時実行されている

GIFからわかる通り、ユーザがセルをクリックした直後に遷移アニメーションが実行されており、レスポンスが改善しています。

サムネイル機能を活用した段階的な読み込み


キャッシュを活用することで、レスポンスの大幅な改善を行うことができました。
しかし思い出してください。遷移先のActivityで表示したかったのは高画質な画像です。今のままでは、サムネイルに使っていた低画質画像を引き延ばして表示してるに過ぎず、綺麗とは言い難い状態になっています。

この問題は、Glideにおけるサムネイル表示機能、Picassoであれば先述のCallbackを活用して解決可能です。カギは 段階的な読み込み です。
サムネイル表示機能とは、Glideのwiki

You can now load multiple images into the same view at the same time so you can minimize the amount of time your users spend looking at loading spinners without sacrificing quality.

と説明があります。 複数の画像を読み込めるから、ユーザが読み込み中のスピナー(ぐるぐる回っているアレ)を見る時間を最小限にできるよ、品質を損なわずにね。 と言っている気がします。

先ほど、遷移元Activityから画像のURLをもらって表示させることで、キャッシュから高速に読み出して表示できるという説明をしました。では、最終的に表示させたい高画質の画像と、すでにキャッシュしている元画像を同時に読み込ませたらどうなるでしょうか?
きっと、キャッシュされている画像が即表示されて、遅れて高画質画像が読み込まれて表示される、という挙動になるはずです。さらに、キャッシュされている画像の読み込み完了と同時に遷移アニメーションを開始すれば、レスポンスの良さも維持しつつ高画質画像を表示させられますね。

ここまでをまとめると、以下のような処理の流れになります。

低画質から高画質へのシームレスな切り替え

では、この流れに沿って実装してみたいと思います。
と言っても、追加するコードはそんなに多くありません。

Glideで画像を読み込む処理を記述している部分で、GlideRequest#thumbnail()を使いサムネイル読込を有効にします。

//すでにMasterActivityで取得済みであろう画像をサムネイルとして利用する
.thumbnail(Glide.with(imageView.getContext()).load(imageUrl))

thumbnail()の引数部分には、通常の画像読込と同じGlide.with()...とすればOKです。

Picassoの場合、Glideのthumbnail()のようなメソッドありません。上で利用したコールバックをうまく使います。
サムネイル画像を読み込む処理の中にコールバックを設定し、onSuccess内部で高画質画像の読み込み処理を実行するよう実装することで、同等の結果が得られると思います。

ここまでの手順をすべて含んだ実装が、サンプルのAdvancedパッケージにありますので、そちらも参考にしてみてください。

まとめ


今回ユーザ体験を維持した遷移アニメーションの実装ということで、キャッシュを活用した方法を説明しました。非同期通信にアニメーションが加わることで、通常よりも複雑な実装になってしまいます。
開発者としてはなかなかとっつきにくい部分ではありますが、一方でユーザに対して情報の連続性の表現や注視点の誘導ができるなどアドバンテージも多いので、ガイドラインに沿ったアニメーションを積極的に取り入れていければと思います。

サンプルコード

Satoshi Takeda
2017年新卒入社。株式会社AbemaTV所属のAndroidエンジニアです。 Androidデバイスが日本に登場した頃からAndroid開発をやっていて、iOS(Swift)もちょこちょこ。とにかくガジェットが好きです。