16
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

nana musicAdvent Calendar 2018

Day 18

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

Last updated at Posted at 2018-12-17

はじめに

この記事はnana music Advent Calendar 2018の18日目の記事です。

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

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

今回つくるもの

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

christmas-toy.gif

仕様は以下の通りです。

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

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

実装

事前準備

ConstraintLayout 2.0を読み込みます。

app/build.gradle
dependencies {
    implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
}

バネの上にキャラクターを乗せる

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

screen
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>

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

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ができるようになります。

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()を呼びます。

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に伝えています。

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の判定を行います。
このとき、キャラクターの領域内のタッチイベントのみ流れてくるため、タッチ領域の判定は必要ありません。

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を使って背景を切り替えます。

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を設定します。

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を切り替えます。

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)

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

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

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

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

参考

16
11
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
16
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?