截获导航控制器系统返回按钮的点击pop及右滑pop事件

时间:2022-04-01 14:48:30

前几天看了@栾小布的一篇文章:Custom backBarButtonItem,在跟着做的时候我又顺便扩展了一些,写此文章的目的是为了总结一下自己所写的东西,方便以后翻看容易,同时也是自己入行iOS一年时间,希望写点东西练练手,还有希望可以分享给大家,希望大家一同讨论,提出宝贵意见以及更简单的实现。总体效果如下:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

同时受@J_雨的轻松学习之——IOS利用Runtime自定义控制器POP手势动画一文影响,所以我们将用两种方法实现。

实现思路

每一个navigationController都自带有一个navigationBar,一个navigationController管理一个viewController的栈,即可以管理好过个viewController,而每个viewController对应一个navigationItem,这些navigationItem通过navigationBar呈现出来。

如果我们定制了某一个viewController的navigationItem的leftBarButtonItem,我们在实例化leftBarButtonItem的时候可以传入target及action,这样我们就可以获取到点击的事件,然而,如果我们没有定制,而是系统的backBarButtonItem,这时候,我们没有机会传入SEL类型的action,那么我们没办法直接实现这个action。

接下来我们点到UIBarButtonItem这个类里面去,看看能不能发现一些蛛丝马迹,首先发现了这两货:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

这意味着只要我们拿到一个UIBarButtonItem对象,我们就可以通过kvc拿到这个SEL,也可以通过kvc去赋值(其实,UIBarButtonItem这个类也提供了这两个的属性。如图三)。所以我们赶紧去UINavigationItem类里去看看,发现了如图四所示的内容。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

截获导航控制器系统返回按钮的点击pop及右滑pop事件

惊喜来的太突然了,当时当我们用的时候,才发现这个backBarButtonItem永远为nil(反正我是找了半天,发现这个item都是nil,如果你找到了,请告诉我),比如下图(我在viewDidAppear中断点调试,结果为nil):

截获导航控制器系统返回按钮的点击pop及右滑pop事件

以前为我探索的方法,通过栾小布的那篇文章,我们知道每个UINavigationBar有四个代理方法,其中有两个是需要返回BOOL值的,这两个方法决定了要不要push,pop某个navigationItem(一般来说,一个navigationItem对应一个viewController)。系统默认会把navigationBar的这个委托设置为拥有它的navigationController(说到这里,我突然想到另外一个idea,不知道是否可行,为了大家能读完这篇文章,我会把我刚刚想到的idea放到文章末尾,希望到时候大家一起验证)。也就是说navigationController是实现了这几个方法或者其中几个(因为这几个代理方法都是@optional 可选方法,所以可能没有全部实现,具体没探索过,但是navigationBar:shouldPopItem:肯定是实现了的,不信?往后看),现在我们要做的就是怎么样重新把系统的默认实现替换为我们的实现,注意,navigationBar:shouldPopItem:的完成签名为:

- (BOOL)navigationBar:(UINavigationBar*)navigationBar shouldPopItem:(UINavigationItem*)item;

返回一个BOOL值,所以我们要替代系统的默认实现,也应该是返回一个BOOL值,并且为了更完美,我们不应该完全覆盖掉系统的实现,而是只是在我们需要返回NO的地方,我们返回NO,而其他的任何时候,我们应该返回系统的默认实现,这样才更完美。下面提供两种实现思路。

通过子类实现

第一种方法就是利用子类可以重载父类的方法,直接上代码,完了解释为什么。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

我们拉了一个.h文件,定义了一个协议。这个协议里有一个方法,这个方法返回一个BOOL值,如果你上面的思路读懂了,这就是一会我们要用来具体实现是否pop的方法。好了,这个协议先放这里,我们一会用到再说。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

这里我们新定义了一个UINavigationController的子类,叫YYNavigationController,并让其重载的父类的navigationBar:shouldPopItem:方法。

这样,我们就可以用这个子类去做拦截系统返回按钮的事件这件事的能力了,不着急,我们先来看看这个子类具体怎么使用,然后再细说实现过程。

我们在appDelegate里面这样写:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

nav的根视图控制器是root,再push一个YYSecondViewController,我们给这个试图控制器对象叫second。如果这个second要想具有拦截系统返回按钮的事件的能力,简单,只要做两事:

1、遵守我们一开始定义的那个协议,像这样

截获导航控制器系统返回按钮的点击pop及右滑pop事件

2、实现我们那个协议里的代理方法,像这样

截获导航控制器系统返回按钮的点击pop及右滑pop事件

这里的实现是,我们弹出警告框,然后返回NO,这样我们点击系统返回按钮之后,就不会直接做pop。

