iOS系统中导航栏的转场解决方案与最佳实践

背景

目前,开源社区和业界内已经存在一些 iOS 导航栏转场的解决方案,但对于历史包袱沉重的美团 App 而言,这些解决方案并不完美。有的方案不能满足复杂的页面跳转场景,有的方案迁移成本较大,为此我们提出了一套解决方案并开发了相应的转场库,目前该转场库已经成为美团点评多个 App 的基础组件之一。

在美团 App 开发的早期,涉及到导航栏样式改变的需求时,经常会遇到转场效果不佳或者与预期样式不符的“小问题”。在业务体量较小的情况下,为了满足快速的业务迭代,通常会使用硬编码的方式来解决这一类“小问题”。但随着美团 App 业务的高速发展,这种硬编码的方式遇到了以下的挑战:

  1. 业务模块的不断增加,导致使用硬编码方式编写的代码维护成本增加,代码质量迅速下降。
  2. 大型 App 的路由系统使得页面间的跳转变得更加自由和灵活,也使得导航栏相关的问题激增,不但增加了问题的排查难度,还降低了整体的开发效率。
  3. App 中的导航栏属于各个业务方的公用资源,由于缺乏相应的约束机制和最佳实践,导致业务方之间的代码耦合程度不断增加。

从各个角度来看,硬编码的方式已经不能很好的解决此类问题,美团 App 需要一个更加合理、更加持久、更加简单易行的解决方案来处理导航栏转场问题。

本文将从导航栏的概念入手,通过讲解转场过程中的状态管理、转换时机和样式变化等内容,引出了在大型应用中导航栏转场的三种常见解决方案,并对美团点评的解决方案进行剖析。

重新认识导航栏

导航栏里的 MVC

在 iOS 系统中, 苹果公司不仅建议开发者遵循 MVC 开发框架,在它们的代码里也可以看到 MVC 的影子,导航栏组件的构成就是一个类似 MVC 的结构,让我们先看看下面这张图:

在这张图里,我们可以将 UINavigationController 看做是 C,UINavigationBar 看做是 V,而 UIViewController 和 UINavigationItem 组成的 Stack 可以看做是 M。这里要说明的是,每个 UIViewController 都有一个属于自己的 UINavigationItem,也就是说它们是一一对应的。

self.navigationItem

很多时候,国内的开发者会将 UINavigationBar 和 UINavigationController 混在一起叫导航栏,这样的做法不仅增加了开发者之间的沟通成本,也容易导致误解。毕竟它们是两个完全不一样的东西。

所以本文为了更好的阐明问题,会采用英文区分不同的概念,当需要描述笼统的导航栏概念时,会使用导航栏组件一词。

通过这一节的回顾,我们应该明确了 NavigationItem、ViewController、NavigationBar 和 NavigationController 在 MVC 框架下的角色。下面我们会重新梳理一下导航栏的生命周期和各个相关方法的调用顺序。

导航栏组件的生命周期

大家可以通过下图获得更为直观的感受,进而了解到导航栏组件在 push 过程中各个方法的调用顺序。

值得注意的地方有两点:

第一个是 UINavigationController 作为 UINavigationBar 的代理,在没有特殊需求的情况下,不应该修改其代理方法,这里是通过符号断点获取它们的调用顺序。如果我们创建了一个自定义的导航栏组件系统,它的调用顺序可能会与此不同。

viewWillLayoutSubviewviewDidLayoutSubview

下面这张图展示了导航栏在 pop 过程中各个方法的调用顺序:

viewDidLoad

通过这两个图,我们已经基本了解了导航栏组件的生命周期和相关方法的调用顺序,这也是后面章节的理论基础。

导航栏组件的改变与革新

导航栏组件在 iOS 11 发布时,获得了重大更新,这个更新可不是增加了一个大标题样式(Large Title Display Mode)那么简单,需要注意的地方大概有两点:

导航栏组件到底怎么了?

