LoginSignup
5
5

More than 3 years have passed since last update.

モダンなアニメーションを実装してみた

Posted at

はじめに

現在のモバイル開発においてモダンなアニメーションはなくてはならないものです。
画面遷移の際、共通要素が途切れてしまうとユーザーが困惑してしまうことがあります。

一覧画面から詳細画面へ遷移する際に共通要素を途切れさせないように表示することで実際にユーザーがUIに手を触れているような感覚になります。

以下の動画の通りです。

このアニメーションはSharedElementTransitionというものを使います。

環境

NavigationComponentを使います。
appレベルのbuild.gradleに依存関係を記述していきます。

build.gradle(app)
dependencies {
    def nav_version = "2.3.0"
    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
}

説明しないこと

Jetpackの説明
NavigationComponentの説明

やり方

1. NavGraphを作る

res/navigationディレクトリを作り、navGraphファイルを作ります。

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/main_navigation"
    app:startDestination="@id/topFragment">

    <fragment
        android:id="@+id/exampleFragment"
        android:name="com.example.motionlayoutsample.view.fragment.ExampleFragment"
        android:label="ExampleFragment" >
        <action
            android:id="@+id/action_example2Fragment_to_example2DetailFragment"
            app:destination="@id/exampleDetailFragment" />
    </fragment>
    <fragment
        android:id="@+id/exampleDetailFragment"
        android:name="com.example.motionlayoutsample.view.fragment.ExampleDetailFragment"
        android:label="ExampleDetailFragment" >
        <argument
            android:name="transitionName"
            app:argType="string"/>
    </fragment>

</navigation>

2. NavHostFragmentを設定する

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".view.activity.MainActivity">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorAccent"
        android:elevation="4dp"
        android:minHeight="?attr/actionBarSize"
        android:theme="@style/ThemeOverlay.AppCompat.ActionBar"
        app:layout_constraintTop_toTopOf="parent" />

    <fragment
        android:id="@+id/top_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toBottomOf="@id/toolbar"
        app:navGraph="@navigation/main_navigation" />


</androidx.constraintlayout.widget.ConstraintLayout>

3. Fragmentを作成する

アニメーションの始まりのFragmentとアニメーション終わりのFragmentを作成します。
今回は動画のように一覧画面から詳細画面にいきたいので、一覧画面と詳細画面を作成します。
その際、共通要素がないと遷移した際にかっこいいアニメーションにならないので、なるべく共通した要素を作りましょう。

一覧画面

Screenshot_20200925-193509.png

RecyclerViewのアイテムのxmlを載せておきます

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.cardview.widget.CardView
            android:id="@+id/cardView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:cardCornerRadius="10dp"
            app:cardElevation="4dp"
            app:cardUseCompatPadding="true"
            app:layout_constraintTop_toTopOf="parent">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent">

                <FrameLayout
                    android:id="@+id/frameLayout"
                    android:layout_width="match_parent"
                    android:layout_height="140dp"
                    android:background="@color/cardview_color"
                    app:layout_constraintTop_toTopOf="parent" />

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="horizontal"
                    android:padding="16dp"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/frameLayout">

                    <FrameLayout
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_gravity="center_vertical"
                        android:background="@drawable/gray_circle" />

                    <LinearLayout
                        android:layout_width="match_parent"
                        android:layout_height="match_parent"
                        android:layout_marginStart="20dp"
                        android:orientation="vertical">

                        <FrameLayout
                            android:layout_width="match_parent"
                            android:layout_height="16dp"
                            android:background="@drawable/gray_80" />

                        <FrameLayout
                            android:layout_width="120dp"
                            android:layout_height="16dp"
                            android:layout_marginTop="10dp"
                            android:background="@drawable/gray_80" />

                    </LinearLayout>

                </LinearLayout>

            </androidx.constraintlayout.widget.ConstraintLayout>

        </androidx.cardview.widget.CardView>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

詳細画面

Screenshot_20200925-193516.png

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout3"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ScrollView
            android:id="@+id/scrollView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fillViewport="true">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <androidx.cardview.widget.CardView
                    android:id="@+id/cardView"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    app:cardElevation="4dp">

                    <androidx.constraintlayout.widget.ConstraintLayout
                        android:layout_width="match_parent"
                        android:layout_height="match_parent">

                        <FrameLayout
                            android:id="@+id/frameLayout"
                            android:layout_width="match_parent"
                            android:layout_height="260dp"
                            android:background="@color/cardview_color"
                            app:layout_constraintTop_toTopOf="parent" />

                        <LinearLayout
                            android:layout_width="match_parent"
                            android:layout_height="wrap_content"
                            android:orientation="horizontal"
                            android:padding="16dp"
                            app:layout_constraintBottom_toBottomOf="parent"
                            app:layout_constraintTop_toBottomOf="@id/frameLayout">

                            <FrameLayout
                                android:layout_width="wrap_content"
                                android:layout_height="wrap_content"
                                android:layout_gravity="center_vertical"
                                android:background="@drawable/gray_circle" />

                            <LinearLayout
                                android:layout_width="match_parent"
                                android:layout_height="match_parent"
                                android:layout_marginStart="20dp"
                                android:orientation="vertical">

                                <FrameLayout
                                    android:layout_width="match_parent"
                                    android:layout_height="16dp"
                                    android:background="@drawable/gray_80" />

                                <FrameLayout
                                    android:layout_width="120dp"
                                    android:layout_height="16dp"
                                    android:layout_marginTop="10dp"
                                    android:background="@drawable/gray_80" />

                            </LinearLayout>

                        </LinearLayout>

                    </androidx.constraintlayout.widget.ConstraintLayout>

                </androidx.cardview.widget.CardView>

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:background="#f9fbe7"
                    android:orientation="vertical"
                    android:paddingStart="16dp"
                    android:paddingTop="30dp"
                    android:paddingEnd="16dp"
                    android:paddingBottom="30dp">

                    <FrameLayout
                        android:layout_width="200dp"
                        android:layout_height="35dp"
                        android:background="@drawable/gray_80"
                        app:delayAnimation="@{100L}" />

                    <FrameLayout
                        android:layout_width="match_parent"
                        android:layout_height="20dp"
                        android:layout_marginTop="16dp"
                        android:background="@drawable/gray_80"
                        app:delayAnimation="@{200L}" />

                    <FrameLayout
                        android:layout_width="200dp"
                        android:layout_height="20dp"
                        android:layout_marginTop="16dp"
                        android:background="@drawable/gray_80"
                        app:delayAnimation="@{220L}" />

                    <FrameLayout
                        android:layout_width="300dp"
                        android:layout_height="20dp"
                        android:layout_marginTop="16dp"
                        android:background="@drawable/gray_80"
                        app:delayAnimation="@{240L}" />

                    <FrameLayout
                        android:layout_width="100dp"
                        android:layout_height="20dp"
                        android:layout_marginTop="16dp"
                        android:background="@drawable/gray_80"
                        app:delayAnimation="@{260L}" />

                </LinearLayout>

            </LinearLayout>
        </ScrollView>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

