アニメーションを使用してビューの表示 / 非表示を切り替える

アプリの使用中は、古い情報を画面から削除して、新しい情報を画面に表示することが必要になります。表示内容をあまりに瞬間的に切り替えると、不快に感じられる場合や、ユーザーが画面上の新しいコンテンツを見逃してしまう場合があります。アニメーションを利用すると、変更速度を遅くして、ユーザーの目を引くことができるため、更新内容がわかりやすくなります。

ビューの表示 / 非表示を切り替える際によく利用されるアニメーションは 3 種類あります。円形出現エフェクト アニメーション、クロスフェード アニメーション、カードフリップ アニメーションです。

クロスフェード アニメーションを作成する

クロスフェード アニメーション(別名ディゾルブ)とは、1 つの View または ViewGroup を徐々にフェードアウトしながら、同時に別のものをフェードインしていく方法です。このアニメーションは、アプリ内のコンテンツやビューを切り替える場合に便利です。下記に示すクロスフェード アニメーションの例は、Android 3.1(API レベル 12)以降で利用可能な ViewPropertyAnimator を使用しています。

進行状況インジケーターからテキスト コンテンツに切り替えるクロスフェードの例を以下に示します。

クロスフェード アニメーション
 

ビューを作成する

まず、クロスフェードする 2 つのビューを作成する必要があります。進行状況インジケーターとスクロール可能なテキストビューを作成する例を以下に示します。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
            android:id="@+id/content"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <TextView style="?android:textAppearanceMedium"
                android:lineSpacingMultiplier="1.2"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/lorem_ipsum"
                android:padding="16dp" />

        </ScrollView>

        <ProgressBar android:id="@+id/loading_spinner"
            style="?android:progressBarStyleLarge"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center" />

    </FrameLayout>
    

クロスフェード アニメーションをセットアップする

クロスフェード アニメーションをセットアップするには:

  1. クロスフェードするビューのメンバー変数を作成します。後で、アニメーション中にビューを変更する際に、この参照が必要になります。
  2. フェードインするビューの表示を GONE に設定します。これにより、ビューがレイアウト スペースを占有することがなくなり、レイアウトの計算から除外さるため、処理が高速化されます。
  3. config_shortAnimTime システム プロパティをメンバー変数内にキャッシュします。このプロパティは、このアニメーションに対して標準の「短い」持続時間を定義します。この持続時間は、軽いアニメーションや、頻繁に発生するアニメーションに最適です。必要に応じて、config_longAnimTimeconfig_mediumAnimTime も利用できます。

上記のコード スニペットのレイアウトをアクティビティ コンテンツ ビューとして使用する例を以下に示します。

Kotlin

    class CrossfadeActivity : Activity() {

        private lateinit var contentView: View
        private lateinit var loadingView: View
        private var shortAnimationDuration: Int = 0

        ...

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_crossfade)

            contentView = findViewById(R.id.content)
            loadingView = findViewById(R.id.loading_spinner)

            // Initially hide the content view.
            contentView.visibility = View.GONE

            // Retrieve and cache the system's default "short" animation time.
            shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
        }
        ...
    }

    

Java

    public class CrossfadeActivity extends Activity {

        private View contentView;
        private View loadingView;
        private int shortAnimationDuration;

        ...

        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_crossfade);

            contentView = findViewById(R.id.content);
            loadingView = findViewById(R.id.loading_spinner);

            // Initially hide the content view.
            contentView.setVisibility(View.GONE);

            // Retrieve and cache the system's default "short" animation time.
            shortAnimationDuration = getResources().getInteger(
                    android.R.integer.config_shortAnimTime);
        }
        ...
    }

    

ビューをクロスフェードする

ビューを適切にセットアップできたら、以下の設定を行ってビューをクロスフェードします。

  1. フェードインするビューのアルファ値を 0 に設定し、表示を VISIBLE に設定します(上記では GONE に設定していました)。これにより、ビューは表示されますが、完全に透明になります。
  2. フェードインするビューのアルファ値を 0 から 1 にアニメーション化します。フェードアウトするビューのアルファ値を 1 から 0 にアニメーション化します。
  3. Animator.AnimatorListener 内で onAnimationEnd() を使用し、フェードアウトするビューの表示を GONE に設定します。アルファ値が 0 であっても、ビューの表示を GONE に設定すると、ビューがレイアウト スペースを占有することがなくなり、レイアウトの計算から除外さるため、処理が高速化されます。

このセットアップを行うメソッドの例を以下に示します。