好,接下来,我们把上面的过程梳理一遍。我们点击系统的返回按钮,这是,程序会走到图7第35行的方法里。沿着这个方法往下走,我们先取出了导航控制器中栈定的vc,(第40~42行,先跳过,稍后再说其作用)接着我们查看其是否实现了我们定义的那个协议,如果其没实现协议(说明那个vc根本没有要做拦截系统返回按钮的事件的打算),所致直接跳到52行,返回系统默认的实现,也就是UINavigationController类(父类)里面的实现,调super的同名方法。如果实现了这个协议,则说明vc有这个想法,所以就调用协议里的方法(第45行),如果人家返回了NO,我们就给navigationBar传达这个意思(第49行),说vc暂时不想pop,这是navigationBar就不会pop。如果vc返回的时YES,我们就返回系统默认的实现(第46行)。

大家想想这个过程,本来navigationBar不知道是否pop,于是问navigationController,navigationController有自己默认的实现,与外界没有关系。而现在我们定义了个子类,继承navigationController,这样,navigationBar不知道是否pop,就来问这个子类,而这个子类的做法是先看看他栈顶的这个vc是否有改变系统默认实现的这个打算(遵守协议),如果没有,返回navigationController(父类)的默认实现,如果有,我们就具体问问它做的决定,如果这个vc返回YES(说明其对pop是支持的,所以我们返回默认实现)。如果不支持,我们就以vc为主,直接返回NO。

很简单,有没有?还是有一个问题,UINavigationController的接口中并没有放出navigationBar:shouldPopItem:这个方法的接口(其实这只是navigationController要实现的一个协议,是不会放到接口中的),所以我们在子类里是无法直接 调用super 的这个方法的,编译会报错。为了解决这个问题,我们用到了一些小技巧(这个不知道叫其为小技巧合不合适?),就是图7中第12~22行干的事。

我们为UINavigationController添加的了一个类别。为UINavigationController添加了这个方法,但是并没有在类别里实现,这样就把UINavigationController的本来已经实现了的(算是)私有方法暴漏了出来。如果在这个类别里实现了这个方法,则调用的时候到底调用哪个实现是不确定的。我们慢慢来分析一下,我们把navigationBar:shouldPopItem:方法叫a方法,将UINavigationController叫A类。本来A类里有了一个a方法的默认实现。只不过这个a方法没有在A类的接口中暴露出来。而现在给A类增加了一个类别,类别接口里添加了a方法而没有实现,我们知道,OC中调用方法是通过方法名比较(粗浅层面)来调用的,所以这是我们就相当于把A类里的私有方法,在类别的接口里暴漏出来了,即外部可以通过这个分类的接口来调用这个私有方法了。我们再来看,如果我们在分类里实现了这个方法,则分类里的实现最终会加入到A类的方法列表里。所以到时候再调a方法的时候,A类的方法列表里有两份实现,具体调哪个就不确定了。所以我们只在接口中申明,但是不实现。

但是实现为空的话,编译器会发出(可恶的)警告,这是我们可以用三句宏来告诉编译器某个地方请不要发出那种类型的警告。这三句宏第一句叫push,第三局叫pop。他们中间的代码就不会发出警告。而警告分好多种,我们需要告诉编译器是哪种类型的警告。那么这个警告的类型我们怎么知道的呢,看下图:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

这样就可以去掉那些(烦人的)警告了。ps:(个人感觉,除非确定这样做是不会出错的,否则不要直接看见警告就这样做)。

这个说完了,在说一个小地方,我们在second(还记得second是什么么?回看图10)里返回NO之前,我们弹出了警告框,我们看看警告框的代理方法。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

第77行是点击了留在此页按钮 而87行是点击了放弃编辑的按钮。为什么不放在一个里面实现呢?这个全看个人爱好,我这里说说我的想法,74行的方法是alertView只要一有btn点击就回调,而78~79行的具体做法就是为了让那个变灰的“<”指示按钮恢复颜色,所以越快越好。而84行的方法是等alertView消失完才调用,因为89行的操作是pop,返回上一页,所以我不希望它时间那么赶,所以我们点击放弃编辑按钮会先看到alertView消失,接下来是pop动画。这都是小问题,还有一个问题在于我们做第89行的pop操作的时候,你猜会怎么着?这可是pop操作哦,你想到了么?好吧,你想到了,程序还是会走到我们子类的navigationBar:shouldPopItem:方法中,即图7的35行。如果这时我们获取nav中栈顶的那个vc还是我们这个vc的话,那就循环了呀,pop不出去了。还好经过我验证,如果是直接写pop语句,让nav来pop的话,这时获取的nav栈顶的vc是…(还是举个例子吧,如果nav的vc栈中有两个vc,第一个是root,第二个是second。这是second页面如果点击系统的返回按钮,这是在图7的35行的代理方法中的获取的栈顶vc是second,而如果是直接代码写的pop操作,则获取的栈顶vc是root。也就是说只要代码写了pop操作,则系统会直接将顶层vc也就是second出栈,然后才回调的,所以这是我们获取到的顶层vc就是root了。然而不管哪种方式,参数中的item都是second的item。你测试了么?没有的话,赶紧动手把,印象更深刻,面试有奇效哦~)。

