MotionLayoutを使ってクリスマスなアプリを作った

# はじめに

この記事は[nana music Advent Calendar 2018](https://qiita.com/advent-calendar/2018/nana-music)の18日目の記事です。

nanaでAndroidエンジニアをしているYoung (@akihito-okada) です。

今回はMotionLayoutを使ってアニメーションを書きつつ、クリスマスなアプリを作ってみました。

# 今回つくるもの

こんな感じのアプリです。

![christmas-toy.gif](https://qiita-image-store.s3.amazonaws.com/0/247380/770f1248-e939-7599-bb45-2376bb0db064.gif)

仕様は以下の通りです。

1. バネの上にキャラクターを乗せる
1. キャラクターは上方向にDragできる
1. バネを伸ばして離すと初めの位置にもどりつつ、bounceさせる
1. キャラクター以外の場所でのDragは抑止する
1. キャラクターのクリックイベントをハンドルする
1. 背景をクロスフェードで切り替える

Githubにソースコードをおいています。
https://github.com/akihito-okada/ChristmasToy

# 実装

### 事前準備

ConstraintLayout 2.0を読み込みます。

```groovy:app/build.gradle
dependencies {
    implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
}
```
### バネの上にキャラクターを乗せる

ConstraintLayoutの属性を使ってバネの上にキャラクターを載せます。

<img width="336" alt="screen" src="https://qiita-image-store.s3.amazonaws.com/0/247380/38050f24-ce5e-51d7-72df-53ac42d10518.png">

```xml:motion_end.xml
<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/motion_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/character"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_marginBottom="50dp"
        android:src="@drawable/image_tonakai"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

    <ImageView
        android:id="@+id/spring"
        android:layout_width="50dp"
        android:layout_height="0dp"
        android:src="@drawable/image_spring"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/character"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.motion.widget.MotionLayout>
```

バネが伸び切ったときの状態も定義します。

```xml:motion_start.xml
<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    app:layoutDescription="@xml/scene_spring_toy"
    android:id="@+id/motionLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/character"
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:layout_marginBottom="250dp"
        android:src="@drawable/image_tonakai"
        android:scaleType="fitXY"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

    <ImageView
        android:id="@+id/spring"
        android:layout_width="50dp"
        android:layout_height="0dp"
        android:src="@drawable/image_spring"
        android:scaleType="fitXY"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/character"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.motion.widget.MotionLayout>
```

### Dragの設定

MotionSceneの中にTransition、OnSwipeを定義します。
OnSwipeを定義することで、期待する方向にDragができるようになります。

```xml:scene_spring_toy.xml
<Transition
    motion:constraintSetStart="@layout/motion_start"
    motion:constraintSetEnd="@layout/motion_end"
    motion:interpolator="bounce"    
    motion:duration="300">

    <OnSwipe
        motion:touchAnchorId="@id/character"
        motion:touchAnchorSide="top"
        motion:dragDirection="dragUp" />

</Transition>
```

また、interpolatorにbounceと、アニメーションのdurationを設定しています。
interporatorを設定するとTransitionのStartからEndに向かって効果がかかります。
このため、Startをバネが縮んだ状態、Endを伸びた状態として設定しています。

初期は縮んだ状態(End)にしたいため、MotionLayoutのprogress(0〜1)を初期化時に1に設定します。
また、キャラクターを離した際、縮んだ状態(End)に戻しつつbounceさせるために、OnTouchListenerのACTION_UPでMotionLayoutのtransitionToEnd()を呼びます。

```kotlin:SpringToyView.kt
motionLayout.also {
    // 初期状態をEndの状態にする
    it.progress = 1F
    it.setOnTouchListener { _, event ->
        if (event.action == android.view.MotionEvent.ACTION_UP) {
            // 初期状態に戻しつつ、bounceさせる
            it.transitionToEnd()
            return@setOnTouchListener true
        }
        false
    }
}
```

### キャラクター以外の場所でDragを抑止する

MotionLayoutで単純に実装をすると、Drag操作はキャラクターのView(Anchorとして指定したView)だけでなく、MotionLayoutの領域全体で有効になります。また、MotionLayoutがイベントを遮ってしまうため、後ろのViewにタッチイベントが伝わりません。
さらに、MotionLayoutの領域内でのみアニメーションが描画されるため、ミニマムに領域を確保すると見切れてしまったり、wrap_contentで動的に領域を確保するとアニメーションが壊れてしまいます。

今回作ってるアプリでは、タッチイベントの重なりがないため問題はありませんが、少し複雑なアプリでは問題になります。このため、キャラクターのViewのみ、Drag操作とタッチイベントを有効にしていきます。

CustomView(SpringToyView)を作成し、その中にMotionLayoutをaddViewします。そして、MotionLayout内にあるキャラクターの領域を判定しつつonInterceptTouchEventを制御することで、キャラクターのタッチイベントのみMotionLayoutに伝えています。

```kotlin:SpringToyView.kt
class SpringToyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : FrameLayout(context, attrs, defStyleAttr) {

    private val touchableArea: View
    private val motionLayout: MotionLayout = LayoutInflater.from(context).inflate(R.layout.motion_start, this, false) as MotionLayout 

    init {
        addView(motionLayout)
        touchableArea = motionLayout.findViewById(R.id.character)
    }

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        // motionLayoutがアニメーション途中
        val isInProgress = (motionLayout.progress > 0.0f && motionLayout.progress < 1.0f)
        // タッチイベントがキャラクターのView内にある時
        val isInAnchor = touchEventInsideAnchorView(touchableArea, ev)
        return if (isInProgress || isInAnchor) {
            // motionLayoutにイベントを流す
            super.onInterceptTouchEvent(ev)
        } else {
            // motionLayoutにイベントを流さない
            true
        }
    }
}
```

### キャラクターのクリックイベントをハンドルする

MotionLayoutにOnTouchListenerを設定し、Clickの判定を行います。
このとき、キャラクターの領域内のタッチイベントのみ流れてくるため、タッチ領域の判定は必要ありません。

```kotlin:SpringToyView.kt
motionLayout.setOnTouchListener(OnTouchListener { _, ev ->
    when (ev.action) {
        MotionEvent.ACTION_DOWN -> {
            startX = ev.x
            startY = ev.y
        }
        MotionEvent.ACTION_UP   -> {
            val endX = ev.x
            val endY = ev.y
            if (isClickedAnchor(startX, endX, startY, endY)) {
                listener?.onClickedAnchor()
                return@OnTouchListener true
            }
            it.transitionToEnd()
            listener?.onTransitionEnd()
            return@OnTouchListener true
        }
    }
    false
})
```

### 背景をクロスフェードで切り替える

せっかくなので、バネを引っ張ったときに、ImageFilterViewを使って背景を切り替えます。

```xml:activity_main.xml
<androidx.constraintlayout.motion.widget.MotionLayout
    android:id="@+id/layoutBackground"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_marginStart="32dp"
    android:layout_marginEnd="32dp"
    android:layout_marginBottom="64dp"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layoutDescription="@xml/scene_image"
    tools:showPaths="true">

    <androidx.constraintlayout.utils.widget.ImageFilterView
        android:id="@+id/imageView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:src="@drawable/image_christmas"
        app:altSrc="@drawable/image_new_year"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.motion.widget.MotionLayout>
```

クロスフェードのTransitionを設定します。

```xml:scene_image.xml
<MotionScene
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="600"
        motion:interpolator="linear" />

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/imageView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintEnd_toEndOf="parent">
            <CustomAttribute
                motion:attributeName="crossfade"
                motion:customFloatValue="0" />
        </Constraint>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/imageView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintEnd_toEndOf="parent">
            <CustomAttribute
                motion:attributeName="crossfade"
                motion:customFloatValue="1" />
        </Constraint>
    </ConstraintSet>

</MotionScene>
```

最後に、キャラクターの操作でImageを切り替えます。

```kotlin:MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // クリスマスなイメージを表示
        layoutLeft.initialize(R.drawable.image_tonakai) {
            layoutBackground.transitionToStart()
        }
        // 新年なイメージを表示
        layoutRight.initialize(R.drawable.image_inoshishi) {
            layoutBackground.transitionToEnd()
        }
    }

    private fun SpringToyView.initialize(characterImageRes: Int, callback: () -> Unit) {
        character.setImageResource(characterImageRes)
        listener = object : SpringToyView.InteractionListener {
            override fun onTransitionEnd() {
                callback.invoke()
            }

            override fun onClickedAnchor() {
                callback.invoke()
            }
        }
    }
```

# おわりに

今回の内容について、社内勉強会でも発表しました。([Speaker Deck](https://speakerdeck.com/akihitookada/motionlayoutwoshi-tutekurisumasunaahuriwozuo-tuta))

姪にこのアプリを見せたところ、初めはめちゃくちゃ楽しそうに触ってましたが、1分もしないうちに飽きてました。

今回作ったアニメーションは、見た目を変えたりすることで、スイッチにするなど応用できそうだなと感じています。

MotionLayoutは、基本的なユースケースから外れると、タッチイベントの制御など対応が難しい部分がありそうかなと感じましたが、少ないコードで簡単にきれいなアニメーションを作れるので、これから使う機会が増えそうですね。

以上です。明日は、@xKxAxKx が書いてくれます！お楽しみに！

# 参考

- Picture-in-picture video overlay with MotionLayout

    https://medium.com/vrt-digital-studio/picture-in-picture-video-overlay-with-motionlayout-a9404663b9e7

- Android のタッチイベントを理解する(その1)

    http://blog.lciel.jp/blog/2013/12/03/android-touch-event/