この記事はデベロッパー アドボケイト Nick Butcher による Android Developers - Medium の記事 "Motional Intelligence: Build smarter animations" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。



イラスト: Virginia Poltrack

今年の Google I/O では、Android アプリでスマートなアニメーションを記述するいくつかのテクニックについてお話ししました。特に詳しく説明したのが、リアクティブ アーキテクチャが使われている場合にアニメーションをうまく動作させる方法です。

要約すると

32 分の動画は見たくないという方もいるでしょう。そういう方のために、かいつまんで説明します。

#AnimationsMatter

私は、アプリのユーザビリティにとってアニメーションは重要だと考えています。アニメーションを使うと、状態の変化や遷移について説明したり、空間モデルを確立させたり、注目を集めたりすることができます。また、ユーザーはアプリについて理解しやすくなり、ナビゲーションにも役立ちます。



アプリのアニメーション フロー
👈 アニメーションがあるとき、ないとき 👉

この例は、アプリの同じフローを示していますが、左側はアニメーションが有効になっており、右側は無効になっています。アニメーションがないと、何が変化したのか説明がないまま突然状態が変わることになるので、唐突な印象を与えます。
私がアニメーションは重要だと考えるのはそのためですが、一方で、最近のアプリのアーキテクチャの変化によって、アニメーションの実装が難しくなっているとも感じています。一般的には、ほとんどの状態管理機能をビューレイヤーの外に出してコントローラ(ViewModel など)に移し、コントローラが何らかの状態オブジェクト(ビューのレンダリングに必要になるアプリの現在の状態をカプセル化した UiModel など)を発行しています。データモデルで何かが変化すると(ネットワーク リクエストの応答やユーザーが開始したアクションの完了など)、更新された状態全体をカプセル化した新しい UI モデルが発行されます。



ViewModel が状態オブジェクトのストリームを発行する
ViewModel が状態オブジェクトのストリームを発行する

この記事では、このパターンやそのメリットについては特に取り上げません。これらを説明した優秀なリソースはたくさんあります。一方向データフロー(Uni-directional Data Flow)や MVI について検索するか、MvRxMobius などのライブラリを調べてみてください。ここで注目するのは、このストリームのもう一方の端、すなわちビューがモデルのストリームを監視し、それを UI にバインドする部分です。これは、ある新しい状態が与えられると、それを UI に対して完全にバインドする純粋な関数のように思えます。UI の現在の状態のことは考えたくありません。つまり、データを UI にバインドする操作はステートレスであるべきです。しかし、アニメーションはステートフルです。アニメーションとは、時間とともにある値から別の値に遷移させる操作だからです。この点が、本投稿で着目したい本質的な矛盾です。現在、この矛盾のために多くのアプリでアニメーションが削除され、実際にユーザビリティの低下につながっています。
……データを UI にバインドする操作はステートレスであるべきです。しかし、アニメーションはステートフルです

問題は何か

では、どうすればこのリアクティブな世界でアニメーションを維持できるのでしょうか。また、どのような課題に対処しなければならないのでしょうか。これについて具体的に考えてみましょう。ここでは、最小限の例として、ログイン画面を取り上げます。



ログインボタンと進捗インジケーターが表示、非表示されるときにフェードインまたはフェードアウトするログイン画面
ログインボタンと進捗インジケーターが表示、非表示されるときにフェードインまたはフェードアウトするログイン画面

ユーザーがログインを押すと、ログインボタンを隠して進捗インジケーターを表示しますが、その際にログインボタンをフェードアウト、進捗インジケーターをフェードインします。
この画面の状態オブジェクトと(静的)バインディングのロジックは、次のようになります。

この変更を アニメーション させたい場合、最初に考えるのは次のようなコードでしょう。ここでは、alpha プロパティをアニメーションさせようとしています(フェードアウトの場合は、最後に visibility の値もセットします)。

しかし、これは予期しない結果になります。



リアクティブ アプリにアニメーションを追加する際に起きがちな問題
リアクティブ アプリにアニメーションを追加する際に起きがちな問題

ここでは、キーを押すたびに新しい UI モデルが発行されています。しかし、本来表示されるべきではない進捗インジケーターが表示されていることがわかるでしょう。また、送信ボタン(デモ用にアニメーション時間を長くしています)を押すと、ボタンと進捗インジケーターの両方が消えてしまうというおかしな状態になります。この原因は、アニメーションにエンドリスナーなどの副作用があり、それが正しく処理されない点にあります。
リアクティブの世界でアニメーションを記述する場合、アニメーションのコードはいくつかの特性に従わなければなりません。ここでは、その特性を以下のように分類します。
  • リエントラント
  • 連続
  • スムーズ