经常有人说 iOS 的原生导航栏组件不好使用,抱怨主要集中在导航栏组件的状态管理和控件的布局问题上。

控件的布局问题随着 iOS 11 的到来已经变得相对容易处理了不少,但导航栏组件的状态管理仍然让开发者头疼不已。

可能已经有朋友在思考导航栏组件的状态管理到底是什么东西?不要着急,下面的章节就会做相关的介绍。

导航栏的状态管理

虽然导航栏组件的 push 和 pop 动画给人一种每次操作后都会创建一遍导航栏组件的错觉,但实际上这些 ViewController 都是由一个 NavigationController 所管理,所以你看到的 NavigationBar 是唯一的。

在 NavigationController 的 Stack 存储结构下,每当 Stack 中的 ViewController 修改了导航栏,势必会影响其他 ViewController 展示的效果。

例如下图所示的场景,如果 NavigationBar 原先的颜色是绿色,但之后进入 Stack 里的 ViewController 将 NavigationBar 颜色修改为紫色后,在此之后 push 的 ViewController 会从默认的绿色变为紫色,直到有新的 ViewController 修改导航栏颜色才会发生变化。

虽然在 push 过程中,NavigationBar 的变化听起来合情合理,但如果你在 NavigationBar 为绿色的 ViewController 里设置不当的话,那么当你 pop 回这个 ViewController 时,NavigationBar 可就不一定是绿色了,它还会保持为紫色的状态。

通过这个例子,我们大概会意识到在导航栏里的 Stack 中,每个 ViewController 都可以永久的影响导航栏样式,这种全局性的变化要求我们在实际开发中必须坚持“谁修改,谁复原”的原则,否则就会造成导航栏状态的混乱。这不仅仅是样式上的混乱,在一些极端状况下,还有可能会引起 Stack 混乱,进而造成 Crash 的情况。

导航栏样式转换的时机

我们刚才提到了“谁修改,谁复原”的原则,但何时修改,何时复原呢?

viewWillAppear:viewWillDisappear:

苹果公司在它的 API 文档中专门用了一段文字来解答大家的疑惑,这段文字的标题为《Handling View-Related Notifications》,在这里我们直接引用原文:

When the visibility of its views changes, a view controller automatically calls its own methods so that subclasses can respond to the change. Use a method like viewWillAppear: to prepare your views to appear onscreen, and use the viewWillDisappear: to save changes or other state information. Use other methods to make appropriate changes.
Figure 1 shows the possible visible states for a view controller’s views and the state transitions that can occur. Not all ‘will’ callback methods are paired with only a ‘did’ callback method. You need to ensure that if you start a process in a ‘will’ callback method, you end the process in both the corresponding ‘did’ and the opposite ‘will’ callback method.

这里很好的解释了所有的 will 系列方法和 did 系列方法的对应关系,同时也给我们吃了一个定心丸,那就是在 appearing 和 disappearing 状态之间会由 will 系列方法进行衔接,避免了状态中断。这对于连续 push 或者连续 pop 的情况是及其重要的,否则我们无法做到 “谁修改,谁复原”的原则。

通常来说,如果只是一个简单的导航栏样式变化,我们的代码结构大体会如下所示:

- (void)viewWillAppear:(BOOL)animated{
[super viewWillAppear:animated];
// MARK: change the navigationbar style
} - (void)viewWillDisappear:(BOOL)animated{
[super viewWillDisappear:animated];
// MARK: restore the navigationbar style
}

现在,我们明确了修改时机,接下来要明确的就是导航栏的样式会进行怎样的变化。

导航栏的样式变化

对于不同 ViewController 之间的导航栏样式变化,大多可以总结为两种情况:

  1. 导航栏的显示与否
  2. 导航栏的颜色变化

导航栏的显示与否

setNavigationBarHidden:animated:
setNavigationBarHidden:setNavigationBarHidden:animated:setNavigationBarHidden:setNavigationBarHidden:animated:

导航栏的颜色变化

