通过 Transition 实现优雅动画

826 阅读8分钟

一、背景

随着移动应用的不断发展,用户体验(UX)已成为衡量一个应用成功与否的关键因素之一。良好的用户体验不仅来自于直观和易用的界面,还包括界面元素的互动性和流畅性。动画效果作为提升互动性和流畅感的重要手段,已经成为现代 Android 应用中不可或缺的一部分。

Transition 动画是 API 19 引入的一项新特性。它可以通过 API 调用在应用的不同部分创建平滑的动画效果,例如界面元素的消失、出现或移动等。Transition 不仅能够处理 View 的变化,还能够处理视图层次结构的变化,使用非常灵活。对于复杂的界面交互,它提供了一个高效且易于维护的方式。

二、Transition 的使用

首先,Transition 需要通过 TransitionManager 来应用。TransitionManager 是用于管理和应用不同过渡效果的主要类。

2.1 系统提供 Transition

2.1.1 Fade

Fade 是一种非常常见的过渡类型,它使视图逐渐变得透明或从透明变回不透明。

private fun testFade() {
    // 设置过渡效果
    val fade = Fade()
    fade.duration = 1000 // 设置持续时间
    // 启动过渡效果
    TransitionManager.beginDelayedTransition(mBinding.root, fade)
    // 执行视图的操作
    mBinding.imageView.isVisible = !mBinding.imageView.isVisible // 视图将会渐渐消失或显示
}

beginDelayedTransition() 用于在视图层次结构改变之前启动过渡,第一个参数传入需要做转场动画的父布局或根布局。

Record\_2025-02-24-14-38-36\_be2d0734b5a3663f81cc717c9cbb36a2.gif

2.1.2 ChangeBounds

ChangeBounds 可以改变视图的位置和尺寸(包括宽度和高度)。

private fun testChangeBounds() {
    // 设置过渡效果
    val changeBounds = ChangeBounds()
    changeBounds.duration = 500

    // 启动过渡效果
    TransitionManager.beginDelayedTransition(mBinding.root, changeBounds)

    // 改变视图的位置和大小
    val params = mBinding.imageView.layoutParams as ViewGroup.MarginLayoutParams
    params.leftMargin = 100
    params.topMargin = 200
    params.width = 500
    params.height = 200
    mBinding.imageView.layoutParams = params
}
Record\_2025-02-24-15-00-02\_be2d0734b5a3663f81cc717c9cbb36a2.gif

ChangeBounds 动画会改变视图的 layout 属性,例如改变视图的位置、大小、边距等。通过设置视图的新 LayoutParams 来应用动画效果。

2.1.3 Slide

Slide 使视图从某个方向滑动进出屏幕。

private fun testSlide() {
    // 设置过渡效果
    val slide = Slide()
    slide.slideEdge = Gravity.END
    slide.duration = 500
    // 启动过渡效果
    TransitionManager.beginDelayedTransition(mBinding.root, slide)
    // 改变视图的可见性
    mBinding.imageView.isVisible = !mBinding.imageView.isVisible
}
Record\_2025-02-24-17-36-59\_be2d0734b5a3663f81cc717c9cbb36a2.gif

slideEdge 控制视图出现或消失的方向,可以是 Gravity.LEFT、Gravity.TOP、Gravity.RIGHT、Gravity.BOTTOM 等。

2.1.4 ChangeClipBounds

当调用 View 的 setClipBounds 方法时会触发动画

private fun testChangeClipBounds() {
    // 设置过渡效果
    val changeClipBounds = ChangeClipBounds()
    changeClipBounds.duration = 500
    // 启动过渡效果
    TransitionManager.beginDelayedTransition(mBinding.root, changeClipBounds)
    // 改变视图的裁剪区域
    mBinding.imageView.apply {
        clipBounds = Rect(50, 50, width - 100, height - 200)
        mBinding.imageView.clipBounds = clipBounds
    }
}
Record\_2025-02-24-19-02-22\_be2d0734b5a3663f81cc717c9cbb36a2.gif