リエントラント

リエントラント(再入可能)とは、いつでもアニメーションの中断や再呼び出しが可能でなければならないということです。新しい状態オブジェクトがいつ発行されるかわからない場合、実行に新しい状態がバインドされることをすべてのアニメーションで考慮しなければなりません。そのためには、実行中のすべてのアニメーションをキャンセルまたは再ターゲットでき、副作用(リスナーなど)もクリーンアップできる必要があります。

連続

連続とは、アニメーションの対象となる値が突然変化してはいけないことを指します。この特性を説明するために、タッチしたり離したりすると、大きさと色がアニメーションするビューについて考えてみましょう。



タッチすると大きさと色がアニメーションする
タッチすると大きさと色がアニメーションする

アニメーションを最後まで実行すれば何の問題もありませんが、すばやくタップすると大きさや色が突然変化します。これは、バインディングのコードに、「フェードのアニメーションは必ず alpha が 0 の状態から始まる」などの誤った前提が含まれている結果です。

スムーズ

この特性について理解するために、イベントに応答して左上または右上にビューをアニメーションさせる例を考えてみましょう。



スムーズでないアニメーション
スムーズでないアニメーション

すばやく連続して 2 回右上に移動させようとすると、ビューは途中で一度止まってからゆっくりと目的地に向かい続けます。移動中に目的地を変えると、同じように一度止まってから突然方向を変えます。このような突然の停止や方向転換は不自然に見えます。現実の世界では、このように動作するものはないからです。スムーズなアニメーションを実現するには、こういったタイプの動作を避ける必要があります。

修正する

では、先ほどの可視性をバインドする関数に戻り、問題を修正してみましょう。まずは、連続性について見てみます。先ほどの alpha アニメーションは、常に初期値から最終値、たとえばフェードインの場合は 0 から 1 に変化するものでした。代わりに、初期値を省略して最終値のみを与えるようにします。


初期値を省略した場合、アニメーターは現在の値を読み取ってそこから開始します。これこそがまさに実現したいことです。これにより、アニメーションするプロパティの値が突然変わる事態を防ぐことができます。

次に、関数をリエントラントにしていつでも呼び出せるようにします。まず、少しばかり怠けて、不要な作業は行わないようにします。ビューが既に目標値になっている場合は、すぐに処理を終了します。


続いて、新しいアニメーションを始める前にキャンセルできるように、実行中にアニメーターとリスナーを保存する必要があります。論理的に考えれば、これを保存すべき場所はビュー自身ですが、View にはこれを行う便利な仕組みが既に備わっています。それが ViewPropertyAnimator です。これは View.animate() を呼び出した際に返されるオブジェクトで、新しいアニメーションを開始した際に、あるプロパティに対して現在実行されているアニメーションがあれば、それを自動的にキャンセルしてくれるという優れものです。

ViewPropertyAnimator は withEndAction メソッドも提供しています。これは、アニメーションが正常に完了した場合のみ実行され、キャンセルされた場合には実行されません。これも私たちが望む動作そのものです。つまり、新しい目標値が入ってきてアニメーションがキャンセルされた場合でも、副作用(先ほどの可視性の変化など)は起こりません。ViewPropertyAnimator に切り替えることで、関数はリエントラントになります。


ViewPropertyAnimator は、同じプロパティに対して実行中のアニメーションがある場合、そのアニメーションをキャンセルしてから新しいアニメーションを開始すると説明しました。これはスムーズの特性と相反します。そのため、先ほど見たように、アニメーションが突然止まって別のアニメーション(同じ時間で距離が短いアニメーション)が始まることになり、アニメーションがスムーズでなくなる可能性があります。これに対処するため、ほとんどのデベロッパーにとっておなじみではないと思われるアニメーション ライブラリに着目します。

spring による補間

spring は、「dynamic-animation」Jetpack ライブラリの一部です。このライブラリの派手なサンプル アニメーションを見て、これは不要だと思った方は多いかもしれません。派手な効果は役立つこともありますが、常に必要または望まれるとは限りません。しかし、このような派手な動きは 無効にすることもでき、その場合でも物理モデルに基づいたアニメーション システムは使うことができます。このアニメーション システムには、汎用的なアニメーションに役立つたくさんの特性が備わっています。特に便利なのは、中断と再ターゲットです。

