LoginSignup
13
6

More than 5 years have passed since last update.

[アニメーションチャレンジ] Android の Shared Element Transition と ValueAnimator を使ってクールなアニメーションを書く

Last updated at Posted at 2019-02-23

チャレンジ

フロントエンドの開発者としては、デザイナーが作るクールなレイアウト・アニメーションをできるだけ忠実に再現したいものです。そのためには普段から良いデザインを見て、どういう実装で再現可能かをイメージするトレーニングをしておくべきだと自分に言い聞かせております。最近、Instagram でこんな感じのかっこいいデザインに出会いました。(http://gotinst.gotceleb.com/m/Bt2qC_lnYNL より)

Inspiration.gif

このデザインの中には他にも色々カッコいいところがあるのですが (プログレスバーにスピード感を表すアニメーションが付いていたり、マップボタンが三つのボタンに変化したり等)、本稿ではプログレスバーのラインのアニメーションに注目して、そのエッセンスを再現してみたいと思います。

Path Transition.gif

実装のアイデア

このデザインは二つの異なる画面をアニメーションでつないでいます。一つは ETA とスピードを数字で知らせてくれるメイン画面、もう一つは地図上で現在どこにいるのかということを知らせてくれるマップ画面です。これらを一つの ActivityFragment で書くことは不可能ではないでしょうが悪手でしょう。ここでは一つの Activity (MainActivity) が二つの Fragment (MainFragment/MapFragment) を切り替えるという構成にすることにします。

ラインのアニメーションが二つの Fragment をまたぐということは、独立したラインのインスタンスが各 Fragment に存在することになり、通常であれば連続したアニメーションにすることはできません。しかし、Android API level 21 (Lollipop) から導入された Shared Element Transition を使うと、二つのインスタンスをあたかも同一インスタンスであるかのようにつなぎ合わせることができます。

もう一つはライン自体のアニメーションです。僕がさっと調べた限りでは、二つのランダムな Bezier path をアニメーションでつなぐというようなことは Android ではサポートされていないようです。なのでそこは自前で実装する必要があります。ValueAnimator という昔からある API (>= Android API level 11) を使うと、コールバックでアニメーションカーブに応じた進捗を取得できます(e.g., 現在アニメーションの 20% の段階ですよ〜、と言うようなこと)。この進捗を用いて、最初のパスと最終パスの間を取る形で、現在のパスを描画するという実装ができるでしょう。(イメージ下図)

Path Transform.png

アイデアをまとめると、下図のようになります。

Strategy.png

MainFragment から MapFragment への切り替えの際には、Shared Element Transition を用いてラインインスタンスのスムーズなマッピングを行い、そこから ValueAnimator を用いてラインのアニメーションを行います。ラインは、実体となる path を Drawable として持った View として実装します。MainFragmentMapFragment の間で、ラインを表す View のレイアウトが変化していることに注目してください(上図では該当部分を黄色でハイライトしています)。

以下、実装はすべて Kotlin となります。なお、Shared Element Transition の制約により、コードの実行環境は API Level 21 以上 (Lollipop 以上) となります。

実装

ステップ 1 - ラインを Drawable として実装し、Fragment を切り替える。

ActivityFragment を切り替える方法などはトピックと外れるので本稿では割愛し、このステップではラインを書く実装のみを説明します。

以下、ラインを表す PathDrawable の実装例。コンストラクタで指定された points に従って、canvas に点をつなぐ線を書く、というシンプルな実装です。

// x と y は 0~1 の間の正規化された値。
// こういう実装でなくてもいいと思うが、イメージのサイズの違いを考慮しなくて良いメリットを重視して、
// path の point は (0~1, 0~1) のボックス内で指定することにした。
data class NormalizedPoint(val x: Float, val y: Float)

open class PathDrawable(val points: MutableList<NormalizedPoint>, val context: Context) : Drawable() {

    protected val path: Path = {
        val p = Path()
        p
    }()

    protected val paint: Paint = {
        val p = Paint()
        p.color = ContextCompat.getColor(context, android.R.color.holo_blue_dark)
        p.strokeWidth = 20.0f
        p.style = Paint.Style.STROKE
        p.strokeCap = Paint.Cap.ROUND

        p
    }()

    init {
    }

    override fun setAlpha(alpha: Int) {
    }

    override fun getOpacity(): Int {
        return PixelFormat.TRANSLUCENT
    }

    override fun setColorFilter(colorFilter: ColorFilter?) {
    }

    // Drawable の main メソッド。描画のたびにこれが呼ばれる。
    override fun draw(canvas: Canvas) {
        configurePath()
        canvas.drawPath(path, paint)
    }

    // PathDrawable の肝ロジック。指定された points をもとに、canvas に対して path を構築する。
    protected fun configurePath() {
        path.reset()

        val b = bounds

        points.forEachIndexed { index, point ->
            val x = point.x
            val y = point.y

            if (index == 0) {
                path.moveTo(x * b.width(), y * b.height())
            } else {
                path.lineTo(x * b.width(), y * b.height())
            }
        }
    }
}

二つの Fragment からは以下のように使用します。

val INITIAL_PATH = listOf(
    NormalizedPoint(0.2f, 0.5f),
    NormalizedPoint(0.8f, 0.5f)
)

// In MainFragment/MapFragment
// ...
   val drawable = PathDrawable(INITIAL_PATH.toMutableList(), activity!!.applicationContext)
   view.pathCanvas.background = drawable

これで、割り当てられた View の真ん中に青色の (android.R.color.holo_blue_dark) 水平なラインを表示することができます。しかし、アニメーションが適用されていないため、二つの画面で別々のラインが存在するような印象を持ってしまいます。

成果物:
step1.gif

ステップ 2 - Shared Element Transition を使う

Shared Element Transition を用いて、二つのラインインスタンスをつなげるには、まず二つのインスタンスに ID をアサインして関連付けを明示する必要があります。この ID に当たるものが android:transitionName です。以下のようにして、ラインインスタンスの View にID="PathCanvasTransition" を割り当てることができます。当然ですが、MainFragment/MapFragment のレイアウト両方で指定します。

     <View
             android:id="@+id/pathCanvas"
...
             android:transitionName="PathCanvasTransition"
     />

一旦二つのインスタンスを関連づけてしまえば、あとは fragment 切り替えの部分で「この transition を使いますよ〜」と宣言するだけです。

         val fragmentManager = supportFragmentManager
         val fragment = MapFragment(this)
+
+        val details = TransitionSet()
+        details.addTransition(ChangeBounds())             // animation の種類を指定する。
+        fragment.sharedElementEnterTransition = details
+
         fragmentManager.beginTransaction()
             .replace(R.id.fragmentContainer, fragment)
             // pathCanvas エレメントに関しては、"PathCanvasTransition" を使いますよ〜。
+            .addSharedElement(pathCanvas, "PathCanvasTransition")
             .commit()

成果物:
step2.gif

ラインがフラグメントをまたいでスムーズに移動するようになりました。これで、二つのラインが同じものを表していることをアニメーションによって表現できます。

ステップ 3 - ValueAnimator を使って path のアニメーション

再度イメージ図を掲載しますが、このステップが、直線のラインから地図上のパスとしてのラインへのアニメーションとなります。

Path Transform.png

Step 1 で作った PathDrawable を継承して、animation をサポートする AnimatablePathDrawable クラスを作ります。

class AnimatablePathDrawable(points: MutableList<NormalizedPoint>, context: Context): PathDrawable(points, context),
    ValueAnimator.AnimatorUpdateListener {

    // 上のイメージ図における青い点の集合
    private val startPoints: MutableList<NormalizedPoint> = mutableListOf()
    // 上のイメージ図における赤い点の集合
    private val endPoints: MutableList<NormalizedPoint> = mutableListOf()

    private val animator: ValueAnimator = {
        // ValueAnimator を初期化。ValueAnimator は本来 value の変化を見るので 
        // value がどの値からどの値まで変化するのかを指定して初期化する。
        // ここでは progress しか使わないので、0 => 1 を指定している。
        val animator = ValueAnimator.ofInt(0, 1)

        // 一秒間のアニメーション
        animator!!.duration = 1000

        // Listner は自分自身
        animator!!.addUpdateListener(this)
        animator!!
    }()

    // このクラスの唯一の public メソッド。
    // toEndPoints を指定してこのメソッドを呼び出すことで、
    // 現在の points から toEndPoints へと遷移するアニメーションがスタートする。
    fun startAnimating(toEndPoints: List<NormalizedPoint>) {
        configureStartEndPoints(points, toEndPoints)
        animator.start()
    }

    private fun configureStartEndPoints(fromStartPoints: List<NormalizedPoint>, toEndPoints: List<NormalizedPoint>) {
        // start points と end points から、お互いに対応する点を補完。
        // 詳細は後述。
    }

    // ValueAnimator のコールバックメソッド。
    override fun onAnimationUpdate(animator: ValueAnimator) {
        // 進捗を取得
        val fraction = animator.animatedFraction

        // 進捗から、現在の points を更新。
        points.clear()
        startPoints.forEachIndexed({ index, startPoint ->
            val endPoint = endPoints[index]

            val x = (endPoint.x - startPoint.x) * fraction + startPoint.x
            val y = (endPoint.y - startPoint.y) * fraction + startPoint.y

            points.add(NormalizedPoint(x, y))
        })

        // invalidateSelf() を呼ぶことで、draw() メソッドが呼ばれ、更新された points をもとにラインが描画される。
        invalidateSelf()
    }
}

コメントから流れは終えると思います。一応説明しておくと、

  1. startAnimating() を呼ぶとアニメーションが開始され、
  2. 随時 onAnimateUpdate(...) コールバックが呼ばれます。
  3. コールバックで進捗を取得し、startPoints と endPoints の間の現在地点を計算して points を更新し、
  4. invalidateSelf() を読んで描画する、

という流れです。

あとは、地味に一番大変なのが、start points と end points から、お互いに対応する点を補完するという処理です。(configureStartEndPoints(...)) 下の図で説明できると思いますが、Start のラインの頂点と End のラインの頂点は、アニメーションのために対応する点がお互いのライン上に必要です。そのために線形補間を用いて点の数を増やします。

Interpolation.png

コードは以下の通りです。効率のいいコードではないですが、わかりやすさ重視で。上の図をそのまま実装したロジックです。

private fun configureStartEndPoints(fromStartPoints: List<NormalizedPoint>, toEndPoints: List<NormalizedPoint>) {
        val startLeft = fromStartPoints.first().x
        val endLeft = toEndPoints.first().x

        val startRight = fromStartPoints.last().x
        val endRight = toEndPoints.last().x

        // 縮尺を計算
        val scaleFactor = (endRight - endLeft) / (startRight - startLeft)

        startPoints.clear()
        startPoints.addAll(fromStartPoints)

        endPoints.clear()
        endPoints.addAll(toEndPoints)

        fromStartPoints.forEach {
            val correspondingEndPointX = endLeft + (it.x - startLeft) * scaleFactor
            // スタートライン上の x における y の値を線形補間によって計算
            val correspondingEndPointY = findInterpolatedYIn(toEndPoints, correspondingEndPointX)
            endPoints.add(NormalizedPoint(correspondingEndPointX, correspondingEndPointY))
        }

        toEndPoints.forEach {
            val correspondingStartPointX = startLeft + (it.x - endLeft) * scaleFactor
            // エンドライン上の x における y の値を線形補間によって計算
            val correspondingStartPointY = findInterpolatedYIn(fromStartPoints, correspondingStartPointX)
            startPoints.add(NormalizedPoint(correspondingStartPointX, correspondingStartPointY))
        }

        // この例ではラインは右方向に伸びるものと仮定しているので、x でソーティング。
        startPoints.sortBy { it.x }
        endPoints.sortBy { it.x }
    }

    private fun findInterpolatedYIn(points: List<NormalizedPoint>, x: Float) : Float {

        points.forEachIndexed { index, point ->
            if (point.x == x) {
                return point.y
            } else if (point.x > x) {
                return (point.y - points[index - 1].y) * (x - points[index - 1].x) / (point.x - points[index - 1].x) + points[index - 1].y
            }
        }

        return points.last().y
    }

これらを実装した上で、startAnimating(...) を呼んでやればこのステップは終わりです。

        var drawable = AnimatablePathDrawable(INITIAL_PATH.toMutableList(), activity!!.applicationContext)
        view.pathCanvas.background = drawable

        // 最終的なマップ上のラインがこのようなものであったとする。
        drawable.startAnimating(listOf(
            NormalizedPoint(0.1f, 0.74f),
            NormalizedPoint(0.15f, 0.68f),
            NormalizedPoint(0.26f, 0.69f),
            NormalizedPoint(0.27f, 0.72f),
            NormalizedPoint(0.29f, 0.7f),
            NormalizedPoint(0.35f, 0.72f),
            NormalizedPoint(0.45f, 0.72f),
            NormalizedPoint(0.5f, 0.66f),
            NormalizedPoint(0.7f, 0.6f),
            NormalizedPoint(0.72f, 0.5f),
            NormalizedPoint(0.8f, 0.4f)
        ))

成果物:

step3.gif

だいぶダイナミックな感じが出てきました。

ステップ 4 - フェードイン・フェードアウト

ステップ 3 でほぼ、やりたいことは終わっているのですが、transition の切り替えがいささか急で、ラインのアニメーションがスムーズなのに比べて違和感を覚えます。transition のエフェクト用に、終わり・始まりの Fragment に対して、exitTransition/enterTransition を指定することができます。

val FADE_IN_TRANSITION = {
    val fade = Fade()
    fade.duration = 1000
    fade
}()

val FADE_OUT_TRANSITION = {
    val fade = Fade()
    fade.duration = 200
    fade
}()

として、transition のエフェクトを用意しておき、


         ...
         val fragmentManager = supportFragmentManager
         val fragment = MapFragment(this)

         val details = TransitionSet()
         details.addTransition(ChangeBounds())
         details.setDuration(1000)

+        fragmentManager.fragments.first().exitTransition = FADE_OUT_TRANSITION
+        fragment.enterTransition = FADE_IN_TRANSITION
+
         fragment.sharedElementEnterTransition = details

         fragmentManager.beginTransaction()
             .replace(R.id.fragmentContainer, fragment)
             .addSharedElement(pathCanvas, getString(R.string.path_canvas_transition))
             .commit()

fragment の切り替え時に、exitTransition/enterTransition として指定します。

最終的な成果物がこうなります:

step4.gif

まとめと課題

だいぶ粗い感じではありますが、プログレスバーから別画面のマップ上へのパスのアニメーションというエッセンスは再現できたのではないでしょうか。

使ったテクニック(!?)は

  • Shared Element Transition
  • ValueAnimator
  • enter/exitTransition

です。コードは github においてあり、本稿のステップ 1~4 に対応してタグ付けされているので、興味があれば見てみてください。

マップ上のパスへのアニメーションが出来たとはいうものの、パスは独立した View であり、Google Map の Overlay になっているわけではありません。プログレスバーから GoogleMap の Overlay へ直接アニメーションする手段というのは、現段階では僕は思いつきません。もし、アイデアのある方がいればご示唆・ご教授願えれば嬉しいです。現状、Overlay を表示したければ、最終成果物のマップのタップから Google Map の Direction へ飛ばすような手順を踏む必要があるのかな、と思います。

ステップ 3 の configureStartEndPoints(...) メソッドは、ラインが右(東)方向に伸びるものと仮定していますが、実際マップの Direction であれば、東に行ったり西に行ったりするはずなので、この実装はだいぶ雑ですが、今回はアニメーションにフォーカスを絞っているのでこれくらいで。二次元で真面目に補間すればできるはずです。

iOS だと、Shared Element Transition に対応するものはライブラリに頼らざるを得ないと思いますが、パスのアニメーションはもうちょっと楽にできるんじゃないかと思っていて、そこもいずれ探ってみたいなと思っています。

13
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
6