2.1.5 ChangeScroll

当调用 View 的 scrollTo 方法时会触发动画

private fun testChangeScroll() {
    val changeScroll = ChangeScroll()
    changeScroll.duration = 500
    TransitionManager.beginDelayedTransition(mBinding.root, changeScroll)
    val view = mBinding.textView
    if (view.scrollX == -30 && view.scrollY == -60) {
        view.scrollTo(0, 0)
    } else {
        view.scrollTo(-30, -60)
    }
}
Record\_2025-02-24-19-38-37\_be2d0734b5a3663f81cc717c9cbb36a2.gif

2.1.6 ChangeTransform

View 的 translation、scale 和 rotation 发生改变时都会触发动画

private fun testChangeTransform() {
    val transition = ChangeTransform()
    transition.duration = 500
    TransitionManager.beginDelayedTransition(mBinding.root, transition)
    mBinding.textView1.apply {
        if (translationX == 100f && translationY == 100f) {
            translationX = 0f
            translationY = 0f
        } else {
            translationX = 100f
            translationY = 100f
        }
    }
    mBinding.textView2.apply {
        rotation = if (rotation == 30f) {
            0f
        } else {
            30f
        }
    }
    mBinding.textView3.apply {
        if (scaleX == 0.5f && scaleY == 0.5f) {
            scaleX = 1f
            scaleY = 1f
        } else {
            scaleX = 0.5f
            scaleY = 0.5f
        }
    }
}
Record\_2025-02-24-19-46-36\_be2d0734b5a3663f81cc717c9cbb36a2.gif

2.1.7 TransitionSet

TransitionSet 可以将多个过渡效果组合成一个过渡效果,统一管理。

private fun testTransitionSet() {
    val fade = Fade()
    val slide = Slide().apply { slideEdge = Gravity.END }

    // 创建 TransitionSet 组合过渡效果
    val transitionSet = TransitionSet().apply {
        addTransition(fade)
        addTransition(slide)
        ordering = TransitionSet.ORDERING_TOGETHER //一起执行
    }
    transitionSet.duration = 800
    // 应用过渡效果
    TransitionManager.beginDelayedTransition(mBinding.root, transitionSet)
    mBinding.imageView.isVisible = !mBinding.imageView.isVisible
}
Record\_2025-02-24-19-53-33\_be2d0734b5a3663f81cc717c9cbb36a2.gif

ordering 属性控制动画是并行执行还是顺序执行。可以选择 ORDERING_TOGETHER(并行执行)或 ORDERING_SEQUENTIAL(顺序执行)。

2.2 监听器

可以为动画添加监听器,以便在动画的开始、结束时做一些操作。

private fun transitionListener(){
    val fade = Fade()
    fade.duration = 500
    fade.addListener(object : Transition.TransitionListener {
        override fun onTransitionStart(transition: Transition) {
            // 动画开始时执行
        }

        override fun onTransitionEnd(transition: Transition) {
            // 动画结束时执行
        }

        override fun onTransitionCancel(transition: Transition) {
            // 动画被取消时执行
        }

        override fun onTransitionPause(transition: Transition) {
            // 动画暂停时执行
        }

        override fun onTransitionResume(transition: Transition) {
            // 动画恢复时执行
        }
    })
    TransitionManager.beginDelayedTransition(mBinding.root, fade)
    mBinding.imageView.isVisible = !mBinding.imageView.isVisible
}

2.3 自定义 Transition

我们可以通过自定义 Transition 来实现更复杂的动画效果。自定义 Transition 主要是通过继承 Transition 类并重写一些方法来实现。

在自定义的 Transition 类中,最关键的方法有三个:

  • captureStartValues(TransitionValues transitionValues)
  • captureEndValues(TransitionValues transitionValues)
  • createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues)
  1. captureStartValues 和 captureEndValues

