目前,开源社区和业界内已经存在一些iOS导航栏转场的解决方案,但对于历史包袱沉重的美团App而言,这些解决方案并不完美。有的方案不能满足复杂的页面跳转场景,有的方案迁移成本较大,为此我们提出了一套解决方案并开发了相应的转场库,目前该转场库已经成为美团点评多个App的基础组件之一。
在美团App开发的早期,涉及到导航栏样式改变的需求时,经常会遇到转场效果不佳或者与预期样式不符的“小问题”。在业务体量较小的情况下,为了满足快速的业务迭代,通常会使用硬编码的方式来解决这一类“小问题”。但随着美团App业务的高速发展,这种硬编码的方式遇到了以下的挑战:
从各个角度来看,硬编码的方式已经不能很好的解决此类问题,美团App需要一个更加合理、更加持久、更加简单易行的解决方案来处理导航栏转场问题。
本文将从导航栏的概念入手,通过讲解转场过程中的状态管理、转换时机和样式变化等内容,引出了在大型应用中导航栏转场的三种常见解决方案,并对美团点评的解决方案进行剖析。
在iOS系统中,苹果公司不仅建议开发者遵循MVC开发框架,在它们的代码里也可以看到MVC的影子,导航栏组件的构成就是一个类似MVC的结构,让我们先看看下面这张图:
在这张图里,我们可以将UINavigationController看做是C,UINavigationBar看做是V,而UIViewController和UINavigationItem组成的Stack可以看做是M。这里要说明的是,每个UIViewController都有一个属于自己的UINavigationItem,也就是说它们是一一对应的。
很多时候,国内的开发者会将UINavigationBar和UINavigationController混在一起叫导航栏,这样的做法不仅增加了开发者之间的沟通成本,也容易导致误解。毕竟它们是两个完全不一样的东西。
所以本文为了更好的阐明问题,会采用英文区分不同的概念,当需要描述笼统的导航栏概念时,会使用导航栏组件一词。
大家可以通过下图获得更为直观的感受,进而了解到导航栏组件在push过程中各个方法的调用顺序。
值得注意的地方有两点:
第一个是UINavigationController作为UINavigationBar的代理,在没有特殊需求的情况下,不应该修改其代理方法,这里是通过符号断点获取它们的调用顺序。如果我们创建了一个自定义的导航栏组件系统,它的调用顺序可能会与此不同。
下面这张图展示了导航栏在pop过程中各个方法的调用顺序:
除了上面说到的两点,pop过程中还需要注意一点,那就是从B返回到A的过程中,A视图控制器的viewDidLoad方法并不会被调用。关于这个问题,只要提醒一下,大多数人都会反应过来是为什么。不过在实际开发过程中,总会有人忘记这一点。
导航栏组件在iOS11发布时,获得了重大更新,这个更新可不是增加了一个大标题样式(LargeTitleDisplayMode)那么简单,需要注意的地方大概有两点:
经常有人说iOS的原生导航栏组件不好使用,抱怨主要集中在导航栏组件的状态管理和控件的布局问题上。
控件的布局问题随着iOS11的到来已经变得相对容易处理了不少,但导航栏组件的状态管理仍然让开发者头疼不已。
虽然导航栏组件的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的情况。
我们刚才提到了“谁修改,谁复原”的原则,但何时修改,何时复原呢?
苹果公司在它的API文档中专门用了一段文字来解答大家的疑惑,这段文字的标题为《HandlingView-RelatedNotifications》,在这里我们直接引用原文:
Whenthevisibilityofitsviewschanges,aviewcontrollerautomaticallycallsitsownmethodssothatsubclassescanrespondtothechange.UseamethodlikeviewWillAppear:toprepareyourviewstoappearonscreen,andusetheviewWillDisappear:tosavechangesorotherstateinformation.Useothermethodstomakeappropriatechanges.
Figure1showsthepossiblevisiblestatesforaviewcontroller’sviewsandthestatetransitionsthatcanoccur.Notall‘will’callbackmethodsarepairedwithonlya‘did’callbackmethod.Youneedtoensurethatifyoustartaprocessina‘will’callbackmethod,youendtheprocessinboththecorresponding‘did’andtheopposite‘will’callbackmethod.
这里很好的解释了所有的will系列方法和did系列方法的对应关系,同时也给我们吃了一个定心丸,那就是在appearing和disappearing状态之间会由will系列方法进行衔接,避免了状态中断。这对于连续push或者连续pop的情况是及其重要的,否则我们无法做到“谁修改,谁复原”的原则。
通常来说,如果只是一个简单的导航栏样式变化,我们的代码结构大体会如下所示:
-(void)viewWillAppear:(BOOL)animated{[superviewWillAppear:animated];//MARK:changethenavigationbarstyle}-(void)viewWillDisappear:(BOOL)animated{[superviewWillDisappear:animated];//MARK:restorethenavigationbarstyle}现在,我们明确了修改时机,接下来要明确的就是导航栏的样式会进行怎样的变化。
对于不同ViewController之间的导航栏样式变化,大多可以总结为两种情况:
对于显示与否的问题,可以在上一节提到的两个方法里调用setNavigationBarHidden:animated:方法,这里需要提醒的有两点:
颜色变化的问题就稍微复杂一些,在iOS7后,导航栏增加了translucent效果,这使得导航栏背景色的变化出现了两种情况:
对于第一种情况,我们需要调用UINavigationBar的setBackgroundColor:方法。
对于第二种情况我们需要调用UINavigationBar的setBackgroundImage:forBarMetrics:方法。
对于第二种情况,这里有三点需要提示:
在刚接触导航栏API时,许多人经常会把文档里的这些英文词搞混,也不太明白带有这些词的变量为什么有的是布尔型,有的是浮点型,总之一切都让人很困惑。
在这里将做了一个总结,这对于理解Apple的API设计原则十分有帮助。
transparent,translucent,opaque三个词经常会用在一起,它用于描述物体的透光强度,为了让大家更好的理解这三个词,这里做了三个比喻:
alpha和opacity经常会在一起使用,它要表示的就是透明度,在Web端这两个属性有着明显的区别。
在Web端里,opacity是设定整个元素的透明值,而alpha一般是放在颜色设置里面,所以我们可以做到对特定对元素的某个属性设定alpha,比如背景、边框、文字等。
div{width:100px;height:100px;background:rgba(0,0,0,0.5);border:1pxsolid#000000;opacity:0.5;}这一概念同样适用于iOS里的概念,比如我们可以通过alpha通道单独的去设置backgroudColor、borderColor,它们互不影响,且有着独立的alpha通道,我们也可以通过opacity统一设置整个view的透明度。
但与Web端不一致的是,iOS里面的view不光拥有独立的alpha属性,同时也是基于CALayer,所以我们可以看到任意UIView对象下面都会有一个layer的属性,用于表明CALayer对象。view的alpha属性与layer里面的opacity属性是一个相等的关系,需要注意的是view上的alpha属性是Web端并不具备的一个能力,所以笔者认为:在iOS中去说alpha时,要区分是在说view上的属性,还是在说颜色通道里的alpha。
由于这两个词都是在描述程度,所以我们看到它们都是CGFloat类型:
translucent会影响导航栏组件里ViewController的View布局,这里需要大家理清5个API的使用场景:
如果我们先定义一个UINavigationController,它里面包含了多个UIViewController,每个UIViewController里面包含一个UIView对象:
这些调整布局的API背后是一套基于topLayoutGuide和bottomLayoutGuide的计算而已,在iOS11后,Apple提出了SafeArea的概念,将原先分裂开来的topLayoutGuide和bottomLayoutGuide整合到一个统一的LayoutGuide中,也就是所谓的SafeArea,这个改变看起来似乎不是很大,但它的出现确实方便了开发者。
这里只说一下contentInsetAdjustmentBehavior和additionalSafeAreaInsets两个API。
对于contentInsetAdjustmentBehavior属性而言,它的诞生也意味着automaticallyAdjustsScrollViewInsets属性的失效,所以我们在那些已经适配了iOS11的工程里能看到如下类似的代码:
if(@available(iOS11.0,*)){self.tableView.contentInsetAdjustmentBehavior=UIScrollViewContentInsetAdjustmentNever;}else{self.automaticallyAdjustsScrollViewInsets=NO;}此处的代码片段只是一个示例,并不适用所有的业务场景,这里需要着重说明几个问题:
对于additionalSafeAreaInsets而言,如果系统提供的这几种行为并不能满足我们的布局要求,开发者还可以考虑使用additionalSafeAreaInsets属性做调整,这样的设定使得开发者可以更加灵活,更加自由的调整视图的布局。
苹果提供了许多修改导航栏组件样式的API,有关于布局的,有关于样式的,也有关于动画的。backIndicatorImage和backIndicatorTransitionMaskImage就是其中的两个API。
backIndicatorImage和backIndicatorTransitionMaskImage操作的是NavigationBar里返回按钮的图片,也就是下图红色圆圈所标注的区域。
想要成功的自定义返回按钮的图标样式,我们需要同时设置这两个API,从字面上来看,它们一个是返回图片本身,另一个是返回图片在转场时用到的mask图片,看起来不怎么难,我们写一段代码试试效果:
self.navigationController.navigationBar.backIndicatorImage=[UIImageimageNamed:@"backArrow"];self.navigationController.navigationBar.backIndicatorTransitionMaskImage=[UIImageimageNamed:@"backArrowMask"];代码里的图片如下所示:
也许大多数人在这里会都认为,mask图片会遮挡住文字使其在遇到返回按钮右边缘的时候就消失。但实际的运行效果是怎么样子的呢?我们来看一下:
在上面的图片中,我们可以看到返回按钮的文字从返回按钮的图片下面穿过并且文字被图片所遮挡,这种动画看起来十分奇怪,这是无法接受的。我们需要做点修改:
self.navigationController.navigationBar.backIndicatorImage=[UIImageimageNamed:@"backArrow"];self.navigationController.navigationBar.backIndicatorTransitionMaskImage=[UIImageimageNamed:@"backArrow"];这一次我们将backIndicatorTransitionMaskImage改为indicatorImage所用的图片。
到这里,可能大多数人都会好奇,这代码也能行?让我们看下它实际的效果:
在上面的图中,我们看到文字在到达图片的右边缘时就从下方穿过并被完全遮盖住了,这种动画效果虽然比上面好一些,但仍然有改进的空间,不过这里我们先不继续优化了,我们先来讨论一下它们背后的运作原理。
iOS系统会将indicatorImage中不透明的颜色绘制成返回按钮的图标,indicatorTransitionMaskImage与indicatorImage的作用不同。indicatorTransitionMaskImage将自身不透明的区域像mask一样作用在indicatorImage上,这样就保证了返回按钮中的文字像左移动时,文字只出现在被mask的区域,也就是indicatorTransitionMaskImage中不透明的区域。
掌握了原理,我们来解释下刚才的两种现象:
在第一种实现中,我们提供的indicatorTransitionMaskImage覆盖了整个返回按钮的图标,所以我们在转场过程中可以清晰的看到返回按钮的文字。
在第二种实现中,我们使用indicatorImage作为indicatorTransitionMaskImage,记住文字是只能出现在indicatorTransitionMaskImage里不透明的区域,所以显然返回按钮中的文字会在图标的最右边就已经被遮挡住了,因为那片区域是透明的。
那么前面提到的进一步优化指的是什么呢?
让我们来看一下下面这个示例图,为了更好的区分,我们将indicatorTransitionMaskImage用红色进行标注。黑色仍然是indicatorImage。
按照刚才介绍的原理,我们应该可以理解,现在文字只会出现在红色区域,那么它的实际效果是什么样子的呢,我们可以看下图:
现在,一个完美的返回动画,诞生啦!
前两章的铺垫就是为了这一章的内容,所以现在让我们开始今天的大餐吧。
刚才我们说了两个页面间NavigationBar的样式变化需要在各自的viewWillAppear:和viewWillDisappear:中进行设置。那么问题就来了:这样的设置会带来什么问题呢?
试想一下,当我们的页面会跳到不同的地方时,我们是不是要在viewWillAppear:和viewWillDisappear:方法里面写上一堆的判断呢?如果应用里还有router系统的话,那么页面间的跳转将变得更加不可预知,这时候又该如何在viewWillAppear:和viewWillDisappear:里做判断呢?
现在我们的问题就来了,如何让导航栏的转场更加灵活且相互独立呢?
常见的解决方案如下所示:
这三种方案各有优劣,我们在网上也可以看到很多关于它们的讨论。
例如方案一,虽然看起来工作量大且难度高,但是这个工作一旦完成,我们就会将处理导航栏转场的主动权牢牢抓在手里。但这个方案的一个弊端就是,如果苹果修改了导航栏的整体风格,就好比iOS11的大标题特效,那么工作量就来了。
对于方案二而言,虽然看起来简单易用,但这需要一个良好的继承关系,如果整个工程里的继承关系混乱或者是历史包袱比较重,后续的维护就像“打补丁”一样,另外这个方案也需要良好的团队代码规范和完善的技术文档来做辅助。
大型App的导航栏问题就像一个典型的“公地悲剧”问题。在软件行业,公用代码的所有权可以被视作“公地”,因为不注重长期需求而容易遭到消耗。如果开发人员倾向于交付“价值”,而以可维护性和可理解性为代价,那么这个问题就特别普遍了。如果是这种情况,每次代码修改将大大减少其总体质量,最终导致软件的不可维护。
所以解决这个问题的核心在于:明确公用代码的所有权,并在开发期施加约束。
使用者只用关心当前ViewController的NavigationBar样式,而不用在push或者pop的时候去处理NavigationBar样式。
举个例子来说,当从A页面push到B页面的时候,转场库会保存A页面的导航栏样式,当pop回去后就会还原成以前的样式,因此我们不用考虑pop后导航栏样式会改变的情况,同时我们也不必考虑push后的情况,因为这个是页面B本身需要考虑的。
转场库的使用十分简单,我们不需要import任何头文件,因为它在底层通过MethodSwizzling进行了处理,只需要在使用的时候遵循下面4点即可:
隐式修改是指使用setBackgroundImage:forBarMetrics:方法时,如果image里的像素点没有alpha通道或者alpha全部等于1会使得translucent变为NO或者nil。
以上,我们讲完了设计理念和使用方法,那么我们来看看美团的转场库到底做了什么?
从大方向上来看,美团使用的是前面所说的第三种方案,不过它也有一些自己独特的地方,为了更好的让大家理解整个过程,我们设计这样一个场景,从页面Apush到页面B,结合之前探讨过的方法调用顺序,我们可以知道几个核心方法的调用顺序大致如下:
在push过程的开始,转场库会在页面A自身的view上添加一个与导航栏一模一样的NavigationBar并将真的导航栏隐藏。之后这个假的导航栏会一直存在页面A上,用于保留A离开时的导航栏样式。
等到页面B调用viewDidLoad或者viewWillAppear:的时候,开发者在这里自行设置真的导航栏样式。转场库在这里会对页面布局做一些修正和辅助操作,但不会影响导航栏的样式。
等到页面B调用viewWillLayoutSubviews的时候,转场库会在页面B自身的view上添加一个与真的导航栏一模一样的NavigationBar,同时将真的导航栏隐藏。此时不论真的导航栏,还是假的导航栏都已经与viewDidLoad或者viewWillAppear:里设置的一样的。
当然,这一步也可以放在viewWillAppear:里并在dispatchmainqueue的下一个runloop中处理。
等到页面B调用viewDidAppear:的时候,转场库会将假的导航栏样式设置到真的导航栏中,并将假的导航栏从视图层级中移除,最终将真的导航栏显示出来。
为了让大家更好地理解上面的内容,请参考下图:
说完了push过程,我们再来说一下从页面Bpop回页面A的过程,几个核心方法的调用顺序如下:
在pop过程的开始,转场库会在页面B自身的view上添加一个与导航栏一模一样的NavigationBar并将真的导航栏隐藏,虽然这个假的导航栏会一直存在于页面B上,但它自身会随着页面B的dealloc而消亡。
等到页面A调用viewWillAppear:的时候,开发者在这里自行设置真的导航栏样式。当然我们也可以不设置,因为这时候页面A还持有一个假的导航栏,这里还保留着我们之前在viewDidLoad里写的导航栏样式。
等到页面A调用viewDidAppear:的时候,转场库会将假的导航栏样式设置到真的导航栏中,并将假的导航栏从视图层级中移除,最终将真的导航栏显示出来。
同样,我们可以参考下面的图来理解上面所说的内容:
现在,大家应该对我们美团的解决方案有了一定的认识,但在实际开发过程中,还需要考虑一些布局和适配的问题。
如果发现导航栏在转场过程中出现了样式错乱,可以遵循以下几点基本原则:
永远记住每个ViewController只用关心自己的样式,设置的时机点在viewWillAppear:或者viewDidLoad里。
如果需要一个透明效果的导航栏,可以使用如下代码实现:
[self.navigationController.navigationBarsetBackgroundImage:[UIImagenew]forBarMetrics:UIBarMetricsDefault];self.navigationController.navigationBar.shadowImage=[UIImagenew];导航栏的颜色渐变效果如果需要导航栏实现随滚动改变整体alpha值的效果,可以通过改变setBackgroundImage:forBarMetrics:方法里image的alpha值来达到目标,这里一般是使用监听scrollView.contentOffset的手段来做。请避免直接修改NavigationBar的alpha值。
还有一点需要注意的是,在页面转场的过程中,也会触发contentOffset的变化,所以请尽量在disappear的时候取消监听。否则会容易出现导航栏透明度的变化。
请避免背景图里的像素点没有alpha通道或者alpha全部等于1,容易触发translucent的隐式改变。
如果我们需要隐藏导航栏,请保证所有的ViewController能坚持如下原则:
如果在转场的过程中还会显示或者隐藏导航栏的话,请保证两个方法的动画参数一致。
-(void)viewWillAppear:(BOOL)animated{[self.navigationControllersetNavigationBarHidden:YESanimated:animated];}viewWillAppear:里的animated参数是受push和pop方法里animated参数影响。
目前已知的有两个系统问题如下:
美团平台诚招iOS、Android、FE高级/资深工程师和技术专家,Base北京、上海、成都,欢迎有兴趣的同学投递简历到zhangsiqi04@meituan.com。