translucent
translucenttranslucent
setBackgroundColor:
setBackgroundImage:forBarMetrics:

对于第二种情况,这里有三点需要提示:

[UIImage new]setBackgroundImage:forBarMetrics:alphatranslucenttranslucentalphatranslucenttranslucenttranslucentbarStyleUIBarStyleBlackUIBarStyleDefaultbarTintColor
transparenttranslucentopaquealphaopacity

在刚接触导航栏 API 时,许多人经常会把文档里的这些英文词搞混,也不太明白带有这些词的变量为什么有的是布尔型,有的是浮点型,总之一切都让人很困惑。

在这里将做了一个总结,这对于理解 Apple 的 API 设计原则十分有帮助。

transparenttranslucentopaque
transparenttranslucentopaque

这三个词更多的是用来表述一种状态,不需要量化,所以这与这三个词相关的属性,一般都是 BOOL 类型。

alphaopacity
opacityalphaalpha
div {
width: 100px;
height: 100px;
background: rgba(0,0,0,0.5);
border: 1px solid #000000;
opacity: 0.5;
}
alphabackgroudColorborderColoralphaopacity
alphaalphaopacityalphaalphaalpha

由于这两个词都是在描述程度,所以我们看到它们都是 CGFloat 类型:

转场过程中需要注意的问题和细节

说完了导航栏的转场时机和转场方式,其实大体上你已经能处理好不同样式间的转换,但还有一些细节需要你去考虑,下面我们来说说其中需要你关注的两点。

translucent 属性带来的布局改变

translucent 会影响导航栏组件里 ViewController 的 View 布局,这里需要大家理清 5 个 API 的使用场景:

edgesForExtendedLayoutextendedLayoutIncluedsOpaqueBarsautomaticallyAdjustScrollViewInsetscontentInsetAdjustmentBehavioradditionalSafeAreaInsets

前三个 API 是 iOS 11 之前的 API,它们之间的区别和联系在 Stack Overflow 上有一个比较精彩的回答 - Explaining difference between automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, edgesForExtendedLayout in iOS7,我在这里就不做详细阐述,总结一下它的观点就是:

如果我们先定义一个 UINavigationController,它里面包含了多个 UIViewController,每个 UIViewController 里面包含一个 UIView 对象:

edgesForExtendedLayoutedgesForExtendedLayoutUIRectEdgeAllautomaticallyAdjustsScrollViewInsetsedgesForExtendedLayoutextendedLayoutIncludesOpaqueBarsextendedLayoutIncludesOpaqueBarsautomaticallyAdjustsScrollViewInsets
topLayoutGuidebottomLayoutGuidetopLayoutGuidebottomLayoutGuide

如果想对 Safe Area 带来的改变有更全面的认识,十分推荐阅读 Rosberry 的工程师 Evgeny Mikhaylov 在 Medium 上的文章 iOS Safe Area,这篇文章基本涵盖了 iOS 11 中所有与 Safe Area 相关的 API 并给出了真正合理的解释。

