一、背景
随着移动应用的不断发展,用户体验(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() 用于在视图层次结构改变之前启动过渡,第一个参数传入需要做转场动画的父布局或根布局。
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
}
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
}
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
}
}
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)
}
}
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
}
}
}
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
}
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)
- captureStartValues 和 captureEndValues
这两个方法用于捕获过渡前和过渡后的视图状态。通过这两个方法,系统能够知道视图的起始状态和最终状态,进而计算出过渡的动画效果。
- 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
)
}
}
解释:
- 使用 ColorDrawable 来提取背景颜色。
- 使用 ObjectAnimator.ofObject() 创建一个颜色变化动画。
- 通过 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)
- 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)
}
}
}
}