苹果为什么这么实现不得而知,但是这刚好可以让我们分别出这次pop操作是点击系统backBarButtonItem还是代码写了pop操作指令(我猜苹果就是为了让我们来实现这个而故意留的)。所以图7的40~42行就是为了做这件事。如果这个参数中的navigationItem跟顶层vc的navigationItem不是一个的话,说名是手写的pop代码,我们直接让其返回系统默认实现(也就是父类的实现)。ps:我刚才测试了一下如果第41行返回NO的情况,会出现神奇效果,紧接着的操作会奔溃,所以以为这这里不会出现返回NO的情况,我所我们可以让41行直接返回YES,这样会降低一点点点点点点的开销(我就是一这么一个极致的人,哎~)。

好了,第一种实现过的这几个细节理解了,第二种实现也类似,但是还有更精彩的地方,马上开始

Method Swizzling 实现

如果你对Method Swizzling这个词很陌生,你可以先看看这篇博客。恭喜你回到现实世界,如果你看了那篇博客感觉好多概念都不知道如何理解的话,不要紧,看我接下来的讲解,我会让你知道你该知道的。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

这是刚开那段代码的复制品,我们需要注意代码中的以下几点。

1、我们给UINavigationController添加了一个叫YYShouldPopExtention分类。2、我们引入了runtime头文件(第10行)。3、注意第21行和22行,整个这一段代码就是把这两个SEL对应的实现给替换掉了。也就是说在遇到这一段代码的时候

截获导航控制器系统返回按钮的点击pop及右滑pop事件

我们在UINavigationController的YYShouldPopExtention分类里实现了yy_viewDidLoad方法,而UINavigationController的本类里有viewDidLoad方法。因为有图13那样的代码。我们就把这两个方法的实现给替换掉了。什么意思呢,当系统正常走到UINavigationController的viewDidLoad方法的时候,实际调用的是图14中72行开始的代码,而走第72行代码的时候,看似递归,但是其走的却是viewDidLoad本来的实现,所以是不会递归的。这一点请牢牢记住。

能这样做带来了极大的好处,首先我们的保证走72行的方法的时候,本来默认的实现能够被调用到,并且还会给我们空间,让我们插入我们自己的代码。比如75~77行。稍微举个例子把,如果我们把程序的运行看做是流过你家门前的一条小溪,本来你们家院子跟小溪进水不犯河水,谁都不理谁,突然有一天,你心血来潮,在小溪的渠道上开了一个口,并且把小溪水引导了你家院子里,然后转了一圈又把小溪水从那个口不远处流入原来的渠道。这样,在小溪看来,它似乎还挺正确的,因为原来它要走的渠道,不会因为在你家院子里转的一圈,就少了一圈还是怎么的,而是还是全部都走。而在以看来,你确实拥有了进入小溪渠道的入口,举个不可挡的例子,如果你在小溪里放点毒药,小溪下游的动物喝了小溪水会死。程序也一样了,你这么一弄之后,程序原来要走的方法照样都会走到,看起来不会出错,但是肯那个会因为你这里填的几句代码,影响到程序以后的运行结果。

ok,这个完成,你猜我下来会说什么?你猜对了,既然可以这么做,我们当然可以用这样的方法把UINavigationController的navigationBar:shouldPopItem:的实现替换为我们自己的实现不就可以了么。

我们可以先这样:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

然后这样:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

还有一个,这里出现了一个新的协议,我是这样定义的:

截获导航控制器系统返回按钮的点击pop及右滑pop事件

相信你们可以看懂图16,通过图17也能理解这个协议怎么用。还是提醒你们注意,图16当中跟98行那样的代码,不是递归,而是调用的时UINavigationController本来的实现。

ok,拦截系统返回按钮的问题解决,但是这样有一个问题,如果遇到是从屏幕左边边缘开始往右滑的手势的时候也会pop,这时候的pop怎么办呢?这个决定看你的app的需求,如果也需要弹出警告框询问是否pop而不是要让用户因为不小心滑了一下导致页面pop而丢失已填写的内容的话,下面请继续跟着我做。

