乐趣区

让UINavigationController更好用

去年看到过美团点评技术团队的一篇文章 iOS 系统中导航栏的转场解决方案与最佳实践,文章对系统导航栏的改造很有意思,最近就试着写点代码练练手。
项目地址:DoubleNavigationController
这个库还没有在实际项目中检验过,还有很多不完善或者不能满足业务需求的地方,欢迎提 issue 或者 PR。
些许疑问
为什么要开发这个库?
UINavigationController 在苹果官方文档上的是这样介绍的:
A navigation controller is a container view controller that manages one or more child view controllers in a navigation interface.

也就是说 UINavigationController 是作为 UIViewController 的管理者,因此它的 NavigationBar 不应该从属于任何一个 ViewController。但是大部分 UI 设计者都没有明白苹果设计的用意,因此在业务中经常出现一个场景:在逻辑上应该从属于同一个 NavigationController 的多个 ViewController 却拥有不同的 NavigationBar 样式。
不同页面的开发者只能看到对自己开发的便利性,对 UINavigationController 的理解不到位,到处修改 NavigationBar 样式,在页面专场过程中 NavigationBar 出现了各种不可控的问题。这个问题在 App 支持路由后会变得更为突出,原因是各个页面的跳转关系将会非常复杂且不可预知。
DoubleNavigationController 解决的便是这个问题,让开发者自由地修改 NavigationBar 样式,并且不用担心在退回到栈下 ViewController 后 NavigationBar 的样式也被修改。简单来说就是,我们修改 NavigationBar 不再会影响栈内现有的页面样式,而只影响之后 Push 的新页面。

为什么不设计成既不影响栈内现有的页面样式,也不影响新页面?
在这里先讲一下 DoubleNavigationController 的两个设计思想“先到先得”、“谁用谁修改”。
“先到先得”
先出现的页面样式不应该受到后出现的页面影响。用户在使用过程中先看到了页面 A 的样式,接着从 A 页面跳转到 B 页面,B 页面的导航栏样式与 A 页面不同,这时用户再返回 A 页面,从正常逻辑上来说,用户希望看到的 A 页面导航栏应该还是之前见过的样式,不应该受到 B 的影响而改变。

“谁用谁修改”
继续上面一个场景,用户从页面 A 到页面 B,再从页面 B 跳转到页面 C,在上一个场景下我们知道,页面 B 修改了导航栏样式,使其与页面 A 不同,当我们跳转到页面 C 时,此时存在如下两种可能:

页面 C 也对刚才 B 页面修改过的导航栏样式属性进行了修改。
页面 C 没有对刚才 B 页面修改过的导航栏样式属性进行修改。

在第 1 种情况下我们很容易确定,C 页面的导航栏样式就应该是 C 页面自己修改的样式。那么在第 2 种情况下,C 页面导航栏应该长什么样?

再考虑以下 3 种方案:跟 A 页面一样?跟 UIAppearance 配置一样?跟 B 页面一样?
C 页面导航栏跟 A 页面一样?
这个方案在逻辑上就是错误的,因为 C 页面根本不应该关心它的上上一个页面样式。如下图,假设 B 页面有两条跳转路径 A1 和 A2,此时 C 页面的样式就有 2 种可能,相信绝大多数 App 的设计都不会出现“1 个页面,2 种 UI”的情况吧。

C 页面导航栏跟 UIAppearance 配置一样?
让 C 页面保持和 UIAppearance 配置一致,这里也存在两个问题,一个问题是如果用户没有配置 UIAppearance 怎么办?
还有一个更大的问题是,这么做似乎破坏了苹果对于 UINavigationController 的定义,这就使得导航栏在逻辑上成为了单个页面所独立持有的个体,在这种情况下倒不如隐藏系统 NavigationBar,每个页面是实现一个自己的导航栏来个更方便维护。
C 页面导航栏跟 B 页面一样?
保持和 B 页面一样,粗略一想,这和“跟 A 页面一样?”方案似乎是差不多的,但实际上这两种方案有着本质区别。“跟 B 页面一样”换一个更好的说法应该是“跟最近一次用户对导航栏修改之后的样式一样”,也就是说 C 页面只需要关注导航栏本身,而不需要关注谁修改了导航栏,这样一来就满足来上述的设计思想“谁用谁修改”。
实现
关于这个库的实现,笔者在这里参考了美团点评的这篇文章 iOS 系统中导航栏的转场解决方案与最佳实践。
在转场的过程中隐藏原有的导航栏并添加假的 NavigationBar,当转场结束后删除假的 NavigationBar 并恢复原有的导航栏,这一过程可以通过 Swizzle 的方式完成,而每个 ViewController 只需要关心自身的样式即可。
DoubleNavigationController 核心的解决方案与这篇文章提到的是一样的,但是在实现方式和细节上可能与文章中提到的并不一样,另外有一些实现细节在美团点评的这篇文章中并没有过多地透露。
几个细节
细节 1:DoubleNavigationController 中选择直接 NSKeyedArchiver 来复制一个 FakeNavigationBar 而并没有自定义 UIView。
细节 2:有些时候一个页面的 NavigationBar 可能会在用户交互过程中动态变化,因此我们需要记录每一次用户对 NavigationBar 外观的修改,并在适当的时候对 FakeNavigationBar 外观也进行更新。
细节 3:由于 UIAppearance 的原理是在 UIView 被添加到视图树后才会去改变对象的外观,因此在使用 FakeNavigationBar 之前需要再一次和当前的 navigationBar 进行一次 UIAppearance 属性的复制。参考:iOS UIAppearance 探秘 — HyanCat’s

例子
clone 这个仓库,进到 Example 目录下执行 pod install 来运行一个 demo。

用法
通过在 ViewController 中实现 dbn_configNavigationController 这个方法来定制导航栏样式。
– (void)dbn_configNavigationController:(UINavigationController *)navigationController {
[navigationController setNavigationBarHidden:NO animated:NO];
navigationController.navigationBar.barTintColor = [UIColor whiteColor];
navigationController.navigationBar.tintColor = [UIColor purpleColor];
navigationController.navigationBar.titleTextAttributes = @{NSFontAttributeName: [UIFont systemFontOfSize:20], NSForegroundColorAttributeName: [UIColor redColor]};
}

– (void)dbn_configNavigationItem:(UINavigationItem *)navigationItem {
UIBarButtonItem *btnItem = [[UIBarButtonItem alloc] initWithTitle:@”Next” style:UIBarButtonItemStylePlain target:self action:@selector(eventFromButton:)];
navigationItem.rightBarButtonItem = btnItem;
navigationItem.title = @”Hello”;
}
你还可以使用 dbn_performBatchUpdates: 这个方法来随时更新导航栏样式。
[self dbn_performBatchUpdates:^(UINavigationController * _Nullable navigationController) {
if (navigationController) {
navigationController.navigationBar.tintColor = [UIColor purpleColor];
}
}];
参考
iOS 系统中导航栏的转场解决方案与最佳实践
UIKit UIAppearance – APPLE
iOS UIAppearance 探秘 — HyanCat’s

退出移动版