Kotlin

    class CrossfadeActivity : Activity() {

        private lateinit var contentView: View
        private lateinit var loadingView: View
        private var shortAnimationDuration: Int = 0

        ...

        private fun crossfade() {
            contentView.apply {
                // Set the content view to 0% opacity but visible, so that it is visible
                // (but fully transparent) during the animation.
                alpha = 0f
                visibility = View.VISIBLE

                // Animate the content view to 100% opacity, and clear any animation
                // listener set on the view.
                animate()
                        .alpha(1f)
                        .setDuration(shortAnimationDuration.toLong())
                        .setListener(null)
            }
            // Animate the loading view to 0% opacity. After the animation ends,
            // set its visibility to GONE as an optimization step (it won't
            // participate in layout passes, etc.)
            loadingView.animate()
                    .alpha(0f)
                    .setDuration(shortAnimationDuration.toLong())
                    .setListener(object : AnimatorListenerAdapter() {
                        override fun onAnimationEnd(animation: Animator) {
                            loadingView.visibility = View.GONE
                        }
                    })
        }
    }

    

Java

    public class CrossfadeActivity extends Activity {

        private View contentView;
        private View loadingView;
        private int shortAnimationDuration;

        ...

        private void crossfade() {

            // Set the content view to 0% opacity but visible, so that it is visible
            // (but fully transparent) during the animation.
            contentView.setAlpha(0f);
            contentView.setVisibility(View.VISIBLE);

            // Animate the content view to 100% opacity, and clear any animation
            // listener set on the view.
            contentView.animate()
                    .alpha(1f)
                    .setDuration(shortAnimationDuration)
                    .setListener(null);

            // Animate the loading view to 0% opacity. After the animation ends,
            // set its visibility to GONE as an optimization step (it won't
            // participate in layout passes, etc.)
            loadingView.animate()
                    .alpha(0f)
                    .setDuration(shortAnimationDuration)
                    .setListener(new AnimatorListenerAdapter() {
                        @Override
                        public void onAnimationEnd(Animator animation) {
                            loadingView.setVisibility(View.GONE);
                        }
                    });
        }
    }

    

カードフリップ アニメーションを作成する

カードフリップは、カードがひっくり返る動きをエミュレートするアニメーションを表示することで、コンテンツのビュー間の切り替えをアニメーション化します。下記に示すカードフリップ アニメーションは、Android 3.0(API レベル 11)以降で利用可能な FragmentTransaction を使用しています。

カードフリップは次のように表示されます。

カードフリップ アニメーション
 

Animator オブジェクトを作成する

カードフリップ アニメーションを作成するには、合計 4 つのアニメーターが必要です。カードの前面が左に出るとき用と、左から入るとき用に 2 つのアニメーターが必要です。また、カードの背面が右から出るとき用と、右に入るとき用に 2 つのアニメーターが必要です。

card_flip_left_in.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
        <!-- Before rotating, immediately set the alpha to 0. -->
        <objectAnimator
            android:valueFrom="1.0"
            android:valueTo="0.0"
            android:propertyName="alpha"
            android:duration="0" />

        <!-- Rotate. -->
        <objectAnimator
            android:valueFrom="-180"
            android:valueTo="0"
            android:propertyName="rotationY"
            android:interpolator="@android:interpolator/accelerate_decelerate"
            android:duration="@integer/card_flip_time_full" />

        <!-- Half-way through the rotation (see startOffset), set the alpha to 1. -->
        <objectAnimator
            android:valueFrom="0.0"
            android:valueTo="1.0"
            android:propertyName="alpha"
            android:startOffset="@integer/card_flip_time_half"
            android:duration="1" />
    </set>
    

card_flip_left_out.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
        <!-- Rotate. -->
        <objectAnimator
            android:valueFrom="0"
            android:valueTo="180"
            android:propertyName="rotationY"
            android:interpolator="@android:interpolator/accelerate_decelerate"
            android:duration="@integer/card_flip_time_full" />

        <!-- Half-way through the rotation (see startOffset), set the alpha to 0. -->
        <objectAnimator
            android:valueFrom="1.0"
            android:valueTo="0.0"
            android:propertyName="alpha"
            android:startOffset="@integer/card_flip_time_half"
            android:duration="1" />
    </set>
    