contentInsetAdjustmentBehavioradditionalSafeAreaInsets
contentInsetAdjustmentBehaviorautomaticallyAdjustsScrollViewInsets
if (@available(iOS 11.0, *)) {
self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else {
self.automaticallyAdjustsScrollViewInsets = NO;
}

此处的代码片段只是一个示例,并不适用所有的业务场景,这里需要着重说明几个问题:

contentInsetAdjustmentBehaviorUIScrollViewContentInsetAdjustmentAutomaticUIScrollViewContentInsetAdjustmentScrollableAxesUIScrollViewContentInsetAdjustmentAlwaysautomaticallyAdjustsScrollViewInsetscontentInsetadjustedContentInsetcontentInset
additionalSafeAreaInsetsadditionalSafeAreaInsets

backIndicator 上的动画

backIndicatorImagebackIndicatorTransitionMaskImage
backIndicatorImagebackIndicatorTransitionMaskImage

想要成功的自定义返回按钮的图标样式,我们需要同时设置这两个 API ,从字面上来看,它们一个是返回图片本身,另一个是返回图片在转场时用到的 mask 图片,看起来不怎么难,我们写一段代码试试效果:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrowMask"];

代码里的图片如下所示:

也许大多数人在这里会都认为,mask 图片会遮挡住文字使其在遇到返回按钮右边缘的时候就消失。但实际的运行效果是怎么样子的呢?我们来看一下:

在上面的图片中,我们可以看到返回按钮的文字从返回按钮的图片下面穿过并且文字被图片所遮挡,这种动画看起来十分奇怪,这是无法接受的。我们需要做点修改:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrow"];
backIndicatorTransitionMaskImage

到这里,可能大多数人都会好奇,这代码也能行?让我们看下它实际的效果:

在上面的图中,我们看到文字在到达图片的右边缘时就从下方穿过并被完全遮盖住了,这种动画效果虽然比上面好一些,但仍然有改进的空间,不过这里我们先不继续优化了,我们先来讨论一下它们背后的运作原理。

iOS 系统会将 indicatorImage 中不透明的颜色绘制成返回按钮的图标, indicatorTransitionMaskImage 与 indicatorImage 的作用不同。indicatorTransitionMaskImage 将自身不透明的区域像 mask 一样作用在 indicatorImage 上,这样就保证了返回按钮中的文字像左移动时,文字只出现在被 mask 的区域,也就是 indicatorTransitionMaskImage 中不透明的区域。

掌握了原理,我们来解释下刚才的两种现象:

在第一种实现中,我们提供的 indicatorTransitionMaskImage 覆盖了整个返回按钮的图标,所以我们在转场过程中可以清晰的看到返回按钮的文字。

在第二种实现中,我们使用 indicatorImage 作为 indicatorTransitionMaskImage,记住文字是只能出现在 indicatorTransitionMaskImage 里不透明的区域,所以显然返回按钮中的文字会在图标的最右边就已经被遮挡住了,因为那片区域是透明的。

那么前面提到的进一步优化指的是什么呢?

让我们来看一下下面这个示例图,为了更好的区分,我们将 indicatorTransitionMaskImage 用红色进行标注。黑色仍然是 indicatorImage。

按照刚才介绍的原理,我们应该可以理解,现在文字只会出现在红色区域,那么它的实际效果是什么样子的呢,我们可以看下图:

现在,一个完美的返回动画,诞生啦!

此节所用的部分效果图出自 Ray Wenderlich 的文章 UIAppearance Tutorial: Getting Started

导航栏的跳转或许可以这么玩儿...

前两章的铺垫就是为了这一章的内容,所以现在让我们开始今天的大餐吧。

这样真的好么?

viewWillAppear:viewWillDisappear:
viewWillAppear:viewWillDisappear:viewWillAppear:viewWillDisappear:

现在我们的问题就来了,如何让导航栏的转场更加灵活且相互独立呢?

常见的解决方案如下所示:

  1. 重新实现一个类似 UINavigationController 的容器类视图管理器,这个容器类视图管理器做好不同 ViewController 间的导航栏样式转换工作,而每个 ViewController 只需要关心自身的样式即可。

  2. 将系统原有导航栏的背景设置为透明色,同时在每个 ViewController 上添加一个 View 或者 NavigationBar 来充当我们实际看到的导航栏,每个 ViewController 同样只需要关心自身的样式即可。

  3. 在转场的过程中隐藏原有的导航栏并添加假的 NavigationBar,当转场结束后删除假的 NavigationBar 并恢复原有的导航栏,这一过程可以通过 Swizzle 的方式完成,而每个 ViewController 只需要关心自身的样式即可。

这三种方案各有优劣,我们在网上也可以看到很多关于它们的讨论。

例如方案一,虽然看起来工作量大且难度高,但是这个工作一旦完成,我们就会将处理导航栏转场的主动权牢牢抓在手里。但这个方案的一个弊端就是,如果苹果修改了导航栏的整体风格,就好比 iOS 11 的大标题特效,那么工作量就来了。

对于方案二而言,虽然看起来简单易用,但这需要一个良好的继承关系,如果整个工程里的继承关系混乱或者是历史包袱比较重,后续的维护就像“打补丁”一样,另外这个方案也需要良好的团队代码规范和完善的技术文档来做辅助。

对于方案三而言,它不需要所谓的继承关系,使用起来也相对简单,这对于那些继承关系和历史包袱比较重的工程而言,这一个不错的解决方案,但在解决 Bug 的时候,Swizzle 这种方式无疑会增加解决问题的时间成本和学习成本。

我们的解决方案

在美团 App 的早期,各个业务方都想充分利用导航栏的能力,但对于导航栏的状态维护缺乏理解与关注,随着业务方的增加和代码量的上升,与导航栏相关的问题逐渐暴露出来,此时我们才意识到这个问题的严重性。

大型 App 的导航栏问题就像一个典型的“公地悲剧”问题。在软件行业,公用代码的所有权可以被视作“公地”,因为不注重长期需求而容易遭到消耗。如果开发人员倾向于交付“价值”,而以可维护性和可理解性为代价,那么这个问题就特别普遍了。如果是这种情况,每次代码修改将大大减少其总体质量,最终导致软件的不可维护。

所以解决这个问题的核心在于:明确公用代码的所有权,并在开发期施加约束。

明确公用代码的所有权,可以理解为将导航栏相关的组件抽离成一个单独的组件,并交由特定的团队维护。而在开发期施加约束,则意味着我们要提供一套完整的解决方案让各个业务方遵守。

这一节我们会以美团内部的解决方案为例,讲解如何实现一个流畅的导航栏跳转过程和相关使用方法。

设计理念

使用者只用关心当前 ViewController 的 NavigationBar 样式,而不用在 push 或者 pop 的时候去处理 NavigationBar 样式。

举个例子来说,当从 A 页面 push 到 B 页面的时候,转场库会保存 A 页面的导航栏样式,当 pop 回去后就会还原成以前的样式,因此我们不用考虑 pop 后导航栏样式会改变的情况,同时我们也不必考虑 push 后的情况,因为这个是页面 B 本身需要考虑的。

使用方法

转场库的使用十分简单,我们不需要 import 任何头文件,因为它在底层通过 Method Swizzling 进行了处理,只需要在使用的时候遵循下面 4 点即可:

viewDidLoadviewWillAppear:setBackgroundImage:forBarMetrics:shadowImageviewWillDisappear:
setBackgroundImage:forBarMetrics:alphaalphatranslucent

基本原理

以上,我们讲完了设计理念和使用方法,那么我们来看看美团的转场库到底做了什么?

从大方向上来看,美团使用的是前面所说的第三种方案,不过它也有一些自己独特的地方,为了更好的让大家理解整个过程,我们设计这样一个场景,从页面 A push 到页面 B,结合之前探讨过的方法调用顺序,我们可以知道几个核心方法的调用顺序大致如下:

pushViewController:animated:viewDidLoadviewWillAppear:viewWillLayoutSubviewsviewDidAppear:

在 push 过程的开始,转场库会在页面 A 自身的 view 上添加一个与导航栏一模一样的 NavigationBar 并将真的导航栏隐藏。之后这个假的导航栏会一直存在页面 A 上,用于保留 A 离开时的导航栏样式。

viewDidLoadviewWillAppear:
viewWillLayoutSubviewsviewDidLoadviewWillAppear:
viewWillAppear:
viewDidAppear:

为了让大家更好地理解上面的内容,请参考下图:

说完了 push 过程,我们再来说一下从页面 B pop 回页面 A 的过程,几个核心方法的调用顺序如下:

popViewControllerAnimated:viewWillAppear:viewDidAppear:
dealloc
viewWillAppear:viewDidLoad
viewDidAppear:

同样,我们可以参考下面的图来理解上面所说的内容:

现在,大家应该对我们美团的解决方案有了一定的认识,但在实际开发过程中,还需要考虑一些布局和适配的问题。

最佳实践

在维护这套转场方案的时间里,我们总结了一些此类方案的最佳实践。

判断导航栏问题的基本准则

如果发现导航栏在转场过程中出现了样式错乱,可以遵循以下几点基本原则:

viewDidLoadviewWillAppear:viewWillDisappear:translucent

只关心当前页面的样式

viewWillAppear:viewDidLoad

透明样式导航栏的正确设置方法

如果需要一个透明效果的导航栏,可以使用如下代码实现:

[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
self.navigationController.navigationBar.shadowImage = [UIImage new];

导航栏的颜色渐变效果

alphasetBackgroundImage:forBarMetrics:alphascrollView.contentOffsetalpha
contentOffset

导航栏背景图片的规范

alphaalphatranslucent

如果真的要隐藏导航栏

如果我们需要隐藏导航栏,请保证所有的 ViewController 能坚持如下原则:

viewWillAppear:setNavigationBarHidden:animated:setNavigationBarHidden:

转场动画与导航栏隐藏动画的一致性

如果在转场的过程中还会显示或者隐藏导航栏的话,请保证两个方法的动画参数一致。

- (void)viewWillAppear:(BOOL)animated{
[self.navigationController setNavigationBarHidden:YES animated:animated];
}
viewWillAppear:

导航栏固有的系统问题

目前已知的有两个系统问题如下:

  1. 当前后两个 ViewController 的导航栏都处于隐藏状态,然后在后一个 ViewController 中使用返回手势 pop 到一半时取消,再连续 push 多个页面时会造成导航栏的 Stack 混乱或者 Crash。
  2. 当页面的层级结构大体如下所示时,在红色导航栏的 Stack 中,返回手势会大概率的出现跨层级的跳转,多次后会导致整个导航栏的 Stack 错乱或者 Crash。

导航栏内置组件的布局规范

导航栏里的组件布局在 iOS 11 后发生了改变,原有的一些解决方案已经失效,这些内容不在本篇文章的讨论范围之内,推荐阅读UIBarButtonItem 在 iOS 11 上的改变及应对方案,这篇文章详细的解释了 iOS 11 里的变化和可行的应对方案。

总结

本文涉及内容较多,从 iOS 系统下的导航栏概念到大型应用里的最佳实践,这里我们总结一下整篇文章的核心内容:

  • 理解导航栏组件的结构和相关方法的生命周期。
    • 导航栏组件的结构留有 MVC 架构的影子,在解决问题时,要去相应的层级处理。
    • 转场问题的关键点是方法的调用顺序,所以了解生命周期是解决此类问题的基础。
  • 状态管理,转换时机和样式变化是导航栏里常见问题的三种表现形式,遇到实际问题时需要区分清楚。
    • 状态管理要坚持“谁修改,谁复原”的原则。
    • 转换时机的设定要做到连续可执行。
    • 样式变化的核心点是导航栏的显示与否与颜色变化。
  • 为了更好的配合大型应用里的路由系统,导航栏转场的常见解决方案有三种,各有利弊,需要根据自身的业务场景和历史包袱做取舍。
    • 解决方案1:自定义导航栏组件。
    • 解决方案2:在原有导航栏组件里添加 Fake Bar。
    • 解决方案3:在导航栏转场过程中添加 Fake Bar。
  • 美团在实际开发过程中采用了第三种方案,并给出了适合美团 App 的最佳实践。

特别感谢莫洲骐在此项目里的贡献与付出。

参考链接

作者简介

思琦,美团点评 iOS 工程师。2016 年加入美团,负责美团平台的业务开发及 UI 组件的维护工作。

招聘

美团平台诚招 iOS、Android、FE 高级/资深工程师和技术专家,Base 北京、上海、成都,欢迎有兴趣的同学投递简历到zhangsiqi04@meituan.com。