iOS攔截導航欄返回按鈕的方法

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.lxweimin.com/p/a502d363c998
2: [iOS攔截導航欄返回按鈕事件的正確方式]: http://www.lxweimin.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.lxweimin.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)
       }
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容