这两个方法用于捕获过渡前和过渡后的视图状态。通过这两个方法,系统能够知道视图的起始状态和最终状态,进而计算出过渡的动画效果。

  1. createAnimator

这个方法是自定义过渡动画的核心,负责根据捕获到的起始值和结束值创建 Animator 对象,来执行过渡动画。

2.3.1自定义背景颜色改变动画

首先,创建一个 ChangeBgColorTransition 类继承自 Transition 的自定义类,代码如下:

class ChangeBgColorTransition : Transition() {
    companion object {
        const val PROPNAME_BG_COLOR = "lx:ChangeBgColorTransition:bgColor"
    }

    override fun captureStartValues(transitionValues: TransitionValues) {
        transitionValues.values?.put(
            PROPNAME_BG_COLOR,
            (transitionValues.view.background as? ColorDrawable)?.color
        )
    }

    override fun captureEndValues(transitionValues: TransitionValues) {
        transitionValues.values?.put(
            PROPNAME_BG_COLOR,
            (transitionValues.view.background as? ColorDrawable)?.color
        )
    }

    override fun createAnimator(
        sceneRoot: ViewGroup,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        val view = endValues?.view ?: return null
        val startColor = startValues?.values?.get(PROPNAME_BG_COLOR) as? Int ?: return null
        val endColor = endValues.values?.get(PROPNAME_BG_COLOR) as? Int ?: return null
        // 创建背景颜色变化的动画
        return ObjectAnimator.ofObject(
            view,
            "backgroundColor",
            ArgbEvaluator(),
            startColor,
            endColor
        )
    }
}
Record\_2025-02-25-11-42-41\_be2d0734b5a3663f81cc717c9cbb36a2.gif

解释:

  1. 使用 ColorDrawable 来提取背景颜色。
  2. 使用 ObjectAnimator.ofObject() 创建一个颜色变化动画。
  3. 通过 ArgbEvaluator 来进行颜色的渐变。ArgbEvaluator 会根据起始颜色和结束颜色创建平滑的颜色过渡效果。

注意:background 可以是 ColorDrawable 或者 ShapeDrawable 等。在捕获颜色时,如果视图的背景是复杂的 Drawable(例如渐变背景),可能需要特别处理。这里假设背景是简单的 ColorDrawable。

使用自定义的 ChangeBgColorTransition ,代码如下:

private fun testChangeBgColorTransition() {
    val changeBgColorTransition = ChangeBgColorTransition()
    changeBgColorTransition.duration = 1000
    TransitionManager.beginDelayedTransition(mBinding.root, changeBgColorTransition)
    mBinding.textView1.apply {
        if (background is ColorDrawable && (background as ColorDrawable).color != Color.BLUE) {
            setBackgroundColor(Color.BLUE)
        } else {
            setBackgroundColor(Color.RED)
        }
    }
}

三、Scene 的使用

Scene 是 Android Transition 动画体系中的一个重要概念,它代表了布局中的一个状态。通过 Scene,你可以捕捉视图层次结构的当前状态,并将这些状态用于不同的过渡动画,方便在不同布局之间应用动画效果。Scene 可以理解为一个“快照”,它保存了视图层次结构的状态。你可以在多个 Scene 之间切换,并使用 Transition 来创建流畅的过渡效果。

3.1 在不同布局之间切换并应用过渡动画

假设你有两个布局文件,它们表示不同的状态,且你希望在它们之间平滑地切换,可以按照以下步骤来实现:

布局文件 (XML)

  1. layout_scene1.xml
