Circular RevealとPaletteによるそれっぽい画面遷移アニメーション

みなさん、アニメーション付けてますか？

画面遷移の際に用いられるアニメーションに[Shared Element](https://developer.android.com/training/transitions/start-activity)というものがあります。
一覧画面から詳細画面に遷移する際には主にこちらが用いられますが、私のケースでは**一覧のサムネイル画像と詳細で表示する画像に差異がある**という要件があったのでShared Elementは不適当でした。

そのため代わりとなる遷移アニメーションはないかと考え、今回の実装を試してみました。

# 通常の画面遷移

まずは何の飾り気もない画面遷移を実装してみます。
分かりやすくするために、デフォルトで設定されているActivityの遷移アニメーションは削除しています。

```activity_main.xml
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/main_image"
        android:layout_width="128dp"
        android:layout_height="128dp"
        android:scaleType="centerCrop"
        android:src="@drawable/image" />

</FrameLayout>
```
```MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<View>(R.id.main_image).setOnClickListener {
            startActivity(Intent(this, SubActivity::class.java))
        }
    }
}
```
```activity_sub.xml
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/sub_image"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop"
        android:src="@drawable/image" />

</FrameLayout>
```
```SubActivity.kt
class SubActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sub)
        // 遷移アニメーションを削除
        overridePendingTransition(0, 0)
    }
}
```

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120327/659d4a46-8e3d-988e-ae70-d47681d40193.gif">
画面がどう遷移しているのか分かりにくいですし、目がチカチカしてユーザに優しくありませんね。

# Circular Revealによる画面遷移

次に[Circular Reveal](https://developer.android.com/training/animation/reveal-or-hide-view#Reveal)によるアニメーションを設定していきます。

```MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<ImageView>(R.id.main_image).setOnClickListener { view ->
            Intent(this, SubActivity::class.java)
                    // 遷移元のViewの中心座標を渡す
                    .putExtra("posX", view.x + (view.width / 2))
                    .putExtra("posY", view.y + (view.height / 2))
                    .let { intent -> startActivity(intent) }
        }
    }
}
```
```SubActivity.kt
class SubActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sub)
        // 遷移アニメーションを削除
        overridePendingTransition(0, 0)

        startAnimation()
    }

    private fun startAnimation() {
        // 遷移元のViewの座標を受け取る
        val posX = intent.getFloatExtra("posX", 0F)
        val posY = intent.getFloatExtra("posY", 0F)

        // Viewの準備ができてからアニメーションを実行する
        findViewById<View>(android.R.id.content).doOnPreDraw { view ->
            ViewAnimationUtils.createCircularReveal(
                    view /* 対象のView */,
                    posX.toInt() /* 中心のX座標 */,
                    posY.toInt() /* 中心のY座標 */,
                    0F /* 開始時の半径 */,
                    Math.max(view.width, view.height).toFloat() * 2 /* 終了時の半径 */
            )
                    // アニメーションにかける時間
                    .apply { duration = 1000 }
                    .start()
        }
    }
}
```

ポイントとなるのは下記の点です。

**・Viewがレイアウトにバインドされてからアニメーションを実行する**
Kotlinの拡張関数である`doOnPreDraw`を使用することで、シンプルに記述しています。

**・遷移元を基準にアニメーションの開始位置を設定する**
今回は遷移元のViewの中心を開始位置としていますが、タップした位置を開始位置にしてみたりするとよりいい感じになるかもしれません。

**・終了時の半径は大きめに設定する**
ここの値が小さいと全体を覆うことができません。今回は雑に長辺の2倍としていますが、ちゃんとやるのであれば公式等を用いて算出するのがよさそうです。

注意点として、今回はアニメーションの挙動を分かりやすくするために時間を１sに設定してあります。
Material Designのガイドライン的には300msほどを推奨しているようですので、参考にするとよいでしょう。
https://material.io/design/motion/speed.html#duration

>Animated elements that traverse a large portion of the screen have the longest durations.

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120327/adf53f1f-1cde-3a3b-c109-7269581f7803.gif">
アニメーションを設定することによって、どこからどこに遷移したのか？というのが分かりやすくなりました。

今回は遷移後の画面で画像が一瞬で表示されていますが、ネットワークから取得する場合など表示まで時間がかかることもあると思います。
そういった場合にどのようになるかを見てみましょう。
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120327/2a09c6a7-a39a-0fce-9ce1-3de4e6bec89e.gif">
悪くはない気もしますが、最初に固定色で覆われてから画像が表示されるのでちょっと変化が激しく感じますね。

# Circular Reveal + Paletteによる画面遷移
変化の度合いを少しでも緩和するために、遷移元の画像の色を用いて遷移先の画像が表示されるまでの間を補完することとします。
この画像から色を取得する処理に[Palette API](https://developer.android.com/training/material/palette-colors)を利用します。

```app/build.gradle
dependencies {

    // Palette
    implementation "androidx.palette:palette:${version}"

}
```
```MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<ImageView>(R.id.main_image).let { imageView ->
            imageView.setOnClickListener { view ->
                imageView.drawable?.toBitmap()?.let { bitmap ->
                    Palette.from(bitmap).generate { palette ->
                        Intent(this, SubActivity::class.java)
                            // 遷移元のViewの中心座標を渡す
                            .putExtra("posX", view.x + (view.width / 2))
                            .putExtra("posY", view.y + (view.height / 2))
                            // 画像の色を渡す
                            .putExtra("color", palette?.getDominantColor(Color.TRANSPARENT))
                            .let { intent -> startActivity(intent) }
                    }
                }
            }
        }
    }
}
```
```SubActivity.kt
class SubActivity : AppCompatActivity() {

    ~~~

    private fun startAnimation() {
        ~~~
        // 遷移元の色を受け取る
        val color = intent.getIntExtra("color", Color.BLACK)
        findViewById<ImageView>(R.id.sub_image).setBackgroundColor(color)
        ~~~
    }
}
```

ポイントとなるのは下記の点です。

**・画像から色を取得する**
Paletteクラスでは様々な種類の色を取得することができますが、今回は`getDominantColor`を使用します。これは画像の主成分となる色を返してくれます。

**・色を遷移後のViewの背景色に指定する**
こうすることで、画像が読み込まれるまでの表示とすることができます。

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120327/83b9b498-a88e-0a54-af7f-bda1348f4579.gif">
ちょっと分かりにくいですが、水の部分の色が広がっていますね。
もう少し分かりやすいものだと下記のようになります。
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120327/39d89557-0e9e-7f47-664c-5f08df755fbc.gif">
**それっぽい！**

# 所感
遷移元と遷移先の種類によって、遷移アニメーションには様々な選択があります。
Circular RevealやPaletteなどでちょっとしたアクセントを付けるだけでも効果はあります。
劇的な変化が望めるようなものではないかもしれませんが、少しでもユーザが使いやすいアプリを追求していきたいものですね！

最後に、アニメーション時間を推奨の300msにしたもので締めとさせていただきます。
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120327/d581c3cb-5d8e-2fad-d312-14071e435a61.gif">
**それっぽい！**