has updated for iOS 13
项目中的页面(webview)分成两种,一种是比较简单的,对于这种页面,浏览完毕后点返回,就是真的返回,退到上一层;另一种是复杂的页面,在页面内部会有一些跳转的情况,但是用户点返回并不是真的想要返回上一层。也就是说,当用户从list页或者首页等进入到A,再从A进入到B(A和B实际上对于webview来说是一个页面)后,点返回,只是想返回A,而不是回到list页。如果点了一次返回就直接从B跳会到list页,用户的体验会很差。一开始web的同事都是设计成在网页(A和B所在的网页)中放置返回箭头,用户直接点网页中的返回进行控制。但是领导觉得这样很不好(领导做安卓的),至于这样有什么不好,我倒是没觉得。不得已只能想办法拦截导航栏按钮动作了。
当然这种拦截的使用方式不限于我上面描述的这个场景。
在此做个总结吧
一般情况下,如果需要对导航栏左侧的返回按钮进行特殊处理,都会选择在导航栏放置UIBarButtonItem,然后实现它的action。这种方法也被称为是自定义返回按钮。
我没使用这个方法,主要是因为加入这个功能的时候,项目已经很复杂了,如果抛弃了iOS原生的返回按钮,不论是显示的样式还是在pad这种大尺寸屏幕上出现的返回按钮title会自动变成上一层的title这个特点 都会有变化,所以不想重新自定义这个按钮,造成不必要的麻烦。
于是我选择了下面这种方式,闲话不多说了。由于最近又实现swift版本,这里放置两个版本的代码吧。
此方法参考:
1: [Custom backBarButtonItem]: http://www.jianshu.com/p/a502d363c998
2: [iOS拦截导航栏返回按钮事件的正确方式]: http://www.jianshu.com/p/25fd027916fa
3: https://stackoverflow.com/questions/1214965/setting-action-for-back-button-in-navigation-controller
OBJC 的实现方法:
UINavigationBarDelegate中定义了下面4个函数:
@protocol UINavigationBarDelegate <UIBarPositioningDelegate>
@optional
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPushItem:(UINavigationItem *)item; // called to push. return NO not to.
- (void)navigationBar:(UINavigationBar *)navigationBar didPushItem:(UINavigationItem *)item; // called at end of animation of push or immediately if not animated
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item; // same as push methods
- (void)navigationBar:(UINavigationBar *)navigationBar didPopItem:(UINavigationItem *)item;
@end
其中,navigationBar: shouldPopItem: 返回YES则pop到上一层,返回NO则不进行pop。就利用这一点,实现拦截返回按钮的功能。代码如下:
// 1, 实现自己的NavigationController
// 它是UINavigationController的子类,并且实现了UINavigationBarDelegate中的navigationBar:shouldPopItem:
// NavigationController.h
#import <UIKit/UIKit.h>
// 定义一个protocol,实现此协议的类提供它自己的返回规则或者进行相应的个性化处理
@protocol NavigationControllerDelegate <NSObject>
@optional
- (BOOL) shouldPopOnBackButtonPress;
@end
@interface NavigationController : UINavigationController
@end
// 2,NavigationController.m
// 遵循协议UINavigationBarDelegate
@interface NavigationController () <UINavigationBarDelegate>
@end
@implementation NavigationController
// pragma mark - UINavigationBarDelegate
- (BOOL) navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item{
BOOL shouldPop = YES;
// 已修改(标记1)
NSUInteger count = self.viewControllers.count;
NSUInteger itemsCount = navigationBar.items.count;
if(count < itemsCount){
return shouldPop;
}
// 通过点击返回键和直接调用popViewController,得到的topViewController不同
UIViewController *vc = self.topViewController;
if([vc respondsToSelector:@selector(shouldPopOnBackButtonPress)]){
shouldPop = [vc performSelector:@selector(shouldPopOnBackButtonPress)];
}
if(shouldPop == NO){
// 返回NO后,返回按钮中的 < 会置灰(文字恢复为黑色)通过设置NavigationBarHidden属性使它恢复
[self setNavigationBarHidden:YES];
[self setNavigationBarHidden:NO];
}else{
// 不能直接调用pop,如果是通过popViewController调起,会造成循环调用此方法
// 如果是通过调用[navigationController popViewControllerAnimated:]导致的shouldPop delegate被调用,
// 此时已经完成了viewController的pop, viewControllers.count 会比 navigationBar.items.count小1
// 这种情况就不必再次调用popViewController,否则会导致循环
if(count >= itemsCount){
dispatch_async(dispatch_get_main_queue(), ^{
[self popViewControllerAnimated: YES];
});
}
}
return shouldPop;
}
@end
如果希望在处理完自定义的pop逻辑后,通过调用父类的navigationBar: shouldPopItem: 方法进行pop,则使用下面的方式完成:
//NavigationController.m:
// 为UINavigationController写一个Category, 用于暴露父类的navigationBar: shouldPopItem:
@interface UINavigationController (UINavigationControllerNeedShouldPopItem)
- (BOOL) navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item;
@end
@implementation UINavigationController (UINavigationControllerNeedShouldPopItem)
@end
@interface NavigationController () <UINavigationBarDelegate>
@end
@implementation NavigationController
// UINavigationBarDelegate
- (BOOL) navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item{
NSUInteger count = self.viewControllers.count;
NSUInteger itemsCount = navigationBar.items.count;
if(count < itemsCount){
return YES;
}
UIViewController *vc = self.topViewController;
if([vc respondsToSelector:@selector(shouldPopOnBackButtonPress)]){
if([vc performSelector:@selector(shouldPopOnBackButtonPress)]){
// 此处调用父类的navigationBar: shouldPopItem:,但是父类并没有暴露此方法,在这里可以调用因为上面的Category - UINavigationControllerNeedShouldPopItem
return [super navigationBar:navigationBar shouldPopItem:item];
}else{
[self setNavigationBarHidden:YES];
[self setNavigationBarHidden:NO];
return NO;
}
}else{
return [super navigationBar:navigationBar shouldPopItem:item];
}
}
@end
// 3,在一个需要此拦截处理的view controller中,实现NavigationControllerDelegate,提供它自己的pop规则
// MyViewController.h
@interface MyViewController : UIViewController
@end
// MyViewController.m
#import "NavigationController.h"
@interface MyViewController () <NavigationControllerDelegate>
@end
@implementation MyViewController
// pragma mark - NavigationControllerDelegate
- (BOOL) shouldPopOnBackButtonPress {
// 实现自己的返回逻辑
if(self.customiseBack == YES){
// ... do something private
return NO;
}else{
return YES;
}
}
@end
SWIFT 的实现方法:
基于UINavigationBarDelegate中定义了下面4个协议:
public protocol UINavigationBarDelegate : UIBarPositioningDelegate {
@available(iOS 2.0, *)
optional public func navigationBar(_ navigationBar: UINavigationBar, shouldPush item: UINavigationItem) -> Bool // called to push. return NO not to.
@available(iOS 2.0, *)
optional public func navigationBar(_ navigationBar: UINavigationBar, didPush item: UINavigationItem) // called at end of animation of push or immediately if not animated
@available(iOS 2.0, *)
optional public func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool // same as push methods
@available(iOS 2.0, *)
optional public func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem)
}
通过完成protocol中的navigationBar(_:shouldPop:)方法完成拦截导航栏按钮的功能。
// 1,NavigationController.swift
import Foundation
// 定义一个protocol,实现它的类,自定义pop规则、逻辑或方法
protocol NavigationControllerBackButtonDelegate {
func shouldPopOnBackButtonPress() -> Bool
}
// 实现自己的NavigationController,它是UINavigationController的子类,并且遵循UINavigationBarDelegate
class NavigationController: UINavigationController, UINavigationBarDelegate {
// 实现navigationBar(_: shouldPop:)
func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool {
var shouldPop = true
// 已修改(标记1)
let viewControllersCount = self.viewControllers.count
let navigationItemsCount = navigationBar.items?.count
if(viewControllersCount < navigationItemsCount!){
return shouldPop
}
if let topViewController: UIViewController = self.topViewController {
if(topViewController is NavigationControllerBackButtonDelegate){
let delegate = topViewController as! NavigationControllerBackButtonDelegate
shouldPop = delegate.shouldPopOnBackButtonPress()
}
}
if(shouldPop == false){
isNavigationBarHidden = true
isNavigationBarHidden = false
}else{
if(viewControllerCount >= navigationItemCount!){
DispatchQueue.main.async { () -> Void in
self.popViewController(animated: true)
}
}
}
return shouldPop
}
}
// 2, MyViewController.swift中,完成自定义的pop规则。实现NavigationControllerBackButtonDelegate
class MyViewController: UIViewController, NavigationControllerBackButtonDelegate {
// MARK: NavigationController delegate
func shouldPopOnBackButtonPress() -> Bool {
var shouldPop = true
if(self.customiseBack == true){
// do something private
shouldPop = false
}
return shouldPop
}
}
注意:如果使用storyboard,需要将其中Navigation Controller对应的class修改为自定义的NavigationController,如果使用代码进行的UINavigationController的初始化,要将UINavigationController修改为NavigationController.
补充一下(标记1:代码已修改):
[Custom backBarButtonItem]: http://www.jianshu.com/p/a502d363c998
中提到一点,之前一直忽略的问题,就是在navigationBar:shouldPopItem中获取topViewController『不稳定』问题。这点没理解透,果然造成我一个大bug,耗费了不少精力。
对于这个问题,我是这样理解的:navigationBar:shouldPopItem 可以由两种方式触发:
1)在UI上点『返回』按钮;
2)在代码中调用 popViewControllerAnimated:
但是,通过这两种方法调用之后,在navigationBar:shouldPopItem中,获取到的topViewController(topViewController, visibleViewController, viewControllers.lastObject)不同。
通过1)方法,获取到的topViewController,是pop前的VC;而通过2)方法,获取到的topViewController是pop之后出现的VC。
注意:我们要拦截的,是 导航栏 『返回』按钮,而不是 popViewControllerAnimated。
就因为这个区别,导致出现了我遇到的问题,简单描述一下,有A->B->C三个VC,其中B和C都遵循协议,实现了相应的是否pop的判断方法,称为shouldPop_B, shouldPop_C。考虑下面两种情况:
情况一:在B中,点返回按钮,返回事件被拦截,调用navigationBar:shouldPopItem,由于是通过点按钮触发的,所以topViewController 是 B,于是调用B->shouldPop_B,根据执行结果,对B进行pop或者保持。
情况二:在B中通过2)方法,调用 [self.navigationController popViewControllerAnimated:YES]; 关闭自己(即B),那么也会触发navigationBar:shouldPopItem,此时,topViewController是A,A并不遵循协议,正常执行pop,关闭B。注意,此时,navigationBar:shouldPopItem 中的 popViewControllerAnimated 也不应当被执行。
以上是在B中的操作,是完全正常的。
但是,如果上述操作换到C中,则会出现下面的问题:
情况一:与在B的情况相同;
情况二:在C中通过2)方法,调用[self.navigationController popViewControllerAnimated:YES]; 关闭自己(即C),触发navigationBar:shouldPopItem,此时,topViewController是B,而B也遵循协议,那么在 navigationBar:shouldPopItem中就执行了B->shouldPop_B,这显然是不对的。
修改这个问题的方法也很简单,因为通过2) 方法,调用popViewControllerAnimated 触发的 navigationBar:shouldPopItem 中,viewControllers.count 与 navigationBar.items.count 不同(通过点击按钮触发则这两个值相同)。所以,通过在 navigationBar:shouldPopItem 最初判断是否为 popViewControllerAnimated 触发,也就是比较上面两个count的大小,如果topViewController不是我们预期的VC,那么直接返回YES,进行正常的pop动作即可。确认了能够获取到预期的topViewController,再对shouldPop进行调用,才能保证调用到了预期的VC上实现的判断方法。(上面的代码已经修改了这个问题)
======= 以下是最近测试发现的问题和解决方法 =======
问题:连击 『返回』按钮,会导致navigation bar异常。异常表现在,当前VC返回了,但是返回后的VC没有了『返回』按钮,或者有时候没有了title。
跟踪发现,连击『返回』按钮时,控制台会打出如下log:
2018-01-03 14:27:33.808220+0800 xxxx[4658:1856459] **** shouldpop shouldPopItem in
2018-01-03 14:27:33.808622+0800 xxxx[4658:1856459] **** shouldpop 2, 1
2018-01-03 14:27:33.862542+0800 xxxx[4658:1856459] **** shouldpop shouldPopItem in
2018-01-03 14:27:33.862769+0800 xxxx[4658:1856459] **** shouldpop 2, 1
2018-01-03 14:27:33.862904+0800 xxxx[4658:1856459] nested pop animation can result in a corrupted navigation bar
2018-01-03 14:27:33.866050+0800 xxxx[4658:1856459] Attempting to begin a transition on navigation bar (<UINavigationBar: 0x10190c0c0; frame = (0 20; 375 44); opaque = NO; autoresize = W; tintColor = UIExtendedGrayColorSpace 1 1; gestureRecognizers = <NSArray: 0x1c4250b90>; layer = <CALayer: 0x1c402a780>>) while a transition is in progress.
可见,函数
- (BOOL) navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
在短时间被调用了两次。google 『nested pop animation can result in a corrupted navigation bar』这段log,大致的解释为:在A中pop B,B已经完成pop后,又调用A pop B造成的了异常。但是控制台也没有其他的信息。APP没有crash,可见不是个fatal exception,但是却已经不能正常使用了。
由于使用的『返回』按钮不是自己添加的UIBarButtonItem,获取不到它的点击事件(点击动作),因此不能在『控制点击』这个点进行处理。于是,我想到的只能是一个比较投机取巧的方法,就是使用delay,在一段时间内不处理pop的动作,从而保证navigation bar的正常。
下面是加入的代码:
// 加入一个属性,标记是否正在执行pop
@property (nonatomic) BOOL isAnimating;
- (BOOL) navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item{
NSLog(@"**** shouldpop shouldPopItem in");
BOOL shouldPop = YES;
NSUInteger count = self.viewControllers.count;
NSUInteger itemsCount = navigationBar.items.count;
if(self.isAnimating == YES){
if(count < itemsCount){
self.isAnimating = NO;
NSLog(@"**** shouldpop isAnimating==yes, set isAnimating=no, %d", shouldPop);
return shouldPop;
}else{
NSLog(@"**** shouldpop isAnimating==yes, shouldpop 3, 0");
return NO;
}
}
self.isAnimating = YES;
NSLog(@"**** shouldpop set isAnimating=yes");
if(count < itemsCount){
self.isAnimating = NO;
NSLog(@"**** shouldpop set isAnimating=no, %d", shouldPop);
return shouldPop;
}
UIViewController *vc = self.topViewController;
if([vc respondsToSelector:@selector(shouldPopOnBackButtonPress)]){
shouldPop = [vc performSelector:@selector(shouldPopOnBackButtonPress)];
}
if(shouldPop == NO){
[self setNavigationBarHidden:YES];
[self setNavigationBarHidden:NO];
}else{
// 不能直接调用pop,如果是通过popViewController调起,会造成循环调用此方法
// 如果是通过调用[navigationController popViewControllerAnimated:]导致的shouldPop delegate,
// 此时已经完成了viewController的pop, viewControllers.count 会比 navigationBar.items.count小1
// 这种情况就不必再次调用popViewController
if(count >= itemsCount){
dispatch_async(dispatch_get_main_queue(), ^{
[self popViewControllerAnimated: YES];
});
}
}
[self resumeAnimationAfter: 0.2];
NSLog(@"**** shouldpop 2, %d", shouldPop);
return shouldPop;
}
- (void)resumeAnimationAfter: (CGFloat)delay {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
self.isAnimating = NO;
NSLog(@"**** shouldpop set isAnimating=no");
});
}
每次执行pop时,都delay 0.2s再置回状态,保证在这0.2s期间是不进行正常响应的。0.2只是个经验值,因为我的工程中大多数都是h5页面,所以0.2s并不会有明显的延迟。
连续快速点击『返回』按钮,控制台输出的log为:
2018-01-04 15:09:30.434872+0800 xxxx[5838:2009875] **** shouldpop shouldPopItem in
2018-01-04 15:09:30.435076+0800 xxxx[5838:2009875] **** shouldpop set isAnimating=yes
2018-01-04 15:09:30.435248+0800 xxxx[5838:2009875] **** shouldpop 2, 1
2018-01-04 15:09:30.503845+0800 xxxx[5838:2009875] **** shouldpop shouldPopItem in
2018-01-04 15:09:30.504052+0800 xxxx[5838:2009875] **** shouldpop isAnimating==yes, shouldpop 3, 0
2018-01-04 15:09:30.505148+0800 xxxx[5838:2009875] **** shouldpop shouldPopItem in
2018-01-04 15:09:30.505310+0800 xxxx[5838:2009875] **** shouldpop isAnimating==yes, shouldpop 3, 0
2018-01-04 15:09:30.506152+0800 xxxx[5838:2009875] **** shouldpop shouldPopItem in
2018-01-04 15:09:30.506547+0800 xxxx[5838:2009875] **** shouldpop isAnimating==yes, shouldpop 3, 0
2018-01-04 15:09:30.507221+0800 xxxx[5838:2009875] **** shouldpop shouldPopItem in
2018-01-04 15:09:30.507322+0800 xxxx[5838:2009875] **** shouldpop isAnimating==yes, shouldpop 3, 0
2018-01-04 15:09:30.507999+0800 xxxx[5838:2009875] **** shouldpop shouldPopItem in
2018-01-04 15:09:30.508113+0800 xxxx[5838:2009875] **** shouldpop isAnimating==yes, shouldpop 3, 0
2018-01-04 15:09:30.649635+0800 xxxx[5838:2009875] **** shouldpop set isAnimating=no
需要说明的一点是,当下面的判断if(count < itemsCount)成立时,需要函数返回yes,执行返回动作。
SWIFT代码:
// delay为延迟时间 milliseconds,毫秒,代码中设置为200
fileprivate func resumeAnimationAfter(_ delay: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) {
self.isAnimating = false
print("**** shouldpop set isAnimating=no")
}
}
===== 以下是iOS 13 上发现的问题 =====
升级iOS 13后,发现部分页面出现了点击一次『返回』,会连续返回两个page的问题。
针对此问题有两点需要说明,只能简单说说现象,深层次的原因暂时是没时间研究了:
1,在代码里直接通过调用popViewController关闭页面,即使用:
self.navigationController?.popViewController(animated: true)
时,iOS 13并不会调用shouldPopitem delegate:
optional public func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool // same as push methods
,页面会被正常pop。而 iOS 12以下会调用,并且在这个delegate起始位置, 如下判断结果为真,也可以正常执行pop动作:
let viewControllersCount = self.viewControllers.count
let navigationItemsCount = navigationBar.items?.count
if(viewControllersCount < navigationItemsCount!){
}
2,点击『返回』后,在iOS 13 和 iOS 12上,上述代码log相同,如下:
navigationItemsCount: 3
**** shouldpop set isAnimating=yes
viewControllersCount: 3, navigationItemsCount: 3
**** shouldpop 2, true
**** shouldpop set isAnimating=no
但是,在iOS 13上,却会连续返回两个page,并且第二个page返回时没有进shouldPop delegate。
在代码方面造成这个现象的原因是下述语句:
NSUInteger count = self.viewControllers.count;
NSUInteger itemsCount = navigationBar.items.count;
if(count >= itemsCount){
dispatch_async(dispatch_get_main_queue(), ^{
[self popViewControllerAnimated: YES];
});
}
此处,判断count>=itemsCount 为真时,执行pop的动作,否则不执行。
就是这个判断,导致在iOS 13上出现连续返回两个page的问题。猜测是navigationbar和viewcontroller是分开的两部分,分别返回,顺序或者层级关系导致。暂时的处理是加入版本判断:
// 2019-10-9: fix: iOS 13, 返回会连续两次
print("viewControllersCount: \(viewControllersCount), navigationItemsCount: \(navigationItemsCount!)")
var doPop = false
let systemVersion = UIDevice.current.systemVersion
let equalOrUpper = systemVersion.compare("13.0", options: NSString.CompareOptions.numeric)
if equalOrUpper == ComparisonResult.orderedAscending { // 小于 13.0
if viewControllersCount >= navigationItemsCount! {
doPop = true
}
}else{
if viewControllersCount > navigationItemsCount! {
doPop = true
}
}
if doPop {
DispatchQueue.main.async { () -> Void in
self.popViewController(animated: true)
}
}