card_flip_right_in.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
        <!-- Before rotating, immediately set the alpha to 0. -->
        <objectAnimator
            android:valueFrom="1.0"
            android:valueTo="0.0"
            android:propertyName="alpha"
            android:duration="0" />

        <!-- Rotate. -->
        <objectAnimator
            android:valueFrom="180"
            android:valueTo="0"
            android:propertyName="rotationY"
            android:interpolator="@android:interpolator/accelerate_decelerate"
            android:duration="@integer/card_flip_time_full" />

        <!-- Half-way through the rotation (see startOffset), set the alpha to 1. -->
        <objectAnimator
            android:valueFrom="0.0"
            android:valueTo="1.0"
            android:propertyName="alpha"
            android:startOffset="@integer/card_flip_time_half"
            android:duration="1" />
    </set>
    

card_flip_right_out.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
        <!-- Rotate. -->
        <objectAnimator
            android:valueFrom="0"
            android:valueTo="-180"
            android:propertyName="rotationY"
            android:interpolator="@android:interpolator/accelerate_decelerate"
            android:duration="@integer/card_flip_time_full" />

        <!-- Half-way through the rotation (see startOffset), set the alpha to 0. -->
        <objectAnimator
            android:valueFrom="1.0"
            android:valueTo="0.0"
            android:propertyName="alpha"
            android:startOffset="@integer/card_flip_time_half"
            android:duration="1" />
    </set>
    

ビューを作成する

「カード」の前面と背面はそれぞれ独立したレイアウトで、2 つのテキストビューや 2 つの画像、その他フリップする各種のビューの組み合わせなど、コンテンツを自由に格納することができます。そのような 2 つのレイアウトを、後でアニメーション化するフラグメント内で使用します。テキストを表示するカードの片面を作成するレイアウトの例を以下に示します。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#a6c"
        android:padding="16dp"
        android:gravity="bottom">

        <TextView android:id="@android:id/text1"
            style="?android:textAppearanceLarge"
            android:textStyle="bold"
            android:textColor="#fff"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/card_back_title" />

        <TextView style="?android:textAppearanceSmall"
            android:textAllCaps="true"
            android:textColor="#80ffffff"
            android:textStyle="bold"
            android:lineSpacingMultiplier="1.2"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/card_back_description" />

    </LinearLayout>
    

そして、カードの反対側に ImageView を表示する場合は次のようになります。

<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@drawable/image1"
        android:scaleType="centerCrop"
        android:contentDescription="@string/description_image_1" />
    

フラグメントを作成する

カードの前面用と背面用のフラグメント クラスをそれぞれ作成します。このクラスは、前に各フラグメントの onCreateView() メソッドで作成したレイアウトを返します。そして、カードを表示する親アクティビティ内に、このフラグメントのインスタンスを作成します。フラグメント クラスを使用する親アクティビティ内にフラグメント クラスをネストする例を以下に示します。

Kotlin

    class CardFlipActivity : FragmentActivity() {
        ...
        /**
         * A fragment representing the front of the card.
         */
        class CardFrontFragment : Fragment() {

            override fun onCreateView(
                    inflater: LayoutInflater,
                    container: ViewGroup?,
                    savedInstanceState: Bundle?
            ): View = inflater.inflate(R.layout.fragment_card_front, container, false)
        }

        /**
         * A fragment representing the back of the card.
         */
        class CardBackFragment : Fragment() {

            override fun onCreateView(
                    inflater: LayoutInflater,
                    container: ViewGroup?,
                    savedInstanceState: Bundle?
            ): View = inflater.inflate(R.layout.fragment_card_back, container, false)
        }
    }

    

Java

    public class CardFlipActivity extends FragmentActivity {
        ...
        /**
         * A fragment representing the front of the card.
         */
        public class CardFrontFragment extends Fragment {
            @Override
            public View onCreateView(LayoutInflater inflater, ViewGroup container,
                    Bundle savedInstanceState) {
                return inflater.inflate(R.layout.fragment_card_front, container, false);
            }
        }

        /**
         * A fragment representing the back of the card.
         */
        public class CardBackFragment extends Fragment {
            @Override
            public View onCreateView(LayoutInflater inflater, ViewGroup container,
                    Bundle savedInstanceState) {
                return inflater.inflate(R.layout.fragment_card_back, container, false);
            }
        }
    }

    

カードフリップをアニメーション化する

次に、親アクティビティ内にフラグメントを表示する必要があります。そのためには、まずアクティビティのレイアウトを作成します。実行時にフラグメントを追加できる FrameLayout を作成する例を以下に示します。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    

