1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android開発30日間マスターシリーズ - Day13: アニメーションとトランジション - ユーザー体験を向上させる動的UI

Posted at

はじめに ✨

皆さん、こんにちは!Androidアプリ開発ロードマップの第13回です。

これまで、アプリの画面レイアウトや画面遷移といった「静的な」要素について学んできました。今回は、これに「動き」を加え、ユーザー体験を劇的に向上させるアニメーショントランジションについて学びます。ボタンを押した時のフィードバック、画面が切り替わる時の滑らかな動きなど、少しの工夫でアプリの印象は大きく変わります。

なぜアニメーションが重要なのか?

アニメーションは単なる「見た目の飾り」ではありません。現代のアプリ開発において、以下の重要な役割を果たします:

  • 視覚的なフィードバック: ユーザーがボタンを押したとき、「今操作が行われた」ことを視覚的に伝えます
  • 状態の変化を分かりやすく: データが読み込まれたり、エラーが発生したりしたときに、状態の変化をスムーズに示します
  • ユーザーの注意を引く: 重要な要素にユーザーの目を向けさせ、操作を誘導します
  • アプリに個性と生命を与える: アプリが生きているかのような感覚を与え、ユーザーの愛着を深めます

Google Material Designでは、アニメーションは「意味のある動き(Meaningful motion)」として定義され、ユーザー体験の重要な要素とされています。

1. Viewアニメーション(アルファ・スケール・回転・移動)

最もシンプルで手軽に実装できるのが、XMLで定義するViewアニメーションです。ビューの透明度、サイズ、回転、位置を変化させることができます。

1.1 基本的なアニメーション

フェードイン/フェードアウト

<!-- res/anim/fade_in.xml -->
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:fromAlpha="0.0"
    android:toAlpha="1.0"
    android:interpolator="@android:anim/accelerate_decelerate_interpolator" />
<!-- res/anim/fade_out.xml -->
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:fromAlpha="1.0"
    android:toAlpha="0.0"
    android:interpolator="@android:anim/accelerate_decelerate_interpolator" />

スケールアニメーション

<!-- res/anim/scale_up.xml -->
<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromXScale="0.0"
    android:fromYScale="0.0"
    android:toXScale="1.0"
    android:toYScale="1.0"
    android:pivotX="50%"
    android:pivotY="50%" />

回転アニメーション

<!-- res/anim/rotate.xml -->
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="1000"
    android:fromDegrees="0"
    android:toDegrees="360"
    android:pivotX="50%"
    android:pivotY="50%"
    android:repeatCount="infinite" />

スライドアニメーション

<!-- res/anim/slide_in_right.xml -->
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromXDelta="100%"
    android:toXDelta="0%" />

1.2 複数のアニメーションを組み合わせる

<!-- res/anim/bounce_in.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/bounce_interpolator">
    
    <alpha
        android:duration="600"
        android:fromAlpha="0.0"
        android:toAlpha="1.0" />
    
    <scale
        android:duration="600"
        android:fromXScale="0.3"
        android:fromYScale="0.3"
        android:toXScale="1.0"
        android:toYScale="1.0"
        android:pivotX="50%"
        android:pivotY="50%" />
        
</set>

1.3 Kotlinコードからの実行

class MainActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityMainBinding
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        setupAnimations()
    }
    
    private fun setupAnimations() {
        // フェードインアニメーション
        binding.fadeInButton.setOnClickListener {
            val fadeInAnim = AnimationUtils.loadAnimation(this, R.anim.fade_in)
            binding.targetView.startAnimation(fadeInAnim)
        }
        
        // スケールアニメーション
        binding.scaleButton.setOnClickListener {
            val scaleAnim = AnimationUtils.loadAnimation(this, R.anim.scale_up)
            binding.targetView.startAnimation(scaleAnim)
        }
        
        // 回転アニメーション
        binding.rotateButton.setOnClickListener {
            val rotateAnim = AnimationUtils.loadAnimation(this, R.anim.rotate)
            binding.targetView.startAnimation(rotateAnim)
        }
        
        // バウンスアニメーション
        binding.bounceButton.setOnClickListener {
            val bounceAnim = AnimationUtils.loadAnimation(this, R.anim.bounce_in)
            binding.targetView.startAnimation(bounceAnim)
        }
    }
}

1.4 アニメーションリスナーの活用

