3Dに縦回転するアニメーションを実装した話

現在作成している自作アプリに、3D方向にviewが回転するアニメーションを取り入れたかったので作成したときの備忘録です。

## やりたかったこと
- viewをタップすると、メンコみたいに縦方向に3D回転にする
- 表面と裏面があり、それぞれでレイアウトが違う
- タップするたびに表面⇄裏面で交互に表示される
- RecyclerViewに表示するViewとして扱いたい


## やったこと
### レイアウト

```xml
<FrameLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <LinearLayout
        android:id="@+id/card"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:layout_marginBottom="15dp"
        android:gravity="center_horizontal">

        <com.google.android.material.card.MaterialCardView
            android:id="@+id/front"
            android:layout_width="match_parent"
            android:layout_height="120dp"
            style="@style/Widget.Material3.App.CardView.Outlined"
            app:cardBackgroundColor="#ffc0cb">
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center">
                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textSize="32sp"
                    android:text="表面"/>
            </LinearLayout>
        </com.google.android.material.card.MaterialCardView>
        
        <com.google.android.material.card.MaterialCardView
            android:id="@+id/back"
            android:layout_width="match_parent"
            android:layout_height="120dp"
            style="@style/Widget.Material3.App.CardView.Outlined"
            android:visibility="gone"
            tools:visibility="visible"
            app:cardBackgroundColor="#e6e6fa">
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center">
                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textSize="32sp"
                    android:text="裏面"/>
            </LinearLayout>
        </com.google.android.material.card.MaterialCardView>
    </LinearLayout>
</FrameLayout>
```
cardという親viewの下に子viewとして、表面(front)と裏面(back)のレイアウトを定義しておき、片方（裏面）をデフォルトで非表示としておきます。
<img width=250 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/472695/0ac28934-2578-d46a-cb51-a458d6f45ce2.png" />


### ロジック
ObjectAnimatorを利用して、3D回転させるようにしました。
https://developer.android.com/guide/topics/graphics/prop-animation?hl=ja#object-animator

```kotlin
fun reverse(card: View, front: View, back: View) {
    card.apply {
        pivotY = height.toFloat()
    }
    val centerY = card.height / 2.0F

    val yFront = ObjectAnimator.ofFloat(card, "y", 0.0f, -centerY).apply {
        duration = 150L
        interpolator = LinearInterpolator()
    }

    val rotateXFront = ObjectAnimator.ofFloat(card, "rotationX", 0.0f, 90.0f).apply {
        duration = 150L
        interpolator = LinearInterpolator()
    }

    val yBack = ObjectAnimator.ofFloat(card, "y", centerY, 0.0f).apply {
        duration = 150L
        interpolator = LinearInterpolator()
    }

    val rotateXBack = ObjectAnimator.ofFloat(card, "rotationX", 270.0f, 360.0f).apply {
        duration = 150L
        interpolator = LinearInterpolator()
    }

    AnimatorSet().apply {
        playTogether(yFront, rotateXFront)
        doOnEnd {
            // 表面と裏面の表示/非表示をチェンジする
            front.isVisible = false
            back.isVisible = true
            card.pivotY = 0.0f
            
            AnimatorSet().apply {
                playTogether(yBack, rotateXBack)
                doOnEnd {
                    card.resetPivot()
                }
                start()
            }
        }
        start()
    }
}
```

共通化とかしていない汚いコードで大変恐縮です🙇‍♂️　が、流れとしては以下の通りです。
1. 表面となるview(front)と裏面となるview(back)、およびそれらを内包しているview(card)を渡します
1. cardのY方向のピボット位置をbottomとします。
1. cardのY方向の中心位置を取得しておきます
1. 表面表示時のアニメーションを定義
    1. 150msかけてYの中心位置まで移動
    1. 同時間かけて縦方向に0°->90°転回
1. 裏面表示時のアニメーションを定義
    1. 150msかけてYのtopまで移動
    1. 同時間かけて縦方向に270°->360°転回
1. `AnimatorSet#playTogether`により、表面の移動処理と回転処理を同時に処理するようにする
1. 表面の処理が済んだ時点（このとき、View自体は90°転回している状態）で、表面と裏面の表示/非表示をチェンジします。
1. 合わせて、cardのY方向のピボット位置をtopに変更します。

最後に、これを呼び出す処理を記載すれば完了です。

```java
binding.front.setOnClickListener(view -> {
    reverse(binding.card, view, binding.back);
});
binding.back.setOnClickListener(view -> {
    reverse(binding.card, view, binding.front);
});
```

タップできる面が表面になっているので、表面がview, 裏面が対になるview(front/back)として先ほどのメソッドを呼び出します。

### 実演
こんな感じで動作します。（gifの都合でカクついていますが、実際は滑らかです）
<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/472695/374f6b29-b219-b67a-6a7f-075c851c47f6.gif" />


## 補足
**ピボットの設定とy方向の移動が必要な理由**
これらを行わない場合、viewが見切れてしまいました。

<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/472695/36905920-56ea-2b86-e208-ad1df2840f6c.png" />

上が元々の状態で下が45°まで回転させたときの様子ですが、横が見切れていることがわかります。

`rotationX`のデフォルトのピボットはviewの中心であり、そこを中心としてviewが拡縮されるようなので、
Yのピボットをviewのbottomとした上で、y方向の移動を併せて行なっています。これにより、viewの中心で回転しているように見せつつ、viewの横が見切れるのを抑えています。
<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/472695/e74dde33-6978-febb-5118-61044d0cbf40.png" />

反転したときはviewのtopをピボットとします
<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/472695/f0843846-fdd6-f421-2339-fac1dd4ecd51.png" />


**裏面の回転が270°->360°の理由**

実際にやってみるとわかりますが、90°->180°にすると上下が反転して表示されてしまいました。
<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/472695/2882ec39-f41d-2552-0ece-f89e864aadd6.png" />
270°->360°にすることで、「元々反転していたものを反転(=元に戻す)」という動作となるため、この問題を解決できます。


今回は以上です。
コメント等お待ちしてます。