使用系统控件的困境
如果去学习Android、Flutter或者前端的代码,就会发现其他App的导航栏都是跟着独立页面走。
但是iOS却不同,你会发现NavigationController更像一个全局的单例,每个页面的NavigationControlle都是一个样的。
而往往业务侧对导航栏的需求又是多样的,渐变、一屏到顶、Web页面全屏接管等等,都会让你在导航栏的配置上焦头烂额,效果达不到也就算了,甚至会引出bug。
而通过隐藏系统导航栏,全部自己写自定义导航栏的时候,NavigationController又会阴魂不散,时不时给你一点意外惊喜。
我这里没有特别多的好策略,就是给出一个我喜欢用的库------RTRootNavigationController。
这里,我先给出完全使用系统UINavigationController的方案,这个方案在RxStudy上面已经实践过,其中push时的假死bug也是网友斧正帮我解决的。这个项目中,完全使用系统导航栏,只用自定义leftBarButtonItem就可以了。
但是需要注意的是,这个思路只适合架构简单,没有太多自定义需求的项目。
dart
class BaseViewController: UIViewController {
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
/// 最简单的设置统一返回按钮的方法,所有的控制器继承该基类即可
let leftBarButtonItem = UIBarButtonItem(image: R.image.back(), style: .plain, target: self, action: #selector(leftBarButtonItemAction(_:)))
navigationItem.leftBarButtonItem = (navigationController?.viewControllers.count ?? 0) > 1 ? leftBarButtonItem : nil
navigationItem.hidesBackButton = true
}
/// 将此方法从private改成对外暴露,让子类能有能力重新这个返回的方法,一般情况这个返回的方法会与侧滑返回的逻辑绑定,比如MyJueJinController就是例子
/// - Parameter item: UIBarButtonItem
@objc
func leftBarButtonItemAction(_ item: UIBarButtonItem) {
navigationController?.popViewController(animated: true)
}
}
dart
class BaseNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
delegate = self
}
}
extension BaseNavigationController: UIGestureRecognizerDelegate, UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
interactivePopGestureRecognizer?.isEnabled = true
/// 解决某些情况下push时的假死bug,防止把根控制器pop掉
if navigationController.viewControllers.count == 1 {
interactivePopGestureRecognizer?.isEnabled = false
}
}
}
谁包裹谁?
再来就是有关于RTNavigationController,其中的包裹方案有以下几种:
- 方案一:使用 RTRootNavigationController 包裹整个 UITabBarController,适用于需要全局统一管理导航控制器的情况。这种方案的优点是结构简单,适合需要全局统一管理导航控制器的情况。缺点是每个选项卡中的视图控制器共享同一个导航控制器,可能会导致导航堆栈管理复杂。

- 方案二:使用 RTRootNavigationController 包裹每个 UIViewController,然后用 UITabBarController 包裹这些 RTRootNavigationController,适用于需要独立管理每个选项卡中的导航堆栈的情况。这种方案的优点是每个选项卡中的视图控制器都有独立的导航控制器,导航堆栈管理更清晰。缺点是结构稍微复杂一些。

其实这种结构不仅适用于RTRootNavigationController,其实对于原生UINavigationController也一样适用,我一般使用方案二。
我个人的理解是,每一个Tab包含的都是一种相关的业务,独立为每一种tab业务用一个Navigation进行管理比较合适,这样就算是路由业务,也能更好的解耦。
RTRootNavigationController的基本使用
页面包裹相关的思路其实和使用UINavigationController一致,这里就不再展开,说一些需要注意的
- 页面隐藏导航栏
dart
class ExampleController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
/// 隐藏导航栏 返回手势可用
navigationController?.isNavigationBarHidden = true
/// 注意使用RT,一定要把这个API设置,并设置为false,否则页面无法侧滑
rt_disableInteractivePop = false
}
}
RT默认当页面导航栏隐藏时,系统的侧滑失效,于是这里rt_disableInteractivePop必须手动设置一下。
RT相关逻辑代码如下:
dart
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
UIViewController *viewController = self.topViewController;
if (!viewController.rt_hasSetInteractivePop) {
BOOL hasSetLeftItem = viewController.navigationItem.leftBarButtonItem != nil;
if (self.navigationBarHidden) {
viewController.rt_disableInteractivePop = YES;
} else if (hasSetLeftItem) {
viewController.rt_disableInteractivePop = YES;
} else {
viewController.rt_disableInteractivePop = NO;
}
}
if ([self.parentViewController isKindOfClass:[RTContainerController class]] &&
[self.parentViewController.parentViewController isKindOfClass:[RTRootNavigationController class]]) {
[self.rt_navigationController _installsLeftBarButtonItemIfNeededForViewController:viewController];
}
}
- (void)setNavigationBarHidden:(BOOL)hidden animated:(BOOL)animated
{
[super setNavigationBarHidden:hidden animated:animated];
if (!self.visibleViewController.rt_hasSetInteractivePop) {
self.visibleViewController.rt_disableInteractivePop = hidden;
}
}
- 页面查找:
我们看一下一个被RTNavigationController包裹的页面层级:


swift
/// 切换到发现页面的社区页面
if let rt = Tools.currentVC()?.tabBarController?.viewControllers?.first as? RTRootNavigationController,
let containerController = rt.topViewController as? RTContainerController,
let viewController = containerController.contentViewController as? UIViewController {
}
我们需要拿到RTContainerController,接着拿到RTContainerController里面的contentViewController属性获取真正我们构建的控制器,而使用UINavigationController,在topViewController我们就可以直接转我们构建的控制器了。
push与pop有完成回调:
objectivec
- (void)pushViewController:(UIViewController *)viewController
animated:(BOOL)animated
complete:(void(^)(BOOL finished))block;
- (UIViewController *)popViewControllerAnimated:(BOOL)animated complete:(void(^)(BOOL finished))block;
但使用rt_navigationController时,可以使用这个改造后的push与pop,它能更好的控制push与pop之后的控制。
删除栈
通过remove可以删除指定的栈内的控制器,特别是当push到某一个页面之后,需要对栈做优化,值得注意的是,使用RT的API会比系统的舒适一点,比如下面这个例子就是push完成后,将当前的页面给删除掉:
swift
self.rt_navigationController?.pushViewController(ChangePhoneController(type: .bindNewPhone), animated: true, complete: { _ in
self.rt_navigationController.removeViewController(self)
})
当然这里使用通过对象删除栈内的方式,并不通用,因为有的时候我们无法拿到栈内的对象,于是通过控制器名称等标签的查找与删除,更符合使用,下面是我封装的一个方法:
swift
// MARK: - 这两个方法是基于RTRootNavigationController的封装
extension BaseViewController {
/// push到目标控制器,并通过类名进行定向移除导航控制器中的栈内控制器
/// - Parameters:
/// - viewController: 目标控制器
/// - animated: 是否有动画效果
/// - removeViewControllerClassNameList: 需要移除控制器名称的数组
/// - isRemoveSelf: 是否移除触发push方法的当前控制器
func pushViewController(_ viewController: UIViewController, animated: Bool, removeViewControllerClassNameList: [String] = [], isRemoveSelf: Bool = true) {
rt_navigationController?.pushViewController(viewController, animated: animated) { [weak self] _ in
guard let self else { return }
if let viewControllers = self.navigationController?.viewControllers {
for vc in viewControllers where removeViewControllerClassNameList.contains(vc.className) {
self.rt_navigationController?.removeViewController(vc)
}
}
if isRemoveSelf {
self.rt_navigationController?.removeViewController(self)
}
}
}
/// 用于通过类名进行定向pop
/// - Parameters:
/// - className: pop回退到的控制器名称
/// - animated: 是否有动画效果
/// - completion: pop完成后的回调
func popToViewController(className: String, animated: Bool, completion: ((Bool) -> Void)? = nil) {
var isPoped = false
for vc in self.navigationController?.viewControllers ?? [] where vc.className == className {
rt_navigationController.pop(to: vc, animated: animated, complete: completion)
isPoped = true
break
}
if !isPoped {
navigationController?.popViewController(animated: true)
}
}
}
这里的className可以认为是NSStringForClass的一种封装,简单而言就是通过类名去识别栈内是否有符合控制器实例,当然这个方法还是有一个缺陷,就是如果栈内有2个或者2个以上同名的控制器实例如何解决?
方法还是有的,只是没有封装到这个方法中,我们在构建控制器的时候,可以给控制器打tag,通过区分tag就可以更好的进行颗粒度细致的控制,这里就讲到这里,扩展我想大家都会了。
禁止侧滑,点击返回按钮的逻辑需要自定义:
如果看RT的官方Demo,在页面中自定义返回按钮的代码,就是下面这种:
objectivec
- (UIBarButtonItem *)rt_customBackItemWithTarget:(id)target action:(SEL)action
{
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
[button setImage:[UIImage imageNamed:@"back"] forState:UIControlStateNormal];
[button sizeToFit];
[button addTarget:target
action:action
forControlEvents:UIControlEventTouchUpInside];
return [[UIBarButtonItem alloc] initWithCustomView:button];
}
这里的action直接调用了super的action,如果在当前页面传一个自己写的方法,那么一定会抛出崩溃出来,堆栈信息如下:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[DCZLApp.BaseNavigationController backAction]: unrecognized selector sent to instance 0x109490000'
你会发现,没有找到这个方法,为什么?我们可以跟踪到rt_customBackItemWithTarget在RT框架中的实现:
objectivec
- (void)_installsLeftBarButtonItemIfNeededForViewController:(UIViewController *)viewController
{
BOOL isRootVC = viewController == RTSafeUnwrapViewController(self.viewControllers.firstObject);
BOOL hasSetLeftItem = viewController.navigationItem.leftBarButtonItem != nil;
if (!isRootVC && !self.useSystemBackBarButtonItem && !hasSetLeftItem) {
if ([viewController respondsToSelector:@selector(rt_customBackItemWithTarget:action:)]) {
viewController.navigationItem.leftBarButtonItem = [viewController rt_customBackItemWithTarget:self
action:@selector(onBack:)];
}
else if ([viewController respondsToSelector:@selector(customBackItemWithTarget:action:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
viewController.navigationItem.leftBarButtonItem = [viewController customBackItemWithTarget:self
action:@selector(onBack:)];
#pragma clang diagnostic pop
}
else {
viewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Back", nil)
style:UIBarButtonItemStylePlain
target:self
action:@selector(onBack:)];
}
}
}
它最后回去调用RTRootNavigationController中的onBack方法,如果我们想要在控制器中重写action,那么可以有两个实现思路:
- 在RTRootNavigationController中新增分类,添加自定义方法,并在当前页面进行调用实现
- 重写RTRootNavigationController中的onBack方法
我们先看第一个思路:
swift
extension BaseViewController {
override func rt_customBackItem(withTarget target: Any!, action: Selector!) -> UIBarButtonItem! {
let button = ExtendTouchButton()
button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
button.addTarget(target, action: #selector(self.rt_navigationController.backAction), for: .touchUpInside)
button.contentHorizontalAlignment = .left
button.setImage(UIImage(named: R.image.dczl_back_black_icon.name), for: .normal)
return UIBarButtonItem(customView: button)
}
}
extension RTRootNavigationController {
@objc func backAction() {
print("我是新写的RTRootNavigationController里面的backAction")
popToRootViewController(animated: true)
}
}
执行如下:

这种思路是可行的,也就是说针对不同的页面与页面逻辑,我们只需要写实现一个RTRootNavigationController分类来处理即可。
第二种方案:
swift
import UIKit
import RTRootNavigationController
class BaseNavigationController: RTRootNavigationController {
var onBackCallback: (() -> Void)?
@objc
override func onBack(_ sender: Any) {
if 伪代码,有什么条件 {
onBackCallback?()
} else {
super.onBack(sender)
}
}
}
/// 看了OC RTRootNavigationController的源码,onBack其实在.m文件中,没有声明在.h中
/// 这里添加分类的同时,其实相当于将RTRootNavigationController的onBack方法给重写了
/// 如果在OC中写,其实就是为RTRootNavigationController创建分类,并在分类的.h将此方法
extension RTRootNavigationController {
@objc
func onBack(_ sender: Any) {
popViewController(animated: true)
}
}
我们的重点是onBack方法的重写,OC中RTRootNavigationController的源码,onBack其实在.m文件中,没有声明在.h中,这里添加分类的同时,其实相当于将RTRootNavigationController的onBack方法暴露出来了,如果在OC中写,其实就是为RTRootNavigationController创建分类,并在分类的.h将此方法。
接着我们通过继承RTRootNavigationController的方式,可以对onBack方法进行重写,通过条件拦截,比如BaseNavigationController里面的当前控制器是哪一个控制,我们就怎么怎么样,亦或者自己定义一个onBackCallback属性去做属性回调即可:


注意的是onBackCallback是全局存在与RTRootNavigationController中的,不同的业务页面可能会对onBackCallback做不同的实现,可能导致的问题就是onBackCallback不断被复写而达不到预期效果,可以考虑onBackCallback实现完成后置为nil,后续有使用再实现。
方案一,直接在是RTRootNavigationController的分类中进行新增方法从而改变action方法,适合于返回事件与当前页面没什么耦合的情况,在新的方法中处理逻辑即可;
方案二,因为可以将回调实现写在当前页面中,所以对于那种业务逻辑比较复杂的情况,使用方案二会比较好一点,当然使用方法二的成本也会高一点,需要对RT框架做对外暴露改造,继承RTRootNavigationController,以及对应的逻辑修改。
当然也有更简单的方法,就是直接更改RT代码,我这里写了一个分类,以减少对代码的入侵:
objectivec
#import <RTRootNavigationController/RTRootNavigationController.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^OnBackCallback)(void);
@interface RTRootNavigationController (OnBack)
@property (nonatomic, copy) OnBackCallback onBackCallback;
- (void)onBack:(id)sender;
@end
NS_ASSUME_NONNULL_END
objectivec
#import <objc/runtime.h>
#import "RTRootNavigationController+OnBack.h"
@implementation RTRootNavigationController (OnBack)
// 关联对象的key
static void *OnBackCallbackKey = &OnBackCallbackKey;
// 闭包属性的getter方法
- (OnBackCallback)onBackCallback {
return objc_getAssociatedObject(self, OnBackCallbackKey);
}
// 闭包属性的setter方法
- (void)setOnBackCallback:(OnBackCallback)onBackCallback {
objc_setAssociatedObject(self, OnBackCallbackKey, onBackCallback, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (void)onBack:(id)sender
{
if (self.onBackCallback) {
self.onBackCallback();
} else {
[self popViewControllerAnimated:YES];
}
}
@end
以前在使用RT框架的过程中,遇到这种禁掉侧滑,返回按钮需要自定义的时候,只能直接将系统导航栏都隐藏了,去写一个高保真的系统导航来完成功能,通过以上思路,可以让代码与逻辑更加简单。
包含WebView页面的返回上一页,侧滑返回上一页,返回pop与侧滑pop:
其实可以认为第4点其实就是对于第3点扩充与应用。
在因为iOS里面WebView点击跳转,我们可以认为是在同一个WebView不停的做Web内部的路由,而这种路由动画就"好像"push,于是什么时候是返回Web的上一个路由还是返回上一个控制器,就成为了判断的关键。可能文字说不明白,直接上个动图:

因为自己的开发过程中使用了Rx,所以代码逻辑如下:
objectivec
(rt_navigationController as? BaseNavigationController)?.onBackCallback = { [weak self] in
if self?.webView.canGoBack == true {
self?.webView.goBack()
} else {
self?.navigationController?.popViewController(animated: true)
}
}
/// iOS 如何让WKWebView侧滑返回时html逐级返回,而不是直接返回到上级控制器?
/// https://www.imooc.com/article/26158
webView.rx.observeWeakly(Bool.self, "canGoBack")
.subscribe(onNext: { [weak self] newValue in
print("新的值: \(newValue)")
if let canGoBack = newValue {
self?.rt_disableInteractivePop = canGoBack
}
})
.disposed(by: rx.disposeBag)
如果不使用RxCocoa与RTNavigationController,可以参考下面这段代码:
swift
private let canGoBackKeyPath = "canGoBack"
webView.addObserver(self, forKeyPath: canGoBackKeyPath, options: .new, context: nil)
open override func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?) {
guard let theKeyPath = keyPath, object as? WKWebView == webView else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
if theKeyPath == canGoBackKeyPath{
if let newValue = change?[NSKeyValueChangeKey.newKey]{
let newV = newValue as! Bool
if newV == true {
self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false;
}else{
self.navigationController?.interactivePopGestureRecognizer?.isEnabled = true;
}
}
}
}
deinit {
webView.removeObserver(self, forKeyPath: canGoBackKeyPath, context: nil)
}
整体的逻辑就是通过监听WebView的canGoBack属性,将其值与侧滑使能绑定,自定义重写的返回按钮事件,也与canGoBack做逻辑判断,canGoBack为true,就做WebView的返回上一页,canGoBack为false,就返回上一页。
最近因为看TheRoute的源码,里面没有push跳转的过程中删除栈的功能,于是我想到了RTRootNavigationController,这里正好做了总结,也算不错。
总结
最近因为看TheRoute的源码,里面没有push跳转的过程中删除栈的功能,于是我想到了RTRootNavigationController,它是我个人用的比较多的一个库,在它原有的基础功能上,我结合自己使用过程中的业务场景与问题做了总结,希望可以帮助到大家。