private fun animateWithListener() {
    val fadeInAnim = AnimationUtils.loadAnimation(this, R.anim.fade_in)
    
    fadeInAnim.setAnimationListener(object : Animation.AnimationListener {
        override fun onAnimationStart(animation: Animation?) {
            Log.d("Animation", "アニメーション開始")
            // アニメーション開始時の処理
        }
        
        override fun onAnimationEnd(animation: Animation?) {
            Log.d("Animation", "アニメーション終了")
            // アニメーション終了時の処理
            binding.statusTextView.text = "アニメーション完了!"
        }
        
        override fun onAnimationRepeat(animation: Animation?) {
            Log.d("Animation", "アニメーション繰り返し")
        }
    })
    
    binding.targetView.startAnimation(fadeInAnim)
}

2. プロパティアニメーション(ValueAnimatorとObjectAnimator)

プロパティアニメーションは、Viewアニメーションよりも強力で柔軟なアニメーションです。Android 3.0(API level 11)以降で利用可能で、ビューのプロパティを直接変更できるため、より自然なアニメーションが可能です。

2.1 ObjectAnimator - 基本的な使い方

import android.animation.ObjectAnimator
import android.animation.AnimatorSet
import android.view.animation.AccelerateDecelerateInterpolator

class PropertyAnimationActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityPropertyAnimationBinding
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityPropertyAnimationBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        setupPropertyAnimations()
    }
    
    private fun setupPropertyAnimations() {
        // アルファ値のアニメーション
        binding.alphaButton.setOnClickListener {
            val alphaAnimator = ObjectAnimator.ofFloat(
                binding.targetView, "alpha", 1f, 0f, 1f
            ).apply {
                duration = 1000
                interpolator = AccelerateDecelerateInterpolator()
            }
            alphaAnimator.start()
        }
        
        // 移動のアニメーション
        binding.moveButton.setOnClickListener {
            val moveAnimator = ObjectAnimator.ofFloat(
                binding.targetView, "translationX", 0f, 300f, 0f
            ).apply {
                duration = 2000
                interpolator = AccelerateDecelerateInterpolator()
            }
            moveAnimator.start()
        }
        
        // スケールのアニメーション
        binding.scaleButton.setOnClickListener {
            val scaleXAnimator = ObjectAnimator.ofFloat(
                binding.targetView, "scaleX", 1f, 1.5f, 1f
            )
            val scaleYAnimator = ObjectAnimator.ofFloat(
                binding.targetView, "scaleY", 1f, 1.5f, 1f
            )
            
            AnimatorSet().apply {
                playTogether(scaleXAnimator, scaleYAnimator)
                duration = 800
                start()
            }
        }
    }
}

2.2 ValueAnimator - より柔軟な制御

private fun customValueAnimator() {
    val colorAnimator = ValueAnimator.ofArgb(
        ContextCompat.getColor(this, R.color.start_color),
        ContextCompat.getColor(this, R.color.end_color)
    ).apply {
        duration = 2000
        addUpdateListener { animation ->
            val color = animation.animatedValue as Int
            binding.targetView.setBackgroundColor(color)
        }
    }
    colorAnimator.start()
}

private fun progressAnimator() {
    val progressAnimator = ValueAnimator.ofInt(0, 100).apply {
        duration = 3000
        addUpdateListener { animation ->
            val progress = animation.animatedValue as Int
            binding.progressBar.progress = progress
            binding.progressText.text = "$progress%"
        }
    }
    progressAnimator.start()
}

2.3 AnimatorSet で複雑なアニメーション

private fun complexAnimation() {
    // 複数のアニメーターを作成
    val fadeIn = ObjectAnimator.ofFloat(binding.targetView, "alpha", 0f, 1f)
    val scaleX = ObjectAnimator.ofFloat(binding.targetView, "scaleX", 0.5f, 1f)
    val scaleY = ObjectAnimator.ofFloat(binding.targetView, "scaleY", 0.5f, 1f)
    val rotate = ObjectAnimator.ofFloat(binding.targetView, "rotation", 0f, 360f)
    val slideIn = ObjectAnimator.ofFloat(binding.targetView, "translationY", -200f, 0f)
    
    // アニメーションの順序を制御
    AnimatorSet().apply {
        // フェードインとスライドインを同時実行
        play(fadeIn).with(slideIn)
        // その後、スケールと回転を同時実行
        play(scaleX).with(scaleY).with(rotate).after(fadeIn)
        
        duration = 1000
        interpolator = AccelerateDecelerateInterpolator()
        start()
    }
}

2.4 アニメーションリスナー

