App 中会经常需要在透明与不透明 NavigationBar 的页面相互切换。有些时候在透明 NavigationBar 页面甚至还需要根据 scrollView 的 contentOffset 来动态调整 NavigationBar 的透明度。有很多 App 都解决不好这里的问题(其实更多的是不在意)。这里 BAT 的反面教材各举一例:QQ、百度云、菜鸟裹裹。下边简单分析下他们的问题。
常用 App 分析
由于导航栏 push 和 pop 的动画都很短,这些问题都是通过侧滑手势来复现的。而且在侧滑返回的时候页面不一定会被 pop。由于实现导航栏隐藏的代码大部分都放在 viewWillAppear、viewDidAppear、viewWillDisappear、viewDidDisappear 四个方法中。通过侧滑手势滑动一段距离再退回去会让这四个方法都被调用,这样才会让其中的 BUG 变得明显。如不加说明文中都是通过侧滑手势进行的测试。
QQ 的问题出现在“动态” Tab 点击进入“好友动态”页面的过程。也许你会觉得这并没有什么问题。的确,这种方案在所有的解决方案中最少算是及格了。但是他的问题在于放弃了系统导航栏中 navigationItem 的动画,导致上一个页面和下一个页面的 title 和 navigationItem 重合。而系统 NavigationBar 的动画做了平移、渐变、裁剪来避免这些问题。QQ 算是一个偷懒的做法。
百度云的问题就要严重的多了。他的问题出现“更多” Tab 跳转到任意一个下级页面的过程中。当点击跳转之后,导航栏会突然出现。在 pop 结束后导航栏又突然消失,显得十分突兀。好在下级页面和本级页面的 Header 都是相近的颜色,如果不通过手势返回很难注意到这个问题。
如果说 QQ 的方案是 60 分,百度云的方法是 40 分的话。那菜鸟裹裹只能得 0 分了。不管是在 push 还是 pop,还是 viewWillAppear、viewDidAppear、viewWillDisappear、viewDidDisappear 的时候,导航栏统统突然出现突然消失,而且前后页面的颜色差异巨大。更夸张的是,他的每一个 Tab 都是这种透明导航栏的设计,所以这个 BUG 贯穿了整个 App 的一级页面跳转。(现在已经修复,但仍有很多问题)
说了这么多反面例子。说说一些正面的例子。天猫、淘宝、支付宝、今日头条、百度贴吧、去哪儿这些 App 都有处理过这些问题。他们都使用系统提供的方案,仅仅几行代码就可以。系统的方案放弃了系统导航栏的动画,但已经十分完美。
最后一个最优秀的例子就是微信。微信在导航栏上所有细节的处理都是最优秀的方案,这在所有 App 中是很罕见的。他保留了导航栏的毛玻璃效果和动画效果并解决了系统导航栏的 BUG。具体可以参考微信的聊天页面与红包页面之间的跳转。
系统 NavigationBar 的 BUG
分析了其他的 App 后就该讲讲本文的三种方案了。但在这之前先要指出一个系统导航栏的 BUG。就是当 pushViewController 时设置了 hideBottomBarWhenPushed 并且此时的 NavigationBar 是半透明效果,那么 NavigationBar 在隐藏与不隐藏 tabBar 的两个页面 push 和 pop 的过程中会出现透到下一层的现象。具体的现象就如 这里 和 这里 的描述。
这个 BUG 不仅在 hideBottomBarWhenPushed 时出现,文中添加假的 NavigationBar 等许多场景都会出现。但这都是在导航栏具有毛玻璃效果的情况下。它在百度贴吧的个人中心被完美复现。同样在微信中被完美解决。
三种解决方案
本文提供的三种方案如下图所示。你可以在这里下载代码。
说了这么多,你一定感觉很神秘了。不要被上边的瞎扯迷惑了,实际的实现都十分简单。下边就逐一说下思路。
Demo 的文件名与文中方案的对应关系
Hidden : 对应方案一
Opaque : 对应方案二
Translucent : 对应方案三
Native : 对应系统不隐藏 NavigationBar 效果
1.系统方案
系统本身就为这种场景设计了专门的 API。你只需要在需要隐藏导航栏的界面的 viewWillAppear 和 viewWillDisappear 调用如下 API 就可以了。
- (void)viewWillAppear:(BOOL)animated {
[self.navigationController setNavigationBarHidden:YES animated:YES];
}
- (void)viewWillDisappear:(BOOL)animated {
[self.navigationController setNavigationBarHidden:NO animated:YES];
}
那些导航栏突然出现和消失的都是因为他们分别在这两个方法调用的不是
[self.navigationController setNavigationBarHidden:YES animated:YES];
而是
self.navigationController.navigationBar.hidden = YES;
这样就可以实现像天猫、淘宝、支付宝、今日头条等上述列出 App 的效果。那些连这种效果都没有实现的,像 QQ 可能是为了追求导航栏渐变的效果,但其他的那些只能是因为产品本身(或程序员本身)不够追求细节导致的。
这种效果有一个坏处就是导航栏被完全隐藏了。这�导致导航栏的动画丢失,侧滑手势失效,而且如果想添加 navigationItem 只能手动在对应位置放上 Button。
Demo 中的这种方案还实现了导航栏的渐变,其实是在隐藏 NavigationBar 的界面添加了一个假的 NavigationBar。但这会导致系统 NavigationBar 的 BUG 再次出现。想要解决这个问题你只能放弃毛玻璃效果用假的 view 来替代假的 NavigationBar,或者放弃 iOS7 在 view 上实现系统毛玻璃的效果。
另外,要想使侧滑返回手势生效。可以自己添加 Gesture。或者可以在 viewController 中
使用如下代码:
self.interactivePopGestureRecognizer.delegate = self;
使用这行代码同样会带来一个不容易复现的问题,就是当界面多次 push 和 pop 后手势就会错乱,导致界面无法 push,这时侧滑变成了 push。Demo 中在 LPHiddenNavigationController 中解决了这个问题,大概就是通过在适当的时候调整手势的 enabled 来实现,这里不再详细说明。
self.interactivePopGestureRecognizer.enabled = NO;
2.全局替换
这种方案的原理同样十分简单。首先是通过如下代码来全局隐藏原 NavigationBar 的背景和分割线。这段代码是写在 baseNavigationController 里的,你也可以通过 appearance 来全局设置。
[self.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
[self.navigationBar setShadowImage:[UIImage new]];
之后通过 baseViewController 给每个 controller 添加假的 NavigationBar 充当背景。这同样会导致系统 NavigationBar 的 BUG 出现。你仍然可以通过放弃毛玻璃效果用假的 View 来替代假的 NavigationBar,或者放弃 iOS7 在 view 上实现系统毛玻璃的效果来避免这个问题。而且使用 NavigationBar 充当背景比较笨重,view 就显得轻便很多了。
这种方案保留了系统原有的 NavigationBar 动画,添加的假的 NavigationBar 只是充当背景,NavigationItem 还由原来的 NavigationBar 实现,已经是十分完美的方案了。但是缺点是需要将所有 viewController 继承自 baseViewController,如果项目本身没有 baseViewController 就会比较麻烦。
在这个 Demo 中同样做了导航栏的渐变效果。由于是每个页面都有一个假的 NavigationBar 充当背景,实现这个效果就十分简单了,只需要调整当前页面 NavigationBar 的透明度即可。这种方案去实现 NavigationBar 的颜色变化也是非常方便的。
3.微信方案
这种方案是在知乎上看到的。“会会”的工程师通过反编译微信找到了这种实现方案。这其实是对方案二的“改进”。方案二需要全局隐藏 NavigationBar 的背景才行,因为涉及到 push 和 pop 的两个 viewController 的 NavigationBar 是不同的透明度或者不同的颜色,而在 push 和 pop 的过程中两种不同的 NavigationBar 要同时显示出来,这就导致我们不可能去统一设置 NavigationBar 的样式。所以不管什么方案我们都只能通过添加假的 NavigationBar 来实现。
但这种方案添加的方式比较巧妙。在 push 或 pop 动画将要执行的时候隐藏系统 NavigationBar 的背景,给涉及到的需要不透明效果的 viewController 添加假的 NavigationBar。当动画执行完毕后,再将假的 NavigationBar 移除并根据当前页面是否需要显示 NavigationBar 来选择是否将系统 NavigationBar 的背景重新设置回来。这些功能均在 viewWillAppear、viewDidAppear、viewWillDisappear、viewDidDisappear 四个方法中实现,具体的代码逻辑稍微复杂一点,直接看代码来理解:
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
self.navigationController.navigationBar.userInteractionEnabled = NO;
[self removeFakeNavBar];
if (((LPTranslucentNavigationController *)self.navigationController).shouldAddFakeNavigationBar) {
[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
[self addFakeNavBar];
}
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
self.navigationController.navigationBar.userInteractionEnabled = YES;
if (![self useTransparentNavigationBar]) {
self.navigationController.navigationBar.barStyle = UINavigationBar.appearance.barStyle;
self.navigationController.navigationBar.translucent = YES;
[self.navigationController.navigationBar setBackgroundImage:[UINavigationBar.appearance backgroundImageForBarMetrics:UIBarMetricsDefault] forBarMetrics:UIBarMetricsDefault];
}
[self removeFakeNavBar];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self removeFakeNavBar];
if (((LPTranslucentNavigationController *)self.navigationController).shouldAddFakeNavigationBar) {
[self addFakeNavBar];
}
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
[self removeFakeNavBar];
}
其中 shouldAddFakeNavigationBar 是在 LPTranslucentNavigationController 中实现的方法。其值根据 navigationController 中最后两个 controller 是否需要透明效果来改变。如果有至少一个需要透明效果,就 return YES,否则 return NO。
另外我们还需要隐藏 NavigationBar 的分割线(Demo 中并没有做)。你也许会觉得像方案二一样添加一行代码不就可以了。
[self.navigationController.navigationBar setShadowImage:[UIImage new]];
但事实这行代码还是会触发系统 NavigationBar 的 BUG。所以只有通过遍历来去掉这根顽固的线了,以下代码在 Demo 中 UIViewController+HideBottomLine 里实现。
- (void)hideBottomLineInView:(UIView *)view {
UIImageView *navBarLineImageView = [self findLineImageViewUnder:view];
navBarLineImageView.hidden = YES;
}
- (void)showBottomLineInView:(UIView *)view {
UIImageView *navBarLineImageView = [self findLineImageViewUnder:view];
navBarLineImageView.hidden = NO;
}
- (UIImageView *)findLineImageViewUnder:(UIView *)view {
if ([view isKindOfClass:UIImageView.class] && view.bounds.size.height <= 1.0) {
return (UIImageView *)view;
}
for (UIView *subview in view.subviews) {
UIImageView *imageView = [self findLineImageViewUnder:subview];
if (imageView) {
return imageView;
}
}
return nil;
}
微信的红包页面其实并不是隐藏了 NavigationBar,而是将 NavigationBar 变为了红色。但思路是相同的,只是将调整透明度变成了调整颜色。
如果整个 App 中有很多这样 NavigationBar 不同的页面,可以参照 Demo 中将其写在 baseViewController 和 baseNavigationController。如果只是单个页面使用,只需要在单个页面按照上述思路去实现就行。
但这种方案也并不是没有缺点。他有一个小小的问题就是。由于频繁的切换真假 NavigationBar,如果通过侧滑手势快速频繁的“返回-取消-返回-取消” NavigationBar 可能偶尔会出现闪烁现象。当然这只是极限测试,正常使用中很难遇到。而且这只出现在从不透明页面向透明页面返回的时候。微信的红包页面调整的是颜色而不是透明度,所以不会出现这个问题。
另外一个问题就是,如果想在此基础上实现 NavigationBar 的渐变,就需要在透明 NavigationBar 的页面出现后再次添加一个假的 NavigationBar。这使得整个过程显得十分的繁琐,而且同样带来了系统 NavigationBar 的 BUG,还不如直接使用方案二。
总结
上述的方案中,如果不需要毛玻璃效果,都是比较完美的方案。如果需要毛玻璃且不想自己实现,只有第三种方案是比较合理的,但是这种方案也只是能满足透明到不透明的效果,想要实现渐变效果还是会引发 BUG。
如果想要最简单的实现方式,还是应该选择方案二。这种方案也是在自己的很多项目中实际使用的方案,因为事实上很多项目都不需要 NavigationBar 的毛玻璃效果。
如果不嫌手动添加返回手势麻烦,或者干脆不需要滑动返回手势,比如作为 tabBar 一级页面的个人中心等,使用方案一才是最简单的选择。
文中使用 baseViewController 和 baseNavigationController 实现的思路都可以通过 Category + Method Swizzling 实现。这种侵入式的方案可以让你免去继承的麻烦,都是可以考虑的。比如这位韩国开发者就是这样实现的。
博客:xuyafei.cn
简书:jianshu.com/users/2555924d8c6e
微博:weibo.com/xuyafei86
Github:github.com/xiaofei86