1. 导航栏动画为什么要用 Lottie,先想清楚这件事
Android 底部导航栏这个组件,几乎每个 App 都有,但绝大多数项目的实现停留在“选中换图标+换文字颜色”这个层面。产品经理拿着设计稿过来,说这个切换要有点“弹一下”“转一圈”“图标从线条变实心”的动效,很多人的第一反应是找现成的开源库,或者干脆用 View 动画硬怼。我一开始也是这么干的,直到某个项目里设计给了一套包含 12 个图标的完整切换动画,每个动画有 40 多帧,还带路径变形和颜色渐变,那一刻我才真正意识到:这套东西不该用代码画。
Lottie 就是解决这个问题的工具。它是 Airbnb 开源的一套跨平台动画方案,核心思路特别朴素:设计师在 After Effects 里做动画,装一个叫 Bodymovin 的插件,把动画导出成一份 JSON 文件,客户端把这个 JSON 丢给 Lottie 的渲染引擎,直接按帧还原出来。整个链路里,开发不需要逐帧写代码,也不需要切几十张序列图,一个 JSON 文件通常几十到几百 KB,比 GIF 和帧动画的体积小一个量级,而且矢量渲染在任何分辨率下都不糊。
放到底部导航栏这个场景里,Lottie 的价值就更明显了。导航栏的图标尺寸一般固定,但动画往往涉及图标形变、外圈扩散、粒子飞散这类效果,用矢量方式渲染天然契合。更关键的是,导航栏切换是个高频交互,用户一秒可能点三次,动画必须能随时被打断、随时反向播放、随时从头开始,这套控制能力 Lottie 都直接给了,playAnimation()、reverseAnimation()、setProgress()、cancelAnimation()这些方法基本够用。相比之下,传统帧动画在频繁切换时会疯狂 GC,属性动画又很难表达复杂的路径变化。
这篇东西适合谁看?如果你已经会用 Lottie 加载一个 JSON 让它自动播放,但一到导航栏这种“状态驱动”的场景就不知道动画该怎么和选中状态绑定,那这篇正好对路。如果你还没接触过 Lottie,也没关系,我会把从资源准备到状态控制的整条链路都拆开讲,中间会补充必要的背景知识。全文基于常见工程实践整理,具体参数和目录结构可以按你的项目实际情况调整。
2. 整体方案设计与技术选型考量
2.1 为什么不直接用属性动画或帧动画
在动手之前,值得把选型的账算清楚。底部导航栏动画的实现路径大致有三条:传统 View 属性动画、帧动画(AnimationDrawable 或序列图)、Lottie。
属性动画的问题在于表达能力上限。ObjectAnimator能改的是平移、缩放、旋转、透明度这些通用属性,一旦设计稿要求图标从“房子”形变成“带烟囱的房子”,或者选中时有个环形扩散波纹,属性动画就得靠ValueAnimator配合自定义 View 的onDraw手算路径,工作量陡增,而且不同图标要写不同逻辑,维护成本直线上升。我见过一个项目为了做“图标弹跳+外圈扩散”,在一个自定义 View 里写了八百多行绘制代码,后来设计改了一版动效,全部推翻重来。
帧动画的问题是体积和内存。一个 300x300 的动画,40 帧 PNG,压缩后可能 500KB 到 1MB,而且解码后每帧都要占一块 Bitmap 内存,导航栏有 4 到 5 个 tab,等于同时驻留好几套帧序列,中低端机上很容易触发内存抖动。更麻烦的是帧动画不好做“反向播放”和“中途打断”,用户快速连点时体验会很怪。
Lottie 的优势恰好落在这两个痛点上:JSON 描述的是矢量路径和关键帧,体积小;渲染是实时计算,能任意 seek 到某一帧,打断和反向都是原生支持的。当然它也有代价,后面会讲性能问题,但在这个场景下收益明显大于成本。
2.2 Lottie 在导航栏场景下的三种落地形态
实际项目里,Lottie 和底部导航栏结合的方式主要有三种,选哪种取决于动效复杂度和交互要求。
第一种是图标级替换。导航栏还是用系统的BottomNavigationView或者自己写的 LinearLayout,只是把每个 tab 的 ImageView 换成一个LottieAnimationView,选中时播放对应动画。这种做法改动最小,能复用现有导航栏的选中逻辑,缺点是 Lottie 视图和 TextView 的联动要自己处理,比如动画播放到某一帧时文字颜色才变。
第二种是整体替换。整个导航栏就是一个 Lottie 动画,包含所有图标和背景,通过setProgress()或者setMinAndMaxProgress()控制播放区间,每个 tab 对应 JSON 里的一段帧区间。这种做法视觉一致性最好,动画可以跨图标联动,但灵活性差,图标数量一变就得重新导出,而且点击热区要自己算。
第三种是混合方案,导航栏结构用原生 View 保证点击和可访问性,每个 tab 内部嵌一个小 Lottie 做图标动画,背景高亮用另一个 Lottie 或者普通 View 做。这是我在实际项目里用得最多的一种,平衡了灵活性和视觉表现力。
下面这张表把三种形态的关键差异列一下,方便你对着自己的需求选。
| 方案形态 | 改动成本 | 视觉上限 | 灵活性 | 适用场景 |
|---|---|---|---|---|
| 图标级替换 | 低 | 中 | 高 | tab 数量稳定、动效集中在图标本身 |
| 整体替换 | 中 | 高 | 低 | 强视觉统一、动画跨 tab 联动 |
| 混合方案 | 中 | 高 | 中高 | 大多数商业项目的实际选择 |
2.3 状态驱动是这套方案的核心设计思想
这一点必须先说透,否则后面所有代码都是散的。导航栏动画和“打开页面自动播放一段动画”最大的区别在于:它是状态驱动的,不是事件驱动的。
事件驱动的思路是“用户点了 tab B,我就播放 B 的动画”。听起来对,但会漏掉很多情况:App 从后台恢复时当前 tab 的动画应该是什么状态?用户快速连点 A、B、A,动画该怎么处理?代码里通过setCurrentItem()程序化切换 tab 时要不要播动画?如果只用setOnItemSelectedListener里播放,这些问题全都会变成 bug。
状态驱动的思路是:动画的进度是当前选中状态的纯函数。选中就是动画播到最后一帧,未选中就是第 0 帧,切换过程就是在这两个值之间过渡。这样一来,无论切换是用户点击触发的、代码触发的、还是页面恢复触发的,只要状态变了,视图就同步到对应进度,逻辑天然自洽。
Lottie 恰好很适合这种模式,因为setProgress(float)就是给你干这个的。基于这个思路,我在实现里通常会把“选中态进度”和“未选中态进度”定义成常量,切换时用ValueAnimator在两者之间插值,再把插值结果喂给setProgress()。这样动画的播放速度、是否可打断、是否带弹性,全都由这一个 Animator 控制,非常干净。
提示:如果 JSON 是从 AE 导出的完整“未选中到选中”动画,通常第 0 帧是未选中态,最后一帧是选中态。但 Bodymovin 导出时首尾帧不一定正好是这两个状态,建议导出前在 AE 里把时间轴对齐,或者在代码里用
setMinAndMaxProgress裁掉多余的帧。
3. Lottie 资源准备与工程接入的关键细节
3.1 从设计稿到可用 JSON 的完整链路
资源这一环是最容易出问题的,而且问题往往不在开发这边,沟通不到位就会反复返工。我把这条链路上真正需要注意的点按顺序列一下。
设计侧要做的第一件事是确认动画的时间轴结构。理想的导航栏动画 JSON 应该是一个“双向动画”:0 到 100% 是未选中变选中,反过来播放就是取消选中。但很多设计师习惯做两个独立动画,一个“选中动画”一个“取消选中动画”,导出成两个 JSON。这两种做法开发侧的成本差很多:单 JSON 双向的方案,一个setProgress就搞定;双 JSON 的方案,切换时要先setAnimation()再playAnimation(),会多一次解析开销,而且切换瞬间可能出现一帧闪烁。所以我一般会在设计开始前就提出要求:一个 tab 一个 JSON,且动画可逆。
第二件事是画布尺寸和锚点。AE 里的合成尺寸要和导航栏图标的实际显示尺寸保持合理比例,锚点(Anchor Point)要放在图标中心。这一点如果错了,Lottie 缩放后会偏移,你会看到图标在切换时“跳一下”。判断方法很简单:把 JSON 丢进 Lottie 官方预览工具,看首帧和末帧的图标位置是否重合,不重合就是锚点或位置关键帧有问题。
第三件事是图层命名和结构。Bodymovin 导出时如果开了“Glyphs”转换,文字图层会转成路径,体积更小但不可编辑;如果动画里有纯色背景,可以让设计改成透明,导航栏背景交给原生 View 控制,这样主题切换时不用重新导出资源。
第四件事是导出的 JSON 体积控制。经验值是单个导航栏图标动画控制在 30KB 以内比较理想。如果超过 100KB,通常是这几个原因:用了位图素材、路径点太多没优化、关键帧密度过高、开了不必要的表达式。让设计在 AE 里用“简化路径”功能降点,或者把不必要的关键帧删掉,往往能砍掉一半体积。
3.2 依赖引入与初始化配置
工程接入这块,Lottie 的依赖引入本身没什么坑,但版本选择和初始化配置有几个点值得说。
dependencies { implementation 'com.airbnb.android:lottie:6.4.0' }版本方面,5.x 到 6.x 有过一次比较大的 API 调整,LottieAnimationView的一些方法签名变了,比如setAnimation(String)的过时替代、lottie_rawRes属性的支持等。新项目直接用 6.x 就行,老项目升级要注意LottieDrawable相关的兼容处理。另外如果你的项目开了 R8 混淆,Lottie 自带 consumer rules,一般不用额外配置,但如果用了lottie_fileName从 assets 加载,记得把 JSON 文件放到src/main/assets/下。
初始化上,我习惯在 Application 里做一次全局配置,主要关掉一些默认开启但导航栏场景用不到的能力:
Lottie.initialize( LottieConfig.Builder() .setEnableSystraceMarkers(false) .build() )另外有个很实用的配置是setRenderMode。Lottie 6.x 支持RenderMode.AUTOMATIC、RenderMode.HARDWARE、RenderMode.SOFTWARE三种。硬件渲染性能好,但某些复杂蒙版和混合模式在硬件模式下会渲染异常;软件渲染兼容性好但耗 CPU。导航栏动画一般比较简单,用AUTOMATIC让它自己判断就行,如果遇到渲染黑块或者动画不显示,先试试切成SOFTWARE排查。
3.3 缓存策略:导航栏动画必须提前预加载
这是个特别容易忽略的点。Lottie 第一次加载 JSON 时要做解析,包括读取 JSON、构建关键帧数据结构、解析路径,这个过程在主线程做的话会有明显卡顿,尤其是导航栏这种一进首页就要显示的组件,用户很可能在解析没完成时就点了 tab,结果第一次切换动画是“跳”过去的,非常掉价。
解决方案是提前预加载并缓存。Lottie 内部有LottieCompositionCache,默认缓存大小是 10 个 composition。导航栏 tab 一般 4 到 5 个,刚好在缓存范围内,但前提是这些 composition 已经被加载过。所以我会在首页onCreate或者更早的时机,用LottieCompositionFactory把导航栏所有 JSON 异步加载一遍:
val tabAnimations = listOf("tab_home.json", "tab_discover.json", "tab_message.json", "tab_mine.json") tabAnimations.forEach { fileName -> LottieCompositionFactory.fromAsset(context, fileName) .addListener { /* 加载完成,已被内部缓存 */ } }这样等用户真正点击时,setAnimation()直接命中缓存,切换几乎无延迟。实测在低端机上,不做预加载时首次切换可能有 80 到 150ms 的延迟,预加载后基本感知不到。
注意:
LottieCompositionCache是全局单例,缓存的是解析后的 composition 对象,不是 Bitmap,所以内存占用可控。但如果你的 JSON 特别大,或者项目里 Lottie 用得很广(比如还有引导页动画、空状态动画),建议手动调大缓存或者对导航栏动画单独做一层引用持有,避免被其他大动画挤出去。
4. 核心实现:把动画进度和选中状态绑在一起
4.1 单个 tab 的动画视图封装
先从一个 tab 的视图开始。我倾向于给每个 tab 写一个自定义 View,内部包一个LottieAnimationView和一个TextView,对外只暴露一个setSelectedState(selected: Boolean, animate: Boolean)方法。这样导航栏容器只负责分发状态,不关心动画细节。
class NavTabView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LinearLayout(context, attrs) { private val lottieView: LottieAnimationView private val labelView: TextView private var progressAnimator: ValueAnimator? = null private val selectedProgress = 1f private val unselectedProgress = 0f init { orientation = VERTICAL gravity = Gravity.CENTER // 布局代码略,实际就是 addView 一个 LottieAnimationView 和一个 TextView } fun bindAnimation(composition: LottieComposition) { lottieView.composition = composition lottieView.progress = unselectedProgress } fun setSelectedState(selected: Boolean, animate: Boolean = true) { progressAnimator?.cancel() val target = if (selected) selectedProgress else unselectedProgress if (!animate) { lottieView.progress = target updateLabel(selected) return } val current = lottieView.progress progressAnimator = ValueAnimator.ofFloat(current, target).apply { duration = 300L interpolator = FastOutSlowInInterpolator() addUpdateListener { lottieView.progress = it.animatedValue as Float } addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { updateLabel(selected) } }) start() } } private fun updateLabel(selected: Boolean) { labelView.setTextColor( ContextCompat.getColor(context, if (selected) R.color.nav_selected else R.color.nav_normal) ) } }这段代码有几个设计取舍值得展开说。
为什么用ValueAnimator驱动setProgress而不是用playAnimation()。因为playAnimation()是“从当前进度播到结束”,它不关心目标状态;而导航栏需要的是“精确走到某个进度”。如果用户在中途连点,playAnimation()的行为是继续从当前位置播,可能方向反了;而ValueAnimator每次都从当前进度插值到目标进度,方向天然正确。而且插值器可以自己指定,想做弹性效果换个OvershootInterpolator就行,不用改动画资源。
为什么在onAnimationEnd里才更新文字颜色。如果文字颜色和图标动画同时变,视觉上文字会“抢戏”。让动画先走完再变文字,观感更舒服。当然这个要看设计意图,如果设计要求同步变,把updateLabel提到addUpdateListener里按进度插值颜色也行。
为什么progressAnimator?.cancel()放在最前面。这是防连点的关键。每次状态变化先取消上一个 animator,保证同一时刻只有一个动画在跑。cancel()会触发onAnimationCancel和onAnimationEnd,所以要注意别在onAnimationEnd里做副作用太重的操作,比如更新 View 可见性,可能被意外调用。
4.2 导航栏容器如何分发状态
容器层的职责很单一:维护“当前选中是哪个 tab”,并在变化时通知所有 tab 更新状态。这里我用一个简单的自定义 LinearLayout 实现,不依赖 Google 的BottomNavigationView,因为后者的 item 结构不好插入 Lottie 视图。
class LottieNavBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LinearLayout(context, attrs) { private val tabs = mutableListOf<NavTabView>() private var currentIndex = 0 var onTabSelected: ((Int) -> Unit)? = null fun setTabs(items: List<NavItem>) { removeAllViews() tabs.clear() items.forEachIndexed { index, item -> val tab = NavTabView(context) tab.bindAnimation(item.composition) tab.setOnClickListener { selectTab(index) } addView(tab, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) tabs.add(tab) } selectTab(currentIndex, animate = false) } fun selectTab(index: Int, animate: Boolean = true) { if (index !in tabs.indices) return val changed = index != currentIndex currentIndex = index tabs.forEachIndexed { i, tab -> tab.setSelectedState(i == index, animate && (changed || !animate)) } if (changed) onTabSelected?.invoke(index) } }这里有个细节:selectTab里判断了changed,只有真的换了 tab 才播动画。但要注意首次初始化和程序化调用setCurrentItem时不应该播动画,所以加了animate参数控制。另外changed || !animate这个条件看起来有点绕,实际是想表达“如果是非动画模式,所有 tab 都直接设到目标状态”。如果你觉得难读,直接拆成两个分支写清楚也行,可读性比炫技重要。
点击热区的处理也顺手提一下。因为每个 tab 是weight=1平分宽度,点击区域是整块,符合用户预期。但如果 Lottie 动画里有超出边界的效果(比如选中时有个外圈扩散),要给父容器设clipChildren = false和clipToPadding = false,否则动画会被裁掉。
4.3 和 ViewPager2 的双向联动
真实项目里导航栏很少独立存在,通常要和ViewPager2或者Fragment容器联动。联动有两个方向:点击 tab 切页面、滑动页面切 tab。
滑动切 tab 这个方向要特别注意动画的实时性。如果用ViewPager2的OnPageChangeCallback在onPageSelected里切 tab,动画只会在页面切换完成后才播,用户滑动过程中导航栏是静止的,割裂感很强。更好的做法是用registerOnPageChangeCallback的onPageScrolled拿到positionOffset,实时喂给相邻两个 tab 的setProgress,做到“滑动多少,图标形变多少”。
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled(position: Int, offset: Float, offsetPixels: Int) { if (offset == 0f) return val fromTab = tabs.getOrNull(position) ?: return val toTab = tabs.getOrNull(position + 1) ?: return fromTab.setProgressDirect(1f - offset) toTab.setProgressDirect(offset) } override fun onPageSelected(position: Int) { navBar.syncSelected(position) } })这里需要给NavTabView暴露一个setProgressDirect(progress: Float),内部直接调lottieView.progress = progress,不经过 Animator,也不更新文字颜色。文字颜色可以在onPageSelected里统一更新,避免滑动过程中颜色来回闪。
提示:实时联动时有个坑,
onPageScrolled在offset == 0f时也会回调,而且是在动画结束的瞬间。如果你在里面无条件设置进度,会把 Animator 驱动的动画直接打断。所以要么加offset == 0f的提前返回,要么在点击驱动的切换过程中用一个标志位屏蔽掉 scrolled 回调。我一般用前者,简单可靠。
5. 性能优化与真机实测的那些坑
5.1 渲染性能和内存占用的实测数据
Lottie 不是免费的午餐,尤其是在导航栏这种常驻组件上,性能问题会被放大。我在一台中端机(骁龙 6 系,8GB 内存)和一台三年前的低端机上做过对比测试,数据大致如下,供你有个量化的预期。
| 测试项 | 中端机 | 低端机 | 备注 |
|---|---|---|---|
| 单 tab JSON 首次解析耗时 | 18ms | 45ms | 大小约 24KB,含路径形变 |
| 缓存后 setAnimation 耗时 | 2ms 以内 | 5ms 左右 | 命中 LottieCompositionCache |
| 单次切换动画渲染帧率 | 58 到 60fps | 45 到 55fps | 动画时长 300ms |
| 四个 tab 常驻内存增量 | 约 6MB | 约 6MB | 主要是 composition 和 drawable |
| 快速连点 10 次后的 GC 次数 | 0 到 1 次 | 2 到 3 次 | 未做防抖时会更多 |
从数据看,中端机基本无压力,低端机的帧率会有波动但可接受。真正需要注意的是内存——四个 tab 各持有一个 Lottie drawable,加上缓存里的 composition,常驻增量在 6MB 左右。如果项目里还有别的 Lottie 场景,总量可能到 15MB 以上,这时候就要考虑按需释放了:导航栏所在的 Activity 不可见时,可以调lottieView.clearAnimation()释放动画资源,但保留 composition 缓存,回来时重新绑定即可。
另外一个容易踩的坑是动画复杂度和渲染模式的关系。如果 JSON 里用了大量蒙版、轨道遮罩、混合模式,硬件渲染在某些机型上会渲染错误,表现为动画区域黑屏或者只显示一部分。判断方法:在开发者选项里打开“GPU 渲染模式分析”,看切换动画时有没有异常的长条;或者直接代码里强制setRenderMode(RenderMode.SOFTWARE)对比效果。如果软件模式正常、硬件模式异常,基本可以确认是渲染兼容问题,这时候要么改动画资源(让设计少用复杂蒙版),要么就在这个场景固定用软件渲染。
5.2 快速连点导致的状态错乱怎么破
这个问题我在两个项目里都遇到过,表现是:用户快速点 A、B、A,最后停在了 A,但 B 的图标是选中态,或者两个图标都停在半途。原因通常是状态更新和动画执行没有对齐。
根源在于前面提到的cancel()时序。假设点 A 时启动 animator 从 0 到 1,点了 50ms 后点 B,cancel()掉 A 的 animator,此时 A 的进度停在 0.3 左右;然后 B 的 animator 从 0 到 1 启动。但如果 A 的onAnimationCancel之后又收到了onAnimationEnd(有些实现里会这样),updateLabel可能被错误调用。另外如果容器层和 tab 层的状态判断有偏差,也可能出现两个 tab 都认为自己是选中态。
我的处理方式是让状态只有一个源头。容器维护currentIndex,每次selectTab时无条件遍历所有 tab 设置setSelectedState(i == index),不依赖 tab 自己判断。同时给点击加一个短防抖,比如 200ms 内忽略重复点击:
private var lastClickTime = 0L private fun onTabClick(index: Int) { val now = SystemClock.uptimeMillis() if (now - lastClickTime < 200L) return lastClickTime = now selectTab(index) }防抖窗口设 200ms 是我试出来的经验值:小于 150ms 用户能感知到“点了没反应”,大于 250ms 连点体验又会变钝。200ms 左右既能挡住误触连点,又不影响正常操作节奏。另外ValueAnimator本身在 API 26 以上支持setCurrentFraction,如果要做更精细的中断控制,可以在cancel后手动progress到目标值,避免停在中间态。
5.3 主题切换和深色模式下的资源适配
导航栏动画在深色模式下经常翻车,原因就一个:JSON 里的颜色是写死的。Lottie 支持颜色替换,但需要 JSON 里的图层有命名,或者你提前知道要改哪个图层的哪个属性。
比较稳妥的做法是让设计导出时就用透明度或纯白/纯黑,颜色由代码在渲染时叠加。Lottie 提供了addValueCallback,可以在运行时替换指定图层的颜色:
lottieView.addValueCallback( KeyPath("icon", "**"), LottieProperty.COLOR ) { colorResolver }这里的KeyPath要对着 JSON 的图层结构写,所以前面强调图层命名要规范。如果动画结构复杂不好定位,还有个更省事的办法:用ColorFilter整体染色,lottieView.colorFilter = PorterDuffColorFilter(...),适合单色图标。缺点是如果动画里有多种颜色,会被统一染色,所以只适合纯色风格的导航栏。
深色模式切换的时机也要注意。如果在 Activity 的onConfigurationChanged里重建,Lottie 会重新绑定,颜色回调自然生效;如果配置了android:configChanges不重建,就需要手动监听并刷新颜色。我一般前者,让系统重建最省心,导航栏动画重新加载也就几十毫秒的事。
6. 常见问题速查与排查思路
6.1 动画不显示、错位、闪烁的排查表
这几种症状在接入 Lottie 的初期几乎都会遇到,我把排查路径整理成表,按出现频率排序。
| 症状表现 | 最可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 完全空白,无任何绘制 | JSON 路径写错或资源未打包 | 检查fromAsset/fromRawRes返回是否 success | 核对文件名大小写、assets 目录位置 |
| 显示但动画不动 | 设置了 progress 但没触发更新 | 检查是否autoPlay关闭后忘了驱动 | 确认 Animator 是否启动、progress 是否被覆盖 |
| 首帧或末帧位置偏移 | AE 锚点位置不对 | 用官方预览工具对比首末帧 | 让设计重设锚点重新导出 |
| 切换瞬间闪一下白 | 视图背景或缓存释放问题 | 看是否频繁setAnimation重建 drawable | 预加载 composition,复用同一个实例 |
| 快速连点后状态错乱 | 动画中断时序和状态源不一致 | 打日志看onAnimationEnd调用次数 | 统一状态源,加点击防抖 |
| 部分区域黑块 | 硬件渲染不支持该混合模式 | 切成 SOFTWARE 模式对比 | 简化动画或固定软件渲染 |
| 文字颜色和图标不同步 | 更新时机不一致 | 检查updateLabel调用位置 | 统一在动画结束或按进度插值 |
其中“显示但动画不动”这个我多解释一句。Lottie 有个容易被忽略的行为:如果你在 XML 里设了lottie_autoPlay="false",又在代码里只调了setProgress,那是没问题的;但如果同时设了lottie_progress属性,代码里的setProgress在初始化时可能被属性值覆盖,导致你以为设置了但实际没生效。排查时打一行日志把lottieView.progress和lottieView.speed打出来,通常就清楚了。
6.2 几个非常规但很有用的实操心得
心得一:用setProgress的同步调用做“无动画初始化”。有些团队会给导航栏加一个首次展示的入场动画,然后又希望程序化切换时不带过渡。我试过比较麻烦的做法是准备两套逻辑,后来发现直接在首次bind时不启动 Animator,只progress = target,视觉上就是瞬间到位,没有任何闪烁。这个技巧在页面恢复(onResume)时也一样适用,避免用户从其他页面回来看到导航栏“重播”一遍。
心得二:动画时长不要照搬设计给的 500ms。设计工具里预览动画通常按 500ms 或 600ms 做,但在真机上,导航栏切换超过 350ms 就会让用户觉得“卡”。我通常压到 250 到 300ms,用FastOutSlowInInterpolator保证起步快、收尾稳,体感上反而更利落。如果设计坚持要长动画,可以考虑把动画拆成“快速形变 + 缓慢细节”两段,但导航栏场景一般没必要。
心得三:给 Lottie 视图设固定尺寸,别用 wrap_content。Lottie 的测量逻辑在wrap_content下会按 JSON 的画布尺寸来,如果画布是 300x300 而导航栏图标实际只显示 48dp,你会得到一个巨大的图标。正确做法是在布局里写死宽高,或者用scaleType = CENTER_INSIDE配合固定尺寸。
心得四:调试时打开 Lottie 的调试覆盖层。在 XML 里加lottie_enableMergePathsForKitKatAndAbove之类的属性是一方面,更实用的是把LottieAnimationView的setOutlineMasksAndMattes相关开关打开看蒙版边界,很多“动画形状不对”的问题其实是蒙版路径问题,看边界一目了然。
心得五:JSON 版本兼容性要实测。Bodymovin 导出的 JSON 有版本字段,Lottie 不同版本对高版本 JSON 的支持程度不一样。我曾经遇到过一个 JSON 在 6.0 上正常、在 5.2 上部分图层丢失的情况,原因是用了新版本才支持的特性。解决办法要么升级 Lottie 依赖,要么让设计用低版本 Bodymovin 重新导出。这个坑很难从报错里看出来,只能靠实测对比。
7. 写在这套方案落地之后的几点个人体会
导航栏动画这个需求,做之前觉得是“锦上添花”,做完之后发现它对产品质感的提升比预期大。用户在那个位置每天要点几十次,动画顺不顺、快不快、会不会卡,感知非常直接。
我自己的经验是,Lottie 这套方案的成败不在代码,而在两头:一头是设计产出 JSON 的规范性,另一头是状态管理的清晰度。代码本身其实就百来行,ValueAnimator加setProgress的组合翻来覆去就那几种写法。真正花时间的是和设计对齐时间轴、排查锚点偏移、处理连点时序这些琐碎事。
最后分享一个我最近在用的扩展思路:把 tab 切换动画和页面的转场动画做成一套“共享节奏”。比如导航栏动画 300ms,页面内容做一个同样 300ms 的淡入位移,两者用同一个插值器。视觉上会形成一种整体感,用户会觉得整个 App 的切换是“一个动作”,而不是导航栏动它的、页面动它的。这个改动成本很低,但效果提升挺明显,值得试一试。