<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/view_bg"
        android:layout_width="0dp"
        android:layout_height="100dp"
        android:layout_marginStart="12dp"
        android:layout_marginTop="20dp"
        android:layout_marginEnd="12dp"
        android:background="#FF0000"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:layout_marginStart="20dp"
        android:contentDescription="@null"
        android:src="@mipmap/icon_test"
        android:scaleType="centerCrop"
        app:layout_constraintBottom_toBottomOf="@id/view_bg"
        app:layout_constraintStart_toStartOf="@id/view_bg"
        app:layout_constraintTop_toTopOf="@id/view_bg" />

    <TextView
        android:id="@+id/tv_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:text="@string/scene_title"
        android:textColor="@color/white"
        android:textSize="15sp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@id/iv_icon"
        app:layout_constraintTop_toTopOf="@id/iv_icon" />

    <TextView
        android:id="@+id/tv_desc"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:ellipsize="end"
        android:maxLines="2"
        android:text="@string/scene_desc"
        android:textColor="@color/white"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="@id/iv_icon"
        app:layout_constraintEnd_toEndOf="@id/view_bg"
        app:layout_constraintStart_toEndOf="@id/iv_icon" />
</androidx.constraintlayout.widget.ConstraintLayout>

2. layout_scene2.xml

<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/view_bg"
        android:layout_width="260dp"
        android:layout_height="320dp"
        android:layout_marginStart="12dp"
        android:layout_marginTop="20dp"
        android:layout_marginEnd="12dp"
        android:background="#0000ff"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="0dp"
        android:layout_height="100dp"
        android:contentDescription="@null"
        android:scaleType="centerCrop"
        android:src="@mipmap/icon_test"
        app:layout_constraintEnd_toEndOf="@id/view_bg"
        app:layout_constraintStart_toStartOf="@id/view_bg"
        app:layout_constraintTop_toTopOf="@id/view_bg" />

    <TextView
        android:id="@+id/tv_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="@string/scene_title"
        android:textColor="@color/white"
        android:textSize="15sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="@id/iv_icon"
        app:layout_constraintStart_toStartOf="@id/iv_icon"
        app:layout_constraintTop_toBottomOf="@id/iv_icon" />

    <TextView
        android:id="@+id/tv_desc"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:layout_marginTop="15dp"
        android:layout_marginEnd="12dp"
        android:lineSpacingExtra="3dp"
        android:text="@string/scene_desc"
        android:textColor="@color/white"
        android:textSize="12sp"
        app:layout_constraintEnd_toEndOf="@id/view_bg"
        app:layout_constraintStart_toStartOf="@id/view_bg"
        app:layout_constraintTop_toBottomOf="@id/tv_name" />
</androidx.constraintlayout.widget.ConstraintLayout>

3. activity_scene.xml

<?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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/scene_root"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/change"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="20dp"
        android:layout_marginEnd="20dp"
        android:layout_marginBottom="40dp"
        android:text="切换"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/scene_root"
        tools:ignore="HardcodedText" />
</androidx.constraintlayout.widget.ConstraintLayout>

4. SceneActivity

class SceneActivity : AppCompatActivity() {
    private lateinit var mBinding: ActivitySceneBinding
    private var currentScene: Scene? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        mBinding = ActivitySceneBinding.inflate(layoutInflater)
        setContentView(mBinding.root)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        // 创建第一个场景(scene_layout_1)
        val scene1 = Scene.getSceneForLayout(mBinding.sceneRoot, R.layout.layout_scene1, this)
        // 创建第二个场景(scene_layout_2)
        val scene2 = Scene.getSceneForLayout(mBinding.sceneRoot, R.layout.layout_scene2, this)

        mBinding.change.setOnClickListener {
            currentScene = if (currentScene != scene1) {
                scene1
            } else {
                scene2
            }
            currentScene?.let { scene ->
                // 在两个场景之间切换,并应用过渡动画
                val transitionSet = TransitionSet()
                transitionSet.addTransition(ChangeBgColorTransition())
                    .addTransition(ChangeBounds())
                transitionSet.duration = 1000  // 设置动画时长
                TransitionManager.go(scene, transitionSet)
            }
        }
    }
}
Record\_2025-02-25-15-51-22\_be2d0734b5a3663f81cc717c9cbb36a2.gif