アクティビティ コード内で、先ほど作成したレイアウト用のコンテンツ ビューを設定します。また、アクティビティの作成時にデフォルト フラグメントを表示することをおすすめします。次のアクティビティ例では、カードの前面をデフォルトで表示しています。

Kotlin

    class CardFlipActivity : FragmentActivity() {

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_activity_card_flip)
            if (savedInstanceState == null) {
                supportFragmentManager.beginTransaction()
                        .add(R.id.container, CardFrontFragment())
                        .commit()
            }
        }
        ...
    }

    

Java

    public class CardFlipActivity extends FragmentActivity {

        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_activity_card_flip);

            if (savedInstanceState == null) {
                getSupportFragmentManager()
                        .beginTransaction()
                        .add(R.id.container, new CardFrontFragment())
                        .commit();
            }
        }
        ...
    }

    

今はカードの前面が表示されている状態であり、フリップ アニメーションを使用すると、適切なタイミングでカードの背面を表示することができます。カードの反対側を表示するメソッドを作成して、以下を行います。

  • 以前作成したカスタム アニメーションを、フラグメント遷移用として設定します。
  • 現在表示されているフラグメントを新しいフラグメントに置き換えて、作成したカスタム アニメーションを使用してこのイベントをアニメーション化します。
  • 以前表示されていたフラグメントをフラグメント バックスタックに追加して、ユーザーが [戻る] ボタンを押すと、カードがもう一度反転するようにします。

Kotlin

    class CardFlipActivity : FragmentActivity() {

        ...

        private fun flipCard() {
            if (showingBack) {
                supportFragmentManager.popBackStack()
                return
            }

            // Flip to the back.

            showingBack = true

            // Create and commit a new fragment transaction that adds the fragment for
            // the back of the card, uses custom animations, and is part of the fragment
            // manager's back stack.

            supportFragmentManager.beginTransaction()

                    // Replace the default fragment animations with animator resources
                    // representing rotations when switching to the back of the card, as
                    // well as animator resources representing rotations when flipping
                    // back to the front (e.g. when the system Back button is pressed).
                    .setCustomAnimations(
                            R.animator.card_flip_right_in,
                            R.animator.card_flip_right_out,
                            R.animator.card_flip_left_in,
                            R.animator.card_flip_left_out
                    )

                    // Replace any fragments currently in the container view with a
                    // fragment representing the next page (indicated by the
                    // just-incremented currentPage variable).
                    .replace(R.id.container, CardBackFragment())

                    // Add this transaction to the back stack, allowing users to press
                    // Back to get to the front of the card.
                    .addToBackStack(null)

                    // Commit the transaction.
                    .commit()
        }
    }

    

Java

    public class CardFlipActivity extends FragmentActivity {

        ...

        private void flipCard() {
            if (showingBack) {
                getSupportFragmentManager().popBackStack();
                return;
            }

            // Flip to the back.

            showingBack = true;

            // Create and commit a new fragment transaction that adds the fragment for
            // the back of the card, uses custom animations, and is part of the fragment
            // manager's back stack.

            getSupportFragmentManager()
                    .beginTransaction()

                    // Replace the default fragment animations with animator resources
                    // representing rotations when switching to the back of the card, as
                    // well as animator resources representing rotations when flipping
                    // back to the front (e.g. when the system Back button is pressed).
                    .setCustomAnimations(
                            R.animator.card_flip_right_in,
                            R.animator.card_flip_right_out,
                            R.animator.card_flip_left_in,
                            R.animator.card_flip_left_out)

                    // Replace any fragments currently in the container view with a
                    // fragment representing the next page (indicated by the
                    // just-incremented currentPage variable).
                    .replace(R.id.container, new CardBackFragment())

                    // Add this transaction to the back stack, allowing users to press
                    // Back to get to the front of the card.
                    .addToBackStack(null)

                    // Commit the transaction.
                    .commit();
        }
    }

    

円形出現エフェクト アニメーションを作成する

出現エフェクト アニメーションを使用すると、UI 要素グループの表示 / 非表示を切り替える際に連続した印象をユーザーに与えることができます。ViewAnimationUtils.createCircularReveal() メソッドを使用することで、切り抜き円形領域をアニメーション化して、ビューの表示 / 非表示を切り替えることができます。このアニメーションは、Android 5.0(API レベル 21)以降で利用可能な ViewAnimationUtils クラスを使用します。

アニメーション前には見えていなかったビューを、アニメーション後に表示する方法の例を以下に示します。