private fun animatorWithListener() {
    val animator = ObjectAnimator.ofFloat(binding.targetView, "translationX", 0f, 300f)
    
    animator.addListener(object : Animator.AnimatorListener {
        override fun onAnimationStart(animation: Animator) {
            binding.statusText.text = "アニメーション開始"
            binding.targetView.setBackgroundColor(Color.GREEN)
        }
        
        override fun onAnimationEnd(animation: Animator) {
            binding.statusText.text = "アニメーション完了"
            binding.targetView.setBackgroundColor(Color.BLUE)
        }
        
        override fun onAnimationCancel(animation: Animator) {
            binding.statusText.text = "アニメーションキャンセル"
        }
        
        override fun onAnimationRepeat(animation: Animator) {
            binding.statusText.text = "アニメーション繰り返し"
        }
    })
    
    animator.start()
}

3. ビューの表示/非表示時のトランジション

ビューの表示・非表示を滑らかに行うトランジションについて学びましょう。

3.1 基本的なトランジション

import androidx.transition.TransitionManager
import androidx.transition.AutoTransition
import androidx.transition.Fade
import androidx.transition.Slide
import android.view.Gravity

private fun basicTransition() {
    binding.toggleButton.setOnClickListener {
        // レイアウト変更前にbeginDelayedTransitionを呼び出す
        TransitionManager.beginDelayedTransition(binding.container)
        
        if (binding.targetView.visibility == View.VISIBLE) {
            binding.targetView.visibility = View.GONE
        } else {
            binding.targetView.visibility = View.VISIBLE
        }
    }
}

3.2 カスタムトランジション

private fun customTransitions() {
    // フェードトランジション
    binding.fadeButton.setOnClickListener {
        val fade = Fade().apply {
            duration = 500
            mode = Fade.MODE_IN
        }
        TransitionManager.beginDelayedTransition(binding.container, fade)
        binding.targetView.visibility = View.VISIBLE
    }
    
    // スライドトランジション
    binding.slideButton.setOnClickListener {
        val slide = Slide().apply {
            duration = 300
            slideEdge = Gravity.START
        }
        TransitionManager.beginDelayedTransition(binding.container, slide)
        binding.targetView.visibility = View.GONE
    }
    
    // オートトランジション(複数の効果を組み合わせ)
    binding.autoButton.setOnClickListener {
        val autoTransition = AutoTransition().apply {
            duration = 400
        }
        TransitionManager.beginDelayedTransition(binding.container, autoTransition)
        
        // 複数のビューを同時に変更
        binding.targetView.visibility = 
            if (binding.targetView.visibility == View.VISIBLE) View.GONE else View.VISIBLE
        binding.secondView.visibility = 
            if (binding.secondView.visibility == View.VISIBLE) View.GONE else View.VISIBLE
    }
}

3.3 シーントランジション(Scene Transition)

<!-- res/layout/scene_start.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center">

    <TextView
        android:id="@+id/text_view"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:text="Start"
        android:gravity="center"
        android:background="@color/purple_200" />

</LinearLayout>
<!-- res/layout/scene_end.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="end">

    <TextView
        android:id="@+id/text_view"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:text="End"
        android:gravity="center"
        android:background="@color/teal_200" />

</LinearLayout>
import androidx.transition.Scene
import androidx.transition.TransitionManager
import androidx.transition.ChangeBounds

class SceneTransitionActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivitySceneTransitionBinding
    private lateinit var startScene: Scene
    private lateinit var endScene: Scene
    private var isStartScene = true
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySceneTransitionBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        setupScenes()
    }
    
    private fun setupScenes() {
        // シーンを作成
        startScene = Scene.getSceneForLayout(binding.sceneRoot, R.layout.scene_start, this)
        endScene = Scene.getSceneForLayout(binding.sceneRoot, R.layout.scene_end, this)
        
        // 初期シーンを設定
        TransitionManager.go(startScene)
        
        binding.transitionButton.setOnClickListener {
            val changeBounds = ChangeBounds().apply {
                duration = 500
            }
            
            if (isStartScene) {
                TransitionManager.go(endScene, changeBounds)
            } else {
                TransitionManager.go(startScene, changeBounds)
            }
            
            isStartScene = !isStartScene
        }
    }
}

4. 実践的なアニメーションパターン

4.1 ローディングアニメーション

