导航与动画进阶:使用MotionLayout与Navigation、复杂过渡动画、自定义Navigator

各位同学,今天我们来聊聊导航与动画的进阶玩法。说实话,很多开发者做导航跳转时,就只用一个简单的fade_inslide_in,页面切换干巴巴的。但用户的眼睛是雪亮的——流畅、有质感的过渡动画,直接决定了App的档次。

我个人习惯把动画分为两类:一类是「页面级过渡」,比如从A页面跳到B页面;另一类是「布局级过渡」,比如同一个页面内,某个卡片展开、折叠、移动。MotionLayout恰好能同时搞定这两件事,而且能和Navigation组件无缝配合。

MotionLayout与Navigation的集成

先说说MotionLayout怎么和Navigation搭伙。你想想看,Navigation默认支持AnimatedNavHost,但它的动画能力其实有限——只能做简单的enter/exit动画。而MotionLayout可以让你在同一个Fragment内部做复杂的布局动画,甚至可以在Fragment切换时,让两个页面的元素产生「共享元素过渡」的效果。

我在项目中遇到过这样一个场景:一个商品列表页,点击某个商品卡片,卡片要「飞」到详情页的顶部位置。如果用传统方式,得写一堆AnimatorTransition代码,维护起来很痛苦。后来我用MotionLayout配合Navigation,直接在NavHostFragment的容器里嵌入MotionLayout,通过OnSwipeOnClick触发场景切换,代码量减少了60%。

核心思路: 将NavHostFragment作为MotionLayout的子视图,利用MotionLayout的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,效果大打折扣。

避坑指南: 共享元素过渡要求两个Fragment的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() }
}

除了共享元素,你还可以组合使用FadeSlideExplode等Transition。我个人习惯的做法是:进入动画用Slide(Gravity.BOTTOM) + Fade,退出动画用Fade,这样视觉上比较柔和。

自定义Navigator

最后聊聊自定义Navigator。Navigation组件默认提供了FragmentNavigatorActivityNavigator等,但有时候我们需要一些特殊的导航行为——比如跳转到WebView页面、跳转到DialogFragment、甚至跳转到Compose页面。这时候就得自己写一个Navigator。

为什么要自定义?我举个例子:项目里有个需求,点击某个按钮后,不是打开一个新Fragment,而是弹出一个全屏的BottomSheetDialogFragment。如果用默认的Navigator,你得手动管理Dialog的显示和隐藏,而且无法利用Navigation的NavBackStackEntry来保存状态。自定义Navigator就能完美解决这个问题。

实现自定义Navigator需要三步:

  1. 继承Navigator抽象类,实现navigate()popBackStack()createDestination()等方法。
  2. 定义自己的Destination类,继承NavDestination,用来描述目标页面的信息。
  3. 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" />
小技巧: 自定义Navigator时,别忘了处理onLaunchSingleTop()onRestoreState()。如果你不处理,当App被系统回收后恢复时,导航状态可能会丢失。我早期的一个项目就因为这个原因,用户切到后台再回来,DialogFragment直接不见了。

知识体系总览

下面这张图总结了本章的核心知识点,你可以把它当作一个「导航动画能力地图」:

导航与动画进阶 MotionLayout + Navigation 集成方式 FragmentContainerView 嵌入 MotionLayout 通过 motion_scene 定义过渡 OnSwipe / OnClick 触发场景切换 复杂过渡动画 核心技术 共享元素过渡 (Shared Element) FragmentTransaction.setSharedElement() postponeEnterTransition / startPostponedEnterTransition 自定义 Navigator 实现步骤 继承 Navigator<D> 抽象类 定义自定义 Destination 注册到 NavController 核心原则 动画服务于用户体验,不要为了动画而动画 自定义Navigator时务必处理状态恢复

从这张图可以看出,三个分支其实是层层递进的:MotionLayout解决「布局级动画」,复杂过渡解决「页面级动画」,自定义Navigator则让你拥有「任意导航目标」的能力。三者结合,基本能覆盖99%的导航动画需求。

最后说一句:动画这东西,多调参数、多跑真机。模拟器上看不出卡顿,但真机上可能掉帧。我一般会在低端机上测试动画效果,确保60fps稳定。嗯,今天就到这里,大家动手试试吧。

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