Android: Lottie Animation を任意の位置とサイズにオーバーレイ(ViewOverlay)表示させる


**2021/12/06：編集メモ：Lottie 3.4.0 では以下の方法でうまく動作していますが、 Lottie 3.6.0 ではうまく動作していないかもしれません。Lottie 3.6.0 以降ではこの記事に記載した方針と ViewGroupOverlay + View (foreground = LottieDrawable) の組み合わせでうまく動かせました。今後あらためて検証する時間が取れたら情報を整理し、 ViewGroupOverlay での対応を記載予定です。**

# TL;DR

* Lottie Animation を ViewOverlay に表示することで、View Tree に影響を与えずにアニメーション表示させる
* ViewOverlay は Android 4.3 以上で使えます

# やりたいことと問題点

Button タップを起点として Lottie でリッチなアニメーションを表示させるとき、レイアウト上の Button のサイズよりも広い範囲にアニメーションを表示させたいことがあります。

たとえば、 Twitter Android アプリのお気に入り Button UI はタップするとハートマークよりも広い範囲にアニメーションが表示されています。

![heart1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/aa690702-f44a-343f-2a8e-f0c1af7736a2.png)

![heart2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/9233cb53-1931-530a-fdb2-821202829979.png)

これを XML レイアウト上で表現すると以下のようなレイアウトとなりますが、この方法では Button の周囲の View と LottieAnimationView が被ることがあったり、見た目上の margin と XML での margin 指定がズレており、レイアウトの配置に苦労します。レイアウトの重なり順にも問題があるため、LottieAnimationView を最前面に移動させる工夫も必要になります。

このレイアウトで A も C もアニメーションを付けることになったら XML が大変なことになりそうです...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/ee8cd0b7-5471-11e0-b30c-cd87487e4e85.png)

アニメーションを XML で表現したときの構成:

```xml
<LinearLayout...>
    <Button.../>
    <FrameLayout...
        android:layout_marginTop="-14dp"
        android:layout_marginStart="-10dp"
        android:layout_marginEnd="-10dp">
        <Button.../>
        <com.airbnb.lottie.LottieAnimationView.../>
    </FrameLayout>
    <Button..../>
</LinearLayout>
```

# 解決策: Lottie Animation を ViewOverlay へ表示する

Android 4.3 以上であれば、ViewOverlay を利用できます。

https://developer.android.com/reference/android/view/ViewGroupOverlay

ViewOverlay は View の最前面に存在しているレイヤーで、任意の View か Drawable を ViewOverlay に表示することができます。
ViewOverlay は View ごとに存在します。ViewOverlay は描画のみに使われるため、タップイベントなどには反応しません。

Activity や Fragment 画面遷移時の Shared Element Transition も ViewOverlay により実現されています。

## ViewOverlay の使い方

* 任意の View に対して `view.overlay` でアクセスできる
* オーバーレイ表示するタイミングで `view.overlay.add(Drawable)` または、 `view.overlay.add(View)` により、描画したい要素を追加する
    * Drawable の描画位置は drawable.bounds で指定する
* オーバーレイ表示が不要になったら、`view.overlay.remove()` で要素を削除する
    * `Any view added to the overlay should be removed when it is no longer needed or no longer visible.` https://developer.android.com/reference/android/view/ViewGroupOverlay#add(android.view.View)
    * 不要になったら削除すべきだそうです

## ViewOverlay + LottieDrawable 実装

後述の PositionedLottieDrawable を用いて、以下のように実装します。

```kotlin
// アニメーションの起点となる Button
private val button: ToggleButton = ...
// オーバーレイ表示したい View。 Button の Parent である場合が多い
private val targetView: ViewGroup = (button.parent as! ViewGroup)
private val drawable = PositionedLottieDrawable()
private val compositionTask: LottieTask<LottieComposition>
init {
    drawable.addAnimatorListener(object: Animator.AnimatorListener {
        override fun onAnimationStart(animation: Animator?) = Unit
        override fun onAnimationEnd(animation: Animator?) {
            // アニメーション終了で Drawable を Overlay から削除
            targetView.overlay.remove(drawable)
            // アニメーション終了後の状態 (Toggle ON/OFF) で表示
            button.alpha = 1f
        }
        override fun onAnimationCancel(animation: Animator?) = Unit
        override fun onAnimationRepeat(animation: Animator?) = Unit
    })
    compositionTask = LottieCompositionFactory
        .fromRawRes(context, R.raw.lottie_animation)
        .addListener {
            // 非同期で Lottie JSON を読み込み、drawable へ設定する
            drawable.composition = it
        }
}
fun clickButton(checked: Boolean) {
    if (checked) {
        // checked = true へ移行するアニメーション処理

        // より安全な実装とするには、ここで drawable.composition が読み込み済みであることを確認してください
        // 読み込みが完了していなければ compositionTask の終了を待ってからアニメーションを実行する必要があります

        // アニメーション中は Button を非表示
        // 表示したままでよければ alpha を変更する必要はない
        // アニメーション中にも Button タップ判定を拾いたいため、visibility ではなく alpha 変更
        button.alpha = 0f
        // アニメーション Drawable の座標を計算する
        // このサンプルでは Button とアニメーションが中央合わせとなるように計算している
        drawable.x = (button.x - (drawable.composition.bounds.width() - button.width) / 2)
        drawable.y = (button.y - (drawable.composition.bounds.height() - button.height) / 2)
        targetView.overlay.add(drawable)
        drawable.playAnimation()
    } else {
        // checked = false へ移行する実装は省略
        // button.alpha やアニメーションの処理を実装する
        // こちらにもアニメーションが必要なら、checked = true のアニメーションを停止してから
        // あたらしくアニメーションを開始したりする
    }
}

```

## PositionedLottieDrawable ワークアラウンド

Lottie 3.6.1 時点で、LottieDrawable は bounds で指定した座標に描画してくれない問題があります。

* Issue #1079 https://github.com/airbnb/lottie-android/issues/1079

以下の PositionedLottieDrawable により、描画座標を修正します。

```kotlin
class PositionedLottieDrawable : LottieDrawable() {
    var x: Float = 0f
    var y: Float = 0f
    override fun draw(canvas: Canvas) {
        canvas.save()
        canvas.translate(x, y)
        super.draw(canvas)
        canvas.restore()
    }
}
```

## ViewOverlay + LottieAnimationView ではうまく動かない

Lottie 3.6.1 時点で、LottieAnimationView は ViewOverlay に追加しても正しくアニメーションしません。

* https://github.com/airbnb/lottie-android/blob/v3.6.1/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java#L613
    * LottieAnimationView.playAnimation() は View.isShown() のときのみアニメーションを開始するように実装されている
    * OverlayView に追加された View は View.isShown() が true となることはないため、アニメーションが開始されることはない