首先需要知道UINavigationController 的右滑手势是通过它的一个属性来完成工作的@property(nonatomic,readonly)UIGestureRecognizer*interactivePopGestureRecognizerNS_AVAILABLE_IOS(7_0); 就是这个属性,它是一个UIGestureRecognizer的子类(更多详情,请看我在首页推的文章刚开始推的第二篇博客),当然,UIGestureRecognizer有一个delegate的对象,其中有一个重要的代理方法就是这个手势是否开始?如果我们截获掉这个这个方法的话,就可以控制这个手势是否开始了。

但是这个手势的delegate对象并不是我们当前的这个navigationController,而是一个类型为_UINavigationInteractiveTransition的私有类的对象。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

我们要截获这个方法,我们可以让interactivePopGestureRecognizer这个手势的delegate设置为当前的这个navigationController。如图18 第76行。但是我们的原则就是不能把系统原先的实现扔掉,我们在不想阻止这个手势的时候世界返回系统的实现,这样才更完美么。所以我们先把原先的那个delegate设置为关联对象保存起来。如第74行。注意最后一个参数,表示内存管理语义,用的是assign(为什么,这里我还真不清楚,只是直觉上的,因为原来在interactivePopGestureRecognizer中保存的时候就是assign,所以现在仍用assign,反正暂时验证可行)。既然在76行的时候,我们将interactivePopGestureRecognizer的delegate指向self(也就是当前的这个navigationController)了,那就得在self中实现代理方法吧,没啥好说的。

截获导航控制器系统返回按钮的点击pop及右滑pop事件

为什么我们偏偏实现了这三个代理呢?答,因为原来的delegate就只能响应这三个代理,不信的话亲可以通过respondsToSelector:亲自测试一下(多动手哦~),所以我们本着一个不多写,一个不少写的原则,将其实现。

我们着重看第一个方法,因为后两个都是返回了默认实现。当你看到第一个的时候,你应该会心的笑了,这个方法通过返回BOOL值来指示这个手势是否应该开始。所以我们先判断这个手势就是我们当前navigationController的interactivePopGestureRecognizer手势,其他手势另说。如果是这个手势,我们还是取到栈顶的vc,看这儿vc是够有意愿在这个手势发生时阻止pop,如果有,我们看看其决定如和,如果返回NO,说明阻止,如果这个vc返回yes,说明这一次它选择不阻止,则我们调用系统实现。

子类化同样也可以实现,这里就不多说了,大家可以自动动手试试,写不了几句代码。

最后这些说的有些糙,但是想必大家都能理解。如果有什么不理解或者有争议的地方,我们一起讨论。

比较一下这两种实现的特点:

(只是代表我的意见,欢迎大家补充)

1、子类化 这种方法好在我们只是对特定的有这种需求的navigationController用添加的方法调用步骤。但是对于已经搭好框架的项目,再去改架子不太友好。

2、这种方法通吃,只要工程里有这个分类,就会在每个nav上生效。在不需要这种需求的nav上也会生效。

所以具体用哪种,还在于你了。

一些自我感觉争议的地方:

首先就是图17中的那个协议,是定义为两个协议,每个协议各管一个事情好呢,还是就一个协议,这两件事本来就是同一个事情。我们前面说的是这个协议表示一个vc有没有要阻止pop的这个意愿,这样说的话,貌似还确实放在一个协议里就可以了。接下来我们再来看,既然说的时一个事,我们再想想把两个方法合并成一个行不行?其实也行,只要后面加参数就行。不知道大家更喜欢那种?我现在时这么觉得的,这个应该是跟着需求来的,你过你的需求里拦截返回按钮和拦截手势是一起的,时时刻刻在一起的,那么他们就是一件事情,可以写在一个协议里。如果需求里可能这个页面需要拦截手势,而不拦截按钮,另一个拦截按钮而不拦截手势,也可能还有两个都拦截的。那么这就是两件事情,需要写在两个协议里。

还有一个地方,我总觉得这个协议应该写成一个单独的文件。因为它不是委托协议,大家也想想这个问题怎么处理好一点。

说出我刚开始卖的那个关子

是不是有第三种实现方法,如果一个vc需要实现拦截系统返回按钮,我们可不可以在这个vc被push到nav的栈里的时候,把navigationBar的delegate设为这个vc,然后在其出栈的时候再设置给nav。这样的话遇到一个问题,如果本来navigationBar的delegate就不是nav,经过你这么一弄,最后给了nav了,这样的话是不对的。所以我们需要这个vc在被push之后保存原来的delegate,接着把navigationBar的delegate设置为vc自己,然后pop之后马上把delegate设置为原来的delegate;不知道实现起来有没有难度,大家感兴趣可以试一试。