private fun createLoadingAnimation() {
    val rotateAnimator = ObjectAnimator.ofFloat(
        binding.loadingIcon, "rotation", 0f, 360f
    ).apply {
        duration = 1000
        repeatCount = ObjectAnimator.INFINITE
        interpolator = LinearInterpolator()
    }
    
    binding.startLoadingButton.setOnClickListener {
        binding.loadingIcon.visibility = View.VISIBLE
        rotateAnimator.start()
    }
    
    binding.stopLoadingButton.setOnClickListener {
        rotateAnimator.cancel()
        binding.loadingIcon.visibility = View.GONE
    }
}

4.2 ボタンフィードバック

private fun setupButtonFeedback() {
    binding.feedbackButton.setOnTouchListener { view, event ->
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                // ボタンを押した時
                ObjectAnimator.ofFloat(view, "scaleX", 1f, 0.95f).apply {
                    duration = 100
                    start()
                }
                ObjectAnimator.ofFloat(view, "scaleY", 1f, 0.95f).apply {
                    duration = 100
                    start()
                }
            }
            
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                // ボタンを離した時
                ObjectAnimator.ofFloat(view, "scaleX", 0.95f, 1f).apply {
                    duration = 100
                    start()
                }
                ObjectAnimator.ofFloat(view, "scaleY", 0.95f, 1f).apply {
                    duration = 100
                    start()
                }
            }
        }
        false
    }
}

4.3 リストアイテムのアニメーション

// RecyclerView.Adapterの中で使用
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.bind(items[position])
    
    // アイテムをアニメーションで表示
    holder.itemView.alpha = 0f
    holder.itemView.translationY = 50f
    
    holder.itemView.animate()
        .alpha(1f)
        .translationY(0f)
        .setDuration(300)
        .setStartDelay(position * 50L) // 遅延を追加してカスケード効果
        .start()
}

5. パフォーマンスの考慮事項

5.1 最適化のポイント

// 良い例:ハードウェアアクセラレーションを活用
private fun optimizedAnimation() {
    binding.targetView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
    
    val animator = ObjectAnimator.ofFloat(binding.targetView, "translationX", 0f, 300f)
    animator.addListener(object : AnimatorListenerAdapter() {
        override fun onAnimationEnd(animation: Animator) {
            // アニメーション終了後にレイヤーを戻す
            binding.targetView.setLayerType(View.LAYER_TYPE_NONE, null)
        }
    })
    animator.start()
}

// アニメーションの停止処理
override fun onPause() {
    super.onPause()
    // アクティビティが停止する時にアニメーションを一時停止
    binding.targetView.animate().cancel()
}

5.2 避けるべき実装

// 悪い例:UIスレッドをブロックする処理
private fun badAnimationExample() {
    // 避ける:無限ループや重い処理をUIスレッドで実行
    ValueAnimator.ofFloat(0f, 1f).apply {
        addUpdateListener { animation ->
            // 重い処理をここで行うとアニメーションがカクつく
            Thread.sleep(10) // これは絶対にしない!
        }
    }
}

まとめ

今回は、アプリに命を吹き込むアニメーションとトランジションについて詳しく学びました。

学んだポイント

  • Viewアニメーション: XMLで手軽に定義できる基本的なアニメーション
  • プロパティアニメーション: ObjectAnimatorValueAnimatorを使った柔軟なアニメーション
  • トランジション: TransitionManagerを使ったビューの状態変化の演出
  • 実践的なパターン: ローディング、ボタンフィードバック、リストアイテムのアニメーション
  • パフォーマンス: ハードウェアアクセラレーションの活用と最適化

アニメーション実装のベストプラクティス

  1. 控えめに使う: 過度なアニメーションはユーザーを疲れさせる
  2. 一貫性を保つ: アプリ全体で統一されたアニメーション時間とスタイル
  3. 目的を明確に: 装飾ではなく、ユーザー体験の向上のために使用
  4. パフォーマンスを意識: 重いアニメーションは避け、必要に応じて最適化
  5. アクセシビリティ: アニメーションを無効にする設定に対応

Material Design Guidelinesでは、アニメーションの持続時間について以下を推奨しています:

  • 小さな要素: 100-200ms
  • 中程度の要素: 200-300ms
  • 大きな要素: 300-400ms
  • 画面遷移: 300-500ms

これらのアニメーションを適切に使うことで、アプリの使いやすさと魅力が格段に向上します。ユーザーの操作に心地よいフィードバックを与え、より直感的で楽しいUIを設計できるようになります。

次回は、いよいよアプリの根幹となるAPI連携とデータ管理のセクションに入ります。まずは「APIとは何か?」から一緒に学んでいきましょう。お楽しみに!💪


この記事がお役に立てば、ぜひ「いいね!」やストックをお願いします。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?