21、DataBinding与动画:属性动画绑定、Transition与DataBinding、布局动画、动画状态管理

动画这东西,说白了就是给UI注入灵魂。没有动画的App,就像一杯白开水——解渴,但没滋味。DataBinding和动画结合,能让你的代码量砍掉一半,而且逻辑更清晰。我早期做动画时,动不动就写几十行findViewById和ObjectAnimator,后来用了DataBinding绑定动画,嗯,真香。

21.1 属性动画绑定:把动画写进布局里

传统做法是:在Activity里找到View,然后创建Animator,再start。DataBinding的做法是:直接在XML里声明动画参数,通过BindingAdapter触发。我个人习惯把动画相关的逻辑封装在ViewModel里,布局只负责绑定。

先看一个最简单的例子——点击按钮让图片旋转:

// ViewModel
class AnimationViewModel : ViewModel() {
    val rotation = ObservableFloat(0f)

    fun rotate() {
        rotation.set(rotation.get() + 360f)
    }
}

// 布局文件 activity_animation.xml
<layout>
    <data>
        <variable name="vm" type="com.example.AnimationViewModel"/>
    </data>
    <ImageView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@drawable/ic_launcher"
        android:onClick="@{() -> vm.rotate()}"
        android:rotation="@{vm.rotation}"/>
</layout>

等等,这样直接设置rotation是没有动画效果的,它是瞬间跳转。我们需要一个BindingAdapter来添加动画过渡:

@BindingAdapter("android:rotation")
fun setRotationWithAnimation(view: View, rotation: Float) {
    view.animate()
        .rotation(rotation)
        .setDuration(300)
        .setInterpolator(AccelerateDecelerateInterpolator())
        .start()
}

这样每次rotation值变化时,都会触发一个300ms的旋转动画。我在项目中遇到过一个问题:如果快速点击多次,动画会互相覆盖,导致旋转角度错乱。解决办法是取消之前的动画:

@BindingAdapter("android:rotation")
fun setRotationWithAnimation(view: View, rotation: Float) {
    view.animate().cancel() // 取消当前动画
    view.animate()
        .rotation(rotation)
        .setDuration(300)
        .start()
}
小技巧: 如果你需要更复杂的属性动画(比如同时改变位置和透明度),可以自定义BindingAdapter接收多个参数,或者使用DataBinding的@BindingConversion。

21.2 Transition与DataBinding:场景切换的优雅方案

Transition框架是Android提供的场景动画工具。配合DataBinding,你可以实现「数据变化→自动触发场景切换」的效果。说白了,就是数据变了,界面自动用动画过渡到新状态。

举个例子:一个列表项展开和收起。传统做法要手动记录展开状态、调用beginDelayedTransition、更新布局。用DataBinding可以这样:

// ViewModel
class ExpandViewModel : ViewModel() {
    val isExpanded = ObservableBoolean(false)

    fun toggle() {
        isExpanded.set(!isExpanded.get())
    }
}

// 布局
<layout>
    <data>
        <variable name="vm" type="com.example.ExpandViewModel"/>
    </data>
    <LinearLayout
        android:onClick="@{() -> vm.toggle()}"
        android:visibility="@{vm.isExpanded ? View.VISIBLE : View.GONE}">
        <!-- 展开的内容 -->
    </LinearLayout>
</layout>

然后在Activity或Fragment中监听数据变化,触发Transition:

vm.isExpanded.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
    override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
        TransitionManager.beginDelayedTransition(rootView)
    }
})

这样每次isExpanded变化时,TransitionManager会自动捕捉布局变化并播放动画。我曾经踩过一个坑:如果布局变化太快(比如连续点击),TransitionManager会丢失中间状态。解决方案是加一个防抖,或者用AutoTransition代替默认Transition。

注意: TransitionManager.beginDelayedTransition必须在布局变化之前调用,否则动画会失效。DataBinding的属性变化是异步的,所以要在回调里先调用beginDelayedTransition,再让DataBinding更新UI。如果你发现动画没生效,八成是顺序搞反了。

21.3 布局动画:让列表和容器动起来

布局动画指的是容器内子View的入场、出场、移动动画。比如RecyclerView的item动画,或者LinearLayout添加子View时的动画。DataBinding可以配合LayoutAnimationController使用。

我个人习惯在XML中直接声明布局动画:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:animateLayoutChanges="true">
    <!-- 子View -->
</LinearLayout>

加上android:animateLayoutChanges="true"后,系统会自动为子View的添加、移除、可见性变化添加默认动画。但默认动画比较简陋,你可以自定义:

// 在代码中设置自定义LayoutTransition
val transition = LayoutTransition()
transition.setAnimator(LayoutTransition.APPEARING, ObjectAnimator.ofFloat(null, "alpha", 0f, 1f).setDuration(500))
transition.setAnimator(LayoutTransition.DISAPPEARING, ObjectAnimator.ofFloat(null, "alpha", 1f, 0f).setDuration(300))
container.layoutTransition = transition

如果配合DataBinding,你可以在ViewModel中控制子View的添加和移除,布局动画会自动触发。比如:

// ViewModel
class ListViewModel : ViewModel() {
    val items = ObservableArrayList<String>()

    fun addItem() {
        items.add("Item ${items.size + 1}")
    }

    fun removeItem(index: Int) {
        items.removeAt(index)
    }
}

// 布局
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:animateLayoutChanges="true">
    <include layout="@layout/item_view"
        android:visibility="@{items.size() > 0 ? View.VISIBLE : View.GONE}"/>
</LinearLayout>

这里要注意:animateLayoutChanges只对直接子View的添加/移除有效。如果你用include标签,需要确保include的根布局是直接子View。

21.4 动画状态管理:别让动画失控

动画状态管理是很多开发者容易忽略的点。你想想看,如果用户快速点击按钮,动画还没播完又触发新的动画,界面会变得一团糟。我早期做过一个点赞动画,用户疯狂点击时,心形图标乱飞,最后不得不加一个状态锁。

DataBinding配合ViewModel可以很好地管理动画状态。我的做法是在ViewModel中维护一个动画状态枚举:

enum class AnimationState {
    IDLE,       // 空闲
    PLAYING,    // 播放中
    FINISHED    // 已完成
}

class AnimationStateViewModel : ViewModel() {
    private val _state = MutableLiveData(AnimationState.IDLE)
    val state: LiveData<AnimationState> = _state

    fun startAnimation() {
        if (_state.value != AnimationState.IDLE) return // 防止重复触发
        _state.value = AnimationState.PLAYING
        // 执行动画...
        // 动画结束后
        _state.value = AnimationState.FINISHED
    }

    fun reset() {
        _state.value = AnimationState.IDLE
    }
}

然后在布局中根据状态控制View的可见性或启用状态:

<Button
    android:enabled="@{vm.state == AnimationState.IDLE}"
    android:onClick="@{() -> vm.startAnimation()}"/>

这样用户在动画播放期间无法再次点击按钮,从根本上避免了状态冲突。

核心思路: 动画状态管理的关键是「状态驱动」。把动画的播放、暂停、结束都映射到ViewModel的状态上,布局只负责根据状态渲染UI。这样逻辑清晰,测试也方便。

21.5 实战:一个带动画的点赞按钮

把上面的知识点串起来,做一个完整的点赞按钮。功能:点击后心形图标放大并变色,同时数字+1,动画期间按钮不可点击。

// ViewModel
class LikeViewModel : ViewModel() {
    val likeCount = ObservableInt(0)
    val isLiked = ObservableBoolean(false)
    val scale = ObservableFloat(1f)

    fun like() {
        if (isLiked.get()) return
        isLiked.set(true)
        scale.set(1.5f) // 触发放大动画
        likeCount.set(likeCount.get() + 1)
        // 动画结束后恢复
        Handler(Looper.getMainLooper()).postDelayed({
            scale.set(1f)
            isLiked.set(false)
        }, 300)
    }
}

// BindingAdapter
@BindingAdapter("app:scaleAnimation")
fun setScaleAnimation(view: View, scale: Float) {
    view.animate().cancel()
    view.animate()
        .scaleX(scale)
        .scaleY(scale)
        .setDuration(200)
        .start()
}

// 布局
<ImageButton
    android:src="@{vm.isLiked ? @drawable/ic_liked : @drawable/ic_unliked}"
    app:scaleAnimation="@{vm.scale}"
    android:enabled="@{!vm.isLiked}"
    android:onClick="@{() -> vm.like()}"/>

这个例子虽然简单,但涵盖了属性动画绑定、状态管理、防重复触发等核心知识点。我在实际项目中用类似方案做过直播间的礼物动画,效果很稳定。

21.6 知识体系图

下面这张图总结了DataBinding与动画的核心关系:

DataBinding + 动画 属性动画绑定 Transition 场景切换 布局动画 动画状态管理 BindingAdapter ObservableFloat beginDelayedTransition animateLayoutChanges LiveData/状态枚举

这张图展示了DataBinding与动画的四个核心方向。属性动画绑定负责单个View的属性变化;Transition负责场景级别的布局切换;布局动画处理容器内子View的增删;动画状态管理则确保整个过程有序进行。四者结合,基本能覆盖日常开发中90%的动画需求。

最后说一句:动画不是越多越好。我见过有些App每个按钮都带弹跳动画,看着眼花。动画的目的是引导用户注意力、反馈操作结果,而不是炫技。用DataBinding做动画,能让你把精力放在「什么时候触发动画」上,而不是「怎么实现动画」上——这才是DataBinding的真正价值。