Kotlin

    // previously invisible view
    val myView: View = findViewById(R.id.my_view)

    // Check if the runtime version is at least Lollipop
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        // get the center for the clipping circle
        val cx = myView.width / 2
        val cy = myView.height / 2

        // get the final radius for the clipping circle
        val finalRadius = Math.hypot(cx.toDouble(), cy.toDouble()).toFloat()

        // create the animator for this view (the start radius is zero)
        val anim = ViewAnimationUtils.createCircularReveal(myView, cx, cy, 0f, finalRadius)
        // make the view visible and start the animation
        myView.visibility = View.VISIBLE
        anim.start()
    } else {
        // set the view to invisible without a circular reveal animation below Lollipop
        myView.visibility = View.INVISIBLE
    }

    

Java

    // previously invisible view
    View myView = findViewById(R.id.my_view);

    // Check if the runtime version is at least Lollipop
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        // get the center for the clipping circle
        int cx = myView.getWidth() / 2;
        int cy = myView.getHeight() / 2;

        // get the final radius for the clipping circle
        float finalRadius = (float) Math.hypot(cx, cy);

        // create the animator for this view (the start radius is zero)
        Animator anim = ViewAnimationUtils.createCircularReveal(myView, cx, cy, 0f, finalRadius);

        // make the view visible and start the animation
        myView.setVisibility(View.VISIBLE);
        anim.start();
    } else {
        // set the view to invisible without a circular reveal animation below Lollipop
        myView.setVisibility(View.INVISIBLE);
    }

    

ViewAnimationUtils.createCircularReveal() アニメーションは 5 つのパラメータを取ります。1 番目のパラメータは、アニメーション前に画面上で非表示 / 表示のいずれかに設定しておくビューを示します。次の 2 つのパラメータは、切り抜き円形領域の中心の x 座標と y 座標を示します。通常はこの座標がビューの中心になりますが、ユーザーがタップしたポイントを利用して、ユーザーが選択した場所からアニメーションを開始することもできます。4 番目のパラメータは、切り抜き円形領域の開始半径を示します。

上記の例の場合、初期半径が 0 に設定されているため、アニメーション後に表示するビューは、アニメーション前は円形領域によって隠されています。最後のパラメータは、円形領域の最終半径を示します。アニメーション後にビューを表示する場合は、必ず、最終半径がビュー自体よりも大きくなるように指定してください。これにより、アニメーションが終了する前に、ビューが完全に表示されるようになります。

アニメーション前に表示されていたビューを非表示にするには:

Kotlin

    // previously visible view
    val myView: View = findViewById(R.id.my_view)

    // Check if the runtime version is at least Lollipop
    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {
        // get the center for the clipping circle
        val cx = myView.width / 2
        val cy = myView.height / 2

        // get the initial radius for the clipping circle
        val initialRadius = Math.hypot(cx.toDouble(), cy.toDouble()).toFloat()

        // create the animation (the final radius is zero)
        val anim = ViewAnimationUtils.createCircularReveal(myView, cx, cy, initialRadius, 0f)

        // make the view invisible when the animation is done
        anim.addListener(object : AnimatorListenerAdapter() {

            override fun onAnimationEnd(animation: Animator) {
                super.onAnimationEnd(animation)
                myView.visibility = View.INVISIBLE
            }
        })

        // start the animation
        anim.start()
    } else {
        // set the view to visible without a circular reveal animation below Lollipop
        myView.visibility = View.VISIBLE
    }

    

Java

    // previously visible view
    final View myView = findViewById(R.id.my_view);

    // Check if the runtime version is at least Lollipop
    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {
        // get the center for the clipping circle
        int cx = myView.getWidth() / 2;
        int cy = myView.getHeight() / 2;

        // get the initial radius for the clipping circle
        float initialRadius = (float) Math.hypot(cx, cy);

        // create the animation (the final radius is zero)
        Animator anim = ViewAnimationUtils.createCircularReveal(myView, cx, cy, initialRadius, 0f);

        // make the view invisible when the animation is done
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                myView.setVisibility(View.INVISIBLE);
            }
        });

        // start the animation
        anim.start();
    } else {
        // set the view to visible without a circular reveal animation below Lollipop
        myView.setVisibility(View.VISIBLE);
    }

    

この場合、切り抜き円形領域の初期半径がビューと同じ大きさに設定されているため、アニメーションの開始前はビューが表示された状態になっています。最終半径が 0 に設定されているため、アニメーションが終了するとビューは非表示になります。アニメーションの完了時にビューの表示を INVISIBLE に設定できるように、リスナーをアニメーションに追加する必要があります。