BindingAdapter

@BindingAdapter("delayAnimation")
fun View.animation(delayTime: Long) {
    visibility = View.INVISIBLE
    val slideInAnimation = TranslateAnimation(0F, 0F, 1000F, 0F)
    slideInAnimation.let {
        it.duration = 100
        it.fillAfter = true
    }

    postDelayed({
        startAnimation(slideInAnimation)
        visibility = View.VISIBLE
    }, delayTime)
}

4. Fragmentの実装

読むのがめんどくさい方はonBindViewHolderの中をご覧ください

一覧画面

package com.example.motionlayoutsample.view.fragment

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigator
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.motionlayoutsample.databinding.FragmentExample2Binding
import com.example.motionlayoutsample.databinding.ItemExample2Binding
import com.google.android.material.floatingactionbutton.FloatingActionButton

class Example2Fragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? = FragmentExample2Binding.inflate(inflater, container, false).let {
        it.recyclerView.apply {
            adapter = ListAdapter()
            layoutManager = LinearLayoutManager(context)
        }
        it.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        postponeEnterTransition()

        view.viewTreeObserver.addOnPreDrawListener {
            startPostponedEnterTransition()
            true
        }
    }

    inner class ListAdapter : RecyclerView.Adapter<ListItemViewHolder>() {
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemViewHolder = ListItemViewHolder(
            ItemExample2Binding.inflate(LayoutInflater.from(context), parent, false)
        )

        override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) {
            holder.binding.also {
                // transitionNameをxml側で設定してしまうと全て同じアイテムということになってしまったのでpositionをtransitionNameにしましょう
                it.cardView.transitionName = position.toString()
                it.cardView.setOnClickListener {  v ->
                    Example2FragmentDirections.actionExample2FragmentToExample2DetailFragment(it.cardView.transitionName).let { direction ->
                        mapOf<View, String>(it.cardView to it.cardView.transitionName).let {
                            it.forEach { sharedElement ->
                                FragmentNavigator.Extras.Builder().apply {
                                    addSharedElement(sharedElement.key, sharedElement.value)
                                }.build().also { sharedExtras ->
                                    v.findNavController().navigate(direction, sharedExtras)
                                }
                            }
                        }
                    }
                }
            }
        }

        override fun getItemCount(): Int = 10
    }

    inner class ListItemViewHolder(val binding: ItemExample2Binding): RecyclerView.ViewHolder(binding.root)
}

詳細画面

package com.example.motionlayoutsample.view.fragment

import android.os.Bundle
import android.transition.TransitionInflater
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.appcompat.widget.ButtonBarLayout
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.navArgs
import com.example.motionlayoutsample.R
import com.example.motionlayoutsample.databinding.FragmentExample2DetailBinding
import com.example.motionlayoutsample.view.extension.popUpButton
import com.example.motionlayoutsample.view.extension.shrinkingButton
import com.example.motionlayoutsample.viewmodel.Example2DetailViewModel
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.android.synthetic.main.fragment_practice.*

class Example2DetailFragment : Fragment() {

    private val args: Example2DetailFragmentArgs by navArgs()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)
        sharedElementReturnTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? = FragmentExample2DetailBinding.inflate(inflater, container, false).let {
        it.cardView.transitionName = args.transitionName
        it.lifecycleOwner = viewLifecycleOwner
        it.root
    }
}

5. 説明

めんどくさい方は上記コピペでそれなりのことできます。

共通要素にtransitionNameを設定する

it.cardView.transitionName = position.toString()

まずはSharedElementTransitionとして扱うViewにTransitionNameを設定します。
単体のViewだったら決め打ちでTransitionNameをつけていいんですが、今回はRecyclerViewでアイテムを使い回しているので同じ名前は使用できません。
なのでpositonをtransitionNameとしてつけています。

ViewをSharedElementTransitionに登録する

FragmentNavigator.Extras.Builder().apply {
    addSharedElement(sharedElement.key, sharedElement.value)
}

遷移先のFragmentでSharedElementTransitionを登録する

遷移先のFragment
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)
        sharedElementReturnTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)
    }

まとめ

今回は途切れない画面遷移としてSharedElementTransitionを紹介しました。
次回はMotionLayoutあたりかな?

参考にした記事

SharedElementTransitionを試してみる

共通要素を使った途切れない画面遷移: RecyclerView から ViewPager へ

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