先ほどの例に戻りましょう。これを spring アニメーションを使って実装し直すと、スムーズさの問題が起こらなくなります。現在の速度を踏まえつつ、目的地を変えてアニメーションを開始し直してくれるので、スムーズなアニメーションを実現できます。



spring ベースのアニメーションでは、再ターゲットしても速度が維持される
spring ベースのアニメーションでは、再ターゲットしても速度が維持される

SpringAnimation の記述は、通常の Animator の記述とよく似ています。メリットの大半は、start() を呼び出す代わりに animateToFinalPosition メソッドを使用することで得られます。このメソッドは、まだ開始されていない場合はアニメーションを開始しますが、重要なのは、実行中のアニメーションがある場合、突然値を変えるのではなく、勢いを維持したまま新しい目的地に再ターゲットしてくれることです。

残念ながら、spring は View.animate のような便利な View API から使うことはできません(Jetpack のみの機能です)。しかし、次のような拡張関数を作成することはできます。

この拡張関数は、指定された ViewProperty平行移動、回転など)に対する spring を作成または取得し、ビューのタグに保存します。これを使えば、animateToFinalPosition メソッドを使って簡単に実行中のアニメーションを更新できます。可視性をバインドする関数でこれを使うと、次のようになります。


さらに、終了アクションを切り替えて、spring アニメーションのエンドリスナーを使う必要があります。完全なコードはこちらの gist から参照できます。アニメーションをある程度カスタマイズしたい場合もあるでしょう。通常のアニメーションでは時間や補間方法を指定しますが、spring はそれとは異なり、弾性(stiffness)や減衰率(damping ratio)を設定することでカスタマイズします。適切なデフォルト値を指定しつつ、関数の呼び出し元から簡単にカスタマイズできるように、拡張関数を変更してこれらのパラメータを受け取れるようにすることもできます。完全な実装はこちらをご覧ください。

以上で、可視性のバインドはリエントラント、連続、スムーズという特性を備えるようになりました。これを実現するのは大変だと思うかもしれませんが、実際に必要になるのはいくつかのバインド関数だけです。この関数は、アプリ全体で使い回すことができます。この spring テクニックを簡単に使用できるようにパッケージ化したライブラリはこちらです。

アイテム アニメーター

このタイプのアニメーションを使った別の例を見てみましょう。先ほどの原理を RecyclerView.ItemAnimator に適用したものです。



DefaultItemAnimator と spring ベースの ItemAnimator
👈 DefaultItemAnimator と spring ベースの ItemAnimator 👉

この例は、シャッフル ボタンを押してアニメーションを実行している間にデータセットがアップデートされる状況をシミュレートしています。すばやく 2 回ボタンを押した場合、spring ベースのアニメーターのスムーズさはまったく違うことに注目してください。左側では、ボックスが止まってから方向が変わります。右側では、スムーズに方向が変わります。ほとんどのアプリは、ネットワークの複数の場所から情報を読み込んで RecyclerView に表示しているはずです。アニメーションにこのような柔軟性を持たせることで、アプリの洗練度が上がり、はるかにスムーズな体験を実現できるようになります。このタイプのアニメーターを Plaid サンプルに追加した際の PR はこちらです。

スマートなアニメーション

皆さんがリアクティブなアプリにアニメーションを記述する際に、この投稿で説明した原理が役立ち、ユーザビリティの改善につながることを期待しています。実は、この原理は次のような順位のリストで表現できます。



アニメーションのニーズ階層
@crafty によるアニメーションのニーズ階層

リエントラントであるということは、正確であるということです。この特性がないと、アニメーションが壊れる可能性があります。ViewPropertyAnimator を使うか、中断されたり再呼び出しされたりする可能性があることに注意してアニメーションのコードを書くようにします。

連続性とは唐突な変化を避けることで、ユーザー エクスペリエンスの向上につながります。これは、アニメーションのコードから誤った前提を削除し、アニメーション間の引き継ぎを簡単にすることで実現できます。

スムーズさは、ケーキ 🎂 のアイシングのようなものです。アニメーションを自然に見せ、動的な変化や中断、再ターゲットに対応できるようにします。

アニメーションを使うと、アプリは楽しくなるだけでなく、わかりやすくもなります。私はそう確信しています。ぜひこのテクニックを習得し、うまくアプリに組み込めるようにしておきましょう。

Reviewed by Yuichi Araki - Developer Relations Team