导航与动画进阶:使用MotionLayout与Navigation、复杂过渡动画、自定义Navigator
各位同学,今天我们来聊聊导航与动画的进阶玩法。说实话,很多开发者做导航跳转时,就只用一个简单的fade_in或slide_in,页面切换干巴巴的。但用户的眼睛是雪亮的——流畅、有质感的过渡动画,直接决定了App的档次。
我个人习惯把动画分为两类:一类是「页面级过渡」,比如从A页面跳到B页面;另一类是「布局级过渡」,比如同一个页面内,某个卡片展开、折叠、移动。MotionLayout恰好能同时搞定这两件事,而且能和Navigation组件无缝配合。
MotionLayout与Navigation的集成
先说说MotionLayout怎么和Navigation搭伙。你想想看,Navigation默认支持AnimatedNavHost,但它的动画能力其实有限——只能做简单的enter/exit动画。而MotionLayout可以让你在同一个Fragment内部做复杂的布局动画,甚至可以在Fragment切换时,让两个页面的元素产生「共享元素过渡」的效果。
我在项目中遇到过这样一个场景:一个商品列表页,点击某个商品卡片,卡片要「飞」到详情页的顶部位置。如果用传统方式,得写一堆Animator和Transition代码,维护起来很痛苦。后来我用MotionLayout配合Navigation,直接在NavHostFragment的容器里嵌入MotionLayout,通过OnSwipe和OnClick触发场景切换,代码量减少了60%。
transition定义来控制Fragment的进出动画。
具体怎么做?看代码:
<!-- activity_main.xml -->
<androidx.constraintlayout.motion.widget.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/motionLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/motion_scene">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.motion.widget.MotionLayout>
然后在motion_scene.xml里定义过渡:
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Transition
app:constraintSetStart="@id/start"
app:constraintSetEnd="@id/end"
app:duration="400">
<OnSwipe
app:touchAnchorId="@id/nav_host_fragment"
app:touchAnchorSide="top"
app:dragDirection="dragUp" />
</Transition>
<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@id/nav_host_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@id/nav_host_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:translationY="-100dp"
app:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>
</MotionScene>
嗯,这里要注意:MotionLayout的transition和Navigation的NavController.navigate()是两套独立的动画系统。如果你想让它们协同工作,需要在Fragment内部监听导航事件,然后手动触发MotionLayout的场景切换。我一般会在BaseFragment里封装一个animateToDestination()方法。
复杂过渡动画实战
接下来聊点硬核的——复杂过渡动画。说白了,就是让两个页面之间的元素「动起来」有逻辑、有层次感,而不是生硬地整体消失再出现。
举个例子:列表页的Item点击后,Item的背景色、圆角、位置都要平滑过渡到详情页的对应元素。这其实就是「共享元素过渡」(Shared Element Transition)。Navigation组件本身不直接支持共享元素,但我们可以通过FragmentTransaction.setSharedElement()来实现。
我曾经在一个电商App里做过这样的效果:商品列表页的图片缩略图,点击后放大并移动到详情页的顶部banner位置。当时踩了个坑——共享元素的transitionName必须唯一,而且要在两个Fragment的布局里都设置相同的名字。如果你忘了设置,动画就会回退成默认的fade,效果大打折扣。
onCreateView()中,对应的View必须在onCreate()之后、onCreateView()返回之前就设置好transitionName。我曾经因为延迟加载导致transitionName为null,动画直接崩了。
代码实现如下:
// 在列表Fragment中
val itemView = layoutInflater.inflate(R.layout.item_product, container, false)
itemView.transitionName = "product_image_${product.id}"
// 跳转时
val extras = FragmentNavigatorExtras(
itemView to "product_image_${product.id}"
)
findNavController().navigate(
R.id.action_list_to_detail,
bundleOf("productId" to product.id),
null,
extras
)
// 在详情Fragment中
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 确保ImageView的transitionName与列表页一致
binding.productImage.transitionName = "product_image_${productId}"
// 延迟共享元素过渡,等待布局完成
postponeEnterTransition()
binding.productImage.doOnPreDraw { startPostponedEnterTransition() }
}
除了共享元素,你还可以组合使用Fade、Slide、Explode等Transition。我个人习惯的做法是:进入动画用Slide(Gravity.BOTTOM) + Fade,退出动画用Fade,这样视觉上比较柔和。
自定义Navigator
最后聊聊自定义Navigator。Navigation组件默认提供了FragmentNavigator、ActivityNavigator等,但有时候我们需要一些特殊的导航行为——比如跳转到WebView页面、跳转到DialogFragment、甚至跳转到Compose页面。这时候就得自己写一个Navigator。
为什么要自定义?我举个例子:项目里有个需求,点击某个按钮后,不是打开一个新Fragment,而是弹出一个全屏的BottomSheetDialogFragment。如果用默认的Navigator,你得手动管理Dialog的显示和隐藏,而且无法利用Navigation的NavBackStackEntry来保存状态。自定义Navigator就能完美解决这个问题。
实现自定义Navigator需要三步:
- 继承
Navigator抽象类,实现navigate()、popBackStack()、createDestination()等方法。 - 定义自己的Destination类,继承
NavDestination,用来描述目标页面的信息。 - 在
NavHostFragment中注册自定义Navigator,通过NavController.navigatorProvider.addNavigator()。
来看一个简化版的DialogNavigator:
class DialogNavigator(
private val fragmentManager: FragmentManager
) : Navigator<DialogNavigator.DialogDestination>() {
class DialogDestination(navigator: DialogNavigator) : NavDestination(navigator) {
var dialogClass: Class<out DialogFragment>? = null
}
override fun navigate(
destination: DialogDestination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
): NavDestination? {
val dialog = destination.dialogClass?.newInstance()
dialog?.arguments = args
dialog?.show(fragmentManager, destination.id.toString())
return destination
}
override fun popBackStack(): Boolean {
// 关闭当前显示的Dialog
val currentDialog = fragmentManager.findFragmentByTag(currentDestination?.id.toString())
if (currentDialog is DialogFragment) {
currentDialog.dismiss()
return true
}
return false
}
override fun createDestination(): DialogDestination = DialogDestination(this)
}
然后在NavGraph里这样用:
<dialog-navigation
android:id="@+id/my_dialog"
app:name=".MyDialogFragment" />
onLaunchSingleTop()和onRestoreState()。如果你不处理,当App被系统回收后恢复时,导航状态可能会丢失。我早期的一个项目就因为这个原因,用户切到后台再回来,DialogFragment直接不见了。
知识体系总览
下面这张图总结了本章的核心知识点,你可以把它当作一个「导航动画能力地图」:
从这张图可以看出,三个分支其实是层层递进的:MotionLayout解决「布局级动画」,复杂过渡解决「页面级动画」,自定义Navigator则让你拥有「任意导航目标」的能力。三者结合,基本能覆盖99%的导航动画需求。
最后说一句:动画这东西,多调参数、多跑真机。模拟器上看不出卡顿,但真机上可能掉帧。我一般会在低端机上测试动画效果,确保60fps稳定。嗯,今天就到这里,大家动手试试吧。