複数 View の同時アニメーションを DataBinding を用いてシンプルに実装する


複数の View を同時にアニメーションするとき、どのように実装しているでしょうか?
例えば、以下の動画のように▼ボタンを押すとドロアが開くような処理を実装するとします。

この動きを実現するためには、

  1. View A にある ▼ ボタンの回転アニメーション(rotation: 0 → 180 )
  2. View B にかけるマスクのアルファ値のアニメーション(alpha: 0 → 1)
  3. View C の拡大・縮小アニメーション(height: 0dp → 200dp)

という 3 つのアニメーションを同時に行う必要があります。
3つそれぞれの View に対して ViewCompat.animate を用いてアニメーションを実装すると、3つのアニメーションの同期が保たれるように実装する必要が出てきます。ボタンが連打されてアニメーションが途中で中断されることなども考慮するとソースコードが長く複雑になってしまいがちです。

このような悩みを解決するためにはどうすれば良いか考えてみました。
この記事では、DataBinding を用いた実装手法を紹介します。
サンプルで作成したプロジェクトは GitHub に上げてあるので、気になる方は手元で動かしてみてください。

登場人物

サンプルプロジェクトに登場するのは以下の3つのファイルです。

  1. Activity (MainActivity.java): 初期化以外何もしない
  2. Animator オブジェクト (DrawerAnimator.java): アニメーションの状態を表すオブジェクト
  3. Layout XML (activity_main.xml): 状態に応じた View の表示を定義するレイアウトファイル

準備: アニメーションの状態を 0 から 1 に正規化する

前準備として、各 View のアニメーションの状態を1つの変数で表すために、状態変数 t を定義します。
この状態変数 t は
0(=ドロアが完全に閉じた状態)
から
1(=ドロアが完全に開いた状態)
に変化するとします。
そうすると、各 View のアニメーションの状態は状態変数 t を用いて以下の式で表すことができます。

1. View A にある ▼ ボタンの回転アニメーション(rotation: 0 → 180 )

rotation = 180 * t

2. View B にかけるグレーマスクのアルファ値のアニメーション(alpha: 0 → 1)

alpha = t

3. View C の拡大・縮小アニメーション(height: 0dp → 200dp)

height = 200dp * t

このように各 View のアニメーション状態を状態変数 t を用いて表現することによって、
アニメーションを制御する際に状態変数 t だけを気にすれば良くなります。
DataBinding を使うことよりも、状態変数 t という1つの変数で表す方が簡略化するポイントかもしれません。

Layout XML に状態を表す式を記述する

ここからが DataBinding の出番です。
状態変数 t を用いた式を Layout XML に記述していきます。

1. View A にある ▼ ボタンの回転アニメーション(rotation: 0 → 180 )

activity_main.xml
<ImageButton
                    android:id="@+id/imageButton"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:background="@null" android:onClick="@{animator::toggleDrawer}"
                    android:padding="16dp"
                    android:rotation="@{180f*animator.t}"  ここに式を記述しています
                    app:srcCompat="@android:drawable/arrow_down_float"/>

2. View B にかけるグレーマスクのアルファ値のアニメーション(alpha: 0 → 1)

activity_main.xml
<FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:alpha="@{animator.t}"  ここに式を記述しています
            android:background="#88000000">

3. View C の拡大・縮小アニメーション(height: 0dp → 200dp)

activity_main.xml
<FrameLayout
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:background="#BFE9DB"
                app:height="@{animator.t * 200f * animator.density}"  ここに式を記述しています
>

View の高さは回転やアルファとは違い、layout_height に DataBinding を記述することができません。
そのため、カスタムバインディングを用いています。
app:height のバインディングアダプタは以下のように定義しています。

DrawerAnimator.java
@BindingAdapter("height")
    public static void setHeight(View view, float height) {
        ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
        layoutParams.height = (int) height;
        view.setLayoutParams(layoutParams);
    }

これで、Layout XML は 状態 t における View の表示 を定義したものになりました。
続いて、状態変数 t を制御する部分を実装していきます。

Animator オブジェクト で状態変数 t を制御する

ここまでくれば、あとは▼ボタンをタップされた際に、状態変数 t を制御する処理を実装すれば OK です。
以下のソースコードではアニメーション途中に▼ボタンが押された時に不自然にならないように制御する処理を入れていますが、基本的には ValueAnimator で状態変数 t を制御しているだけです。

DrawerAnimator.java
public void toggleDrawer(View imageButton) {
        if (mAnimator != null) {
            mAnimator.cancel();
        }
        float toParam = isOpening ? 0.0f : 1.0f;
        mAnimator = ValueAnimator.ofFloat(mT, toParam);
        mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                float animatedParam = (float) valueAnimator.getAnimatedValue();
                isOpening = mT < animatedParam;
                mT = animatedParam;
                notifyPropertyChanged(BR.t);
            }
        });
        mAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mAnimator = null;
            }
        });
        mAnimator.start();
    }

以下の動画のように、▼ボタンを連打した場合でも自然な動きになっています。

Activity では DataBinding と Animator オブジェクトの初期化をするだけ

状態変数 t における View の表示は Layout XML で定義して、状態変数 t の制御は Animator オブジェクトで定義しました。また、▼ボタンが押されたら Animator の toggleDrawer メソッドを呼ぶ処理も Layout XML で定義されています。
したがって、Activity では DataBiding と Animator オブジェクトの初期化をするだけです。

MainActivity.java
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ActivityMainBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        binding.setAnimator(DrawerAnimator.getInstance(getResources().getDisplayMetrics().density));
    }
}

DrawerAnimator がシングルトンで実装されていることを疑問に思った方もいらっしゃるのではないでしょうか? これは、View の階層構造が複雑な時に効果を発揮します。例えば、Activity の内部にある Fragment 内のアニメーションを、親の Activity 側から制御したい時、シングルトンのインスタンスを取得すれば Activity 側から制御することが可能になります。

まとめ

この記事では、複数 View の同時アニメーションを DataBidingin を用いてシンプルに実装する方法を紹介しました。
筆者が開発を担当したアプリが、View 構造が複雑かつアニメーションがリッチということで、どのように実装するか頭を悩ませていました。また、アニメーションは細かい調整が使い勝手を左右するため、プロダクトオーナーとモノを見ながら細かい調整を重ねて行く必要がありました。
今回紹介した方法で実装したことによって、アニメーションの微調整を柔軟に行いやすくなったと感じました。また、アニメーション関連のバグ件数もリニューアル前より抑えられたように感じます。

この記事を書くためにサンプルで作成したプロジェクトはこちらです。気になる方は手元で動かしてみてください!