9、导航动画:为页面跳转添加过渡动画、自定义动画资源、使用Shared Element Transition

导航动画这件事,说实话,很多开发者容易忽略。我见过不少项目,功能做得挺扎实,但页面切换就是“啪”一下硬切过去,用户体验瞬间掉了一个档次。你想想看,用户滑动屏幕、点击按钮,结果界面生硬地闪一下——这感觉就像看电影时画面突然卡帧,很出戏。

我个人习惯是,只要项目用了Navigation组件,动画配置一定是第一时间加上的。因为成本真的很低,但收益非常明显。今天我们就来聊聊怎么给页面跳转加上丝滑的过渡动画,包括自定义动画资源,以及那个让用户“哇”出来的Shared Element Transition。

9.1 基础过渡动画:从零到一

Navigation组件对动画的支持,说白了就是通过NavOptions来配置。你不需要写什么复杂的动画框架,系统自带的几个动画就够用。

先看一个最简单的例子。假设我们有A、B两个Fragment,从A导航到B时,想让A从左侧滑出,B从右侧滑入:

// 在Navigation代码中配置动画
val navOptions = NavOptions.Builder()
    .setEnterAnim(R.anim.slide_in_right)
    .setExitAnim(R.anim.slide_out_left)
    .setPopEnterAnim(R.anim.slide_in_left)
    .setPopExitAnim(R.anim.slide_out_right)
    .build()

findNavController().navigate(
    R.id.action_a_to_b,
    null,
    navOptions
)

这里四个动画分别对应什么场景?我刚开始用的时候也搞混过,后来总结了一个规律:

动画属性 含义 实际场景
enterAnim 目标页面进入时的动画 B页面从右侧滑入
exitAnim 当前页面离开时的动画 A页面向左侧滑出
popEnterAnim 返回时目标页面的进入动画 从B返回A,A从左侧滑入
popExitAnim 返回时当前页面的离开动画 从B返回A,B向右侧滑出

嗯,这里要注意:pop前缀的动画是给返回操作用的。如果你只配置了前两个,返回时就会没有动画,或者动画方向反了。我在项目中就踩过这个坑,测试反馈说“返回时页面闪了一下”,其实就是pop动画没配。

核心原则:前进和返回的动画应该是对称的。前进时A左出B右入,返回时就应该是B右出A左入。

9.2 自定义动画资源:让过渡更有质感

系统自带的slide_in_right这些动画,其实只是简单的位移。如果你想做缩放、淡入淡出、甚至旋转效果,就得自己写动画资源文件了。

动画资源放在res/anim/目录下。我给大家看一个我常用的“缩放+淡入”组合动画:

<!-- res/anim/scale_fade_in.xml -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:interpolator="@android:interpolator/decelerate_cubic">
    
    <alpha
        android:fromAlpha="0.0"
        android:toAlpha="1.0" />
        
    <scale
        android:fromXScale="0.8"
        android:fromYScale="0.8"
        android:toXScale="1.0"
        android:toYScale="1.0"
        android:pivotX="50%"
        android:pivotY="50%" />
        
</set>

这个动画的效果是:页面从80%大小淡入到正常大小,持续时间300毫秒。我个人觉得这个动画比单纯的滑动要优雅,尤其适合那种详情页或者弹窗式的页面。

对应的退出动画:

<!-- res/anim/scale_fade_out.xml -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="200"
    android:interpolator="@android:interpolator/accelerate_cubic">
    
    <alpha
        android:fromAlpha="1.0"
        android:toAlpha="0.0" />
        
    <scale
        android:fromXScale="1.0"
        android:fromYScale="1.0"
        android:toXScale="0.8"
        android:toYScale="0.8"
        android:pivotX="50%"
        android:pivotY="50%" />
        
</set>

小技巧:进入动画的duration可以比退出动画长一点。比如进入300ms,退出200ms。这样用户感觉页面是“从容地出现,干脆地离开”,心理体验更好。

然后在NavOptions里引用这些自定义资源:

val options = NavOptions.Builder()
    .setEnterAnim(R.anim.scale_fade_in)
    .setExitAnim(R.anim.scale_fade_out)
    .setPopEnterAnim(R.anim.scale_fade_in)
    .setPopExitAnim(R.anim.scale_fade_out)
    .build()

你看,代码量几乎没有增加,但效果完全不一样了。

9.3 Shared Element Transition:让元素“飞”过去

这是导航动画里最炫酷的部分。Shared Element Transition,说白了就是让一个View从A页面“飞”到B页面。比如一个商品缩略图,点击后放大到详情页的顶部大图——这个过渡效果会让用户觉得非常自然。

实现这个效果需要三步走。我一步步说清楚。

第一步:给两个页面的共享View设置相同的transitionName

<!-- FragmentA的布局 -->
<ImageView
    android:id="@+id/product_thumb"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:transitionName="product_image"
    android:src="@drawable/product_thumb" />

<!-- FragmentB的布局 -->
<ImageView
    android:id="@+id/product_detail_image"
    android:layout_width="match_parent"
    android:layout_height="300dp"
    android:transitionName="product_image"
    android:src="@drawable/product_thumb" />

注意:transitionName必须完全一致,大小写都不能错。我曾经因为一个下划线没写对,调试了半天才发现。

第二步:在Fragment中设置共享元素

// FragmentA中发起导航
val extras = FragmentNavigatorExtras(
    product_thumb to "product_image"
)

findNavController().navigate(
    R.id.action_a_to_b,
    null,
    null,
    extras
)

这里的product_thumb是View的id,"product_image"是transitionName。两者通过to关键字关联起来。

第三步:在FragmentB中延迟启动过渡

// FragmentB的onViewCreated中
postponeEnterTransition()

// 等图片加载完成后
product_detail_image.doOnPreDraw {
    startPostponedEnterTransition()
}

为什么要延迟?因为Shared Element Transition需要知道目标View的最终位置和大小,才能计算出动画路径。如果图片还没加载完成,系统拿不到正确的尺寸,动画就会乱跳。我遇到过最夸张的情况是图片从屏幕左上角飞过来——因为ImageView当时还没布局完成,位置是(0,0)。

避坑指南:如果你用了Glide或Coil加载网络图片,一定要在图片加载完成后再调用startPostponedEnterTransition()。否则动画会失效,或者出现奇怪的闪烁。

9.4 知识体系总览

为了让你更直观地理解导航动画的整体结构,我画了一张图:

Navigation 导航动画体系 基础过渡动画 • enterAnim / exitAnim • popEnterAnim / popExitAnim • 通过 NavOptions 配置 • 支持 slide / fade / scale 自定义动画资源 • res/anim/ 目录下创建 • 组合 alpha / scale / translate • 自定义 interpolator • 控制 duration 时长 Shared Element • 共享 transitionName • FragmentNavigatorExtras • postponeEnterTransition • 图片加载完成后启动 最佳实践总结 1. 前进与返回动画保持对称,避免视觉突兀 2. 进入动画时长 > 退出动画时长,提升流畅感 3. Shared Element 必须等目标 View 布局完成后再启动 4. 动画资源复用,同一类页面使用统一动画风格

9.5 实战中的一些经验

最后分享几个我在项目中积累的小经验。

关于动画时长:不要超过400ms。我见过有人把动画设成800ms,结果用户觉得页面响应太慢。移动端的动画讲究“快而自然”,200-350ms是比较舒服的范围。

关于动画冲突:如果你同时用了Navigation动画和Fragment自身的动画,可能会产生冲突。我的建议是统一用Navigation的NavOptions来控制,不要在Fragment的onCreateAnimation里再写一套。

关于性能:Shared Element Transition对性能要求比较高。如果共享的View是一个大图,建议先压缩再过渡。我曾经在一个低端机上测试,图片从缩略图放大到全屏时明显掉帧,后来加了图片压缩就解决了。

一个实用技巧:在调试动画时,可以打开开发者选项里的“动画时长缩放”,把它调到10x,这样动画会变慢,方便你观察每个细节。调试完记得改回来,否则用户会以为你的App卡顿了。

导航动画这件事,说白了就是给用户一个“视觉上的引导”。让他知道“我从哪里来,要到哪里去”。好的动画让用户觉得流畅自然,差的动画让用户觉得莫名其妙。你想想看,一个App如果页面跳转都做不好,用户怎么放心把数据交给你?

好了,这一章的内容就到这里。动画配置虽然简单,但细节不少。建议你打开项目,把今天讲的三种动画都试一遍,感受一下不同参数带来的效果差异。


公众号:蓝海资料掘金营,微信deep3321