ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

CoordinatorLayoutでMotionLayoutアニメーションを実現する(Android)

こんにちは、tkyです。

前回はMotionLayoutを使って実践で使えそうなアニメーションを実装しました。

今回も実践で使えそうなアニメーションを実装してみようと思います。

こちら

f:id:ticktakclock:20191231132129g:plain:w200
デモ

画面スクロール時にタイトルのテキストがアニメーションするようなMotionLayoutです。

前回同様のGitHubリポジトリとなります。 Sample10 をご確認ください。

github.com

レイアウト

CoordinatorLayoutなのでここは特に変わらずです。AppBarのレイアウト(layout/motion_10_header)が今回MotionLayoutに対応させることになります。

body側はただのTextViewなので特に触れません。GitHub参照ということで。。。

layout/motion_10.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="false">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="180dp"
        android:theme="@style/AppTheme.AppBarOverlay">

        <include layout="@layout/motion_10_header" />

    </com.google.android.material.appbar.AppBarLayout>

    <include layout="@layout/motion_10_body" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

AppBar

これがMotionLayoutのレイアウトになりますが、ポイントは Sample10MotionLayout というMotionLayoutを継承したカスタムMotionLayoutクラスを使っているというところです。

Sample10MotionLayout については後述します。

あとは表示したいUIを配置していきます。今回の場合、

  • 背景に使うImageView(写真は適当に準備しています)
  • タイトルTextView
  • サブタイトルTextView
<?xml version="1.0" encoding="utf-8"?>
<com.github.ticktakclock.motionlayoutsample.Sample10MotionLayout
    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"
    tools:showPaths="true"
    android:id="@+id/motionLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:minHeight="50dp"
    app:layoutDescription="@xml/scene_10"
    app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed">


    <ImageView
        android:id="@+id/background"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:scaleType="centerCrop"
        app:srcCompat="@drawable/public_domain_photo_2"
        tools:layout_editor_absoluteX="0dp"
        tools:layout_editor_absoluteY="0dp" />

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/sample_10_title"
        android:textColor="@android:color/white"
        android:textSize="24sp"
        tools:layout_editor_absoluteX="173dp"
        tools:layout_editor_absoluteY="263dp" />

    <TextView
        android:id="@+id/title_sub"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/sample_10_sub_title"
        android:textColor="@android:color/white"
        tools:layout_editor_absoluteX="184dp"
        tools:layout_editor_absoluteY="348dp" />
</com.github.ticktakclock.motionlayoutsample.Sample10MotionLayout>

トリガー

やりたいことは「スクロールしたときにTextViewの位置を変更する」です。

スクロールは”縦のスワイプ”になるので motion:dragDirection="dragUp" でスクロールを実装します。ターゲットは背景画像にしておきます。

    <Transition
        motion:constraintSetEnd="@id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="200"
        motion:motionInterpolator="linear">
        <OnSwipe
            motion:dragDirection="dragUp"
            motion:targetId="@id/background"
            motion:touchAnchorSide="bottom" />
    </Transition>

開始

@startにはAppBarが開いている状態の成約を指定していきます。

背景画像は見せておきたいので android:alpha="1" で表示状態にしておきます。

あとは通常通りタイトルTextViewは中央に来るように、サブタイトルTextViewはタイトルの下に来るように成約を設定します。

f:id:ticktakclock:20191231150318p:plain:w200
開始レイアウト

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/background"
            android:alpha="1">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent" />
        </Constraint>
        <Constraint android:id="@id/title">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                motion:layout_constraintBottom_toBottomOf="@id/background"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="@id/background" />
        </Constraint>
        <Constraint android:id="@id/title_sub">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="8dp"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toBottomOf="@id/title" />
        </Constraint>
    </ConstraintSet>

終了

@endにはAppBarが閉じている状態の成約を指定していきます。

背景画像は見せなくて良いので android:alpha="0" で表示状態にしておきます。

スクロールした時にタイトルTextViewは左下、サブタイトルTextViewはタイトルの右横に配置します。

f:id:ticktakclock:20191231151114p:plain:w200
終了レイアウト

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/background"
            android:alpha="0">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                motion:layout_constraintBottom_toBottomOf="parent" />
        </Constraint>
        <Constraint android:id="@id/title">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:layout_marginBottom="8dp"
                motion:layout_constraintBottom_toBottomOf="@id/background"
                motion:layout_constraintStart_toStartOf="parent" />
        </Constraint>
        <Constraint android:id="@id/title_sub">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                motion:layout_constraintBottom_toBottomOf="@id/title"
                motion:layout_constraintStart_toEndOf="@id/title"
                motion:layout_constraintTop_toTopOf="@id/title" />
        </Constraint>
    </ConstraintSet>

カスタムMotionLayoutについて

ここまでやれば普通であればスクロールでアニメーションできるのですが、残念ながらMotionLayoutクラスではアニメーションできませんでした。

f:id:ticktakclock:20191231165627g:plain:w200
MotionLayoutのみデモ

アニメーションする際、MotionLayoutの progress 値が変化することでTransition発生することになりますが、AppBarのレイアウトの縦移動だけではMotionLayoutのこの値が変化することはないようです。

ということでMotionLayoutに AppBarLayout.OnOffsetChangedListener を実装してAppbarLayout で発生するoffsetをMotionLayoutのprogressに適用させることでTransitionの発生を促すようにチャレンジします。

class Sample10MotionLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), AppBarLayout.OnOffsetChangedListener {

    override fun onOffsetChanged(appBarLayout: AppBarLayout?, offset: Int) {
        val scrolled = appBarLayout?.totalScrollRange?.toFloat()
        progress = -offset / (appBarLayout?.totalScrollRange?.toFloat() ?: 0f)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        (parent as? AppBarLayout)?.addOnOffsetChangedListener(this)
    }
}

これでスクロールでMotionLayoutのTransitionを効かせる事ができたのですが、もっと良い方法がないか模索したいところです。

まとめ

CoordinatorLayoutとMotionLayoutを組み合わせたアニメーションを実装してみました。

MotionLayoutを拡張してAppBarのoffsetをMotionLayoutのprogressに適用させることでアニメーションを実現しました。

どうしてもカスタムMotionLayoutが必要なのかはわかりませんが、もっと良い方法があれば是非教えていただきたいです。

発展

カスタムMotionLayoutのprogressをうまく使えば様々なケースでMotionLayoutを使えそうなことがわかったのでDrawerLayoutでも適用可能かと思われます。

詳しくはGitHubSample11 をご確認ください。

f:id:ticktakclock:20191231172336g:plain:w200
Sample11

class Sample11MotionLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), DrawerLayout.DrawerListener {

    override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
        progress = slideOffset
    }

    override fun onDrawerClosed(drawerView: View) {

    }

    override fun onDrawerOpened(drawerView: View) {

    }

    override fun onDrawerStateChanged(newState: Int) {

    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        (parent as? DrawerLayout)?.addDrawerListener(this)
    }
}