iOS右滑返回手勢深度全解和最佳實施方案

給策發表於2018-04-24

序言

   在ios7以後,蘋果推出了手勢滑動返回功能,也就是從螢幕左側向右滑動可返回上一個介面。大大提高了APP在大屏手機和iPad上的操作體驗,場景切換更加流暢。做右滑返回手勢配置時,可能會遇到的問題:

   1. 右滑返回手勢為什麼失效?

   2. 右滑返回手勢如何全域性開啟及怎麼避免頁面卡死?

   3. 特定頁面停用右滑手勢後如何再次開啟?

   4. 右滑返回手勢與滾動檢視手勢衝突怎麼解決?

   5. 全屏右滑返回怎麼設定?

問題分析

右滑返回手勢為什麼失效?

   右滑返回手勢失效主要是因為自定義了頁面中navigationItem的leftBarButtonItem或leftBarButtonItems,或是self.navigationItem.hidesBackButton = YES;隱藏了返回按鈕,亦或是self.navigationItem.leftItemsSupplementBackButton = NO;,讓我們來梳理下。    UINavigationItem(Apple文件)是一個常見的類,然而還有不少開發者對該類瞭解甚少,這裡注重說明下backBarButtonItemleftBarButtonItemrightBarButtonItemleftItemsSupplementBackButton四個屬性。leftBarButtonItem、rightBarButtonItem是在當前頁面設定,並展示在當前頁面的navigationItem上。backBarButtonItem若是在當前頁面設定,卻展示在次級頁面navigationItem上。

   比如在AViewController push BViewController時,在A設定了self.navigationItem.backBarButtonItem的title和image,經過試驗發現,這個backBarButtonItem為BViewController的self.navigationController.navigationBar.backItem.backBarButtonItem。雖然self.navigationController.navigationBar.backItem.backBarButtonItem 是讀寫屬性,但是self.navigationController、self.navigationController.navigationBar、 self.navigationController.navigationBar.backItem,都是readonly屬性,因此backBarButtonItem,只能在AViewController中定義並在Push:BViewController之前進行設定。leftBarButtonItem、rightBarButtonItem可以在BViewController的ViewDidLoad後設定。

   注意backBarButtonItem只能自定義image和title,不能重寫target 或 action,系統會忽略其他的相關設定項。如果硬是需要重寫action做一些其他的工作,則需要自定義一個leftBarButtonItem。    系統預設情況下leftBarButtonItem的優先順序是要高於backBarButtonItem的,當存在leftBarButtonItem時,自動忽略backBarButtonItem,達到重寫backBarButtonItem的目的,但會造成右滑返回手勢的響應代理從當前頁面被覆蓋性移除。同時,系統也提供了leftItemsSupplementBackButton屬性來控制backBarButtonItem 是否被 leftBarButtonItem “覆蓋”,預設值是NO,若配置leftBarButtonItem,還需要有返回按鈕和右滑手勢,需要在leftBarButtonItem或leftBarButtonItems後,把leftItemsSupplementBackButton,設定為YES。

特定頁面停用右滑手勢?

   如左右分頁瀏覽、看視訊、看音訊、支付等特定頁面場景,是“不希望”使用者便捷離開的,或有彈窗提示的需求,也有避免使用者誤操作的考慮。同時,可能存在右滑返回手勢衝突,或右滑返回後可能有音訊焦點不能及時釋放的問題。怎麼做呢?我們可以通過程式碼設定停用右滑返回手勢,或改用presentViewController方式載入頁面。

恢復右滑手勢的解決方案

方案一 手勢代理替換

   系統的自帶的有返回箭頭和上級頁面title的返回按鈕,我們無需設定,系統自動生成,預設tintColor為藍色。然而,這樣的樣式並不是我們想要的。我們通常做法是去,設定該頁面的leftBarButtonItem或leftBarButtonItems,來自定義返回按鈕的樣式。通過上面的問題分析,我們可以知道,leftBarButtonItem或leftBarButtonItems 直接覆蓋了self.navigationController.navigationBar.backItem.backBarButtonItem,造成右滑返回手勢的響應代理從當前頁面被覆蓋性移除,造成右滑返回手勢失效。我們可以通過在上個頁面設定self.navigationItem.backBarButtonItem,並在下個頁面設定self.navigationItem.leftItemsSupplementBackButton = YES。沒有做基類管理的專案可能到處都是自定義leftBarButtonItem或leftBarButtonItem,工作量較大。快上車,讓老司機帶你一程!

保留系統的右滑返回手勢

   既然設定backBarButtonItem較為繁雜,我們可以換個思路,手勢已被覆蓋性移除,我們需要給頁面新增上右滑返回手勢。若專案有全域性的UINavigationController基類,實現下列參考程式碼:

@implementation YGNavigationController

- (void)viewDidLoad
{
    [super viewDidLoad];
    //設定右滑返回手勢的代理為自身
    __weak typeof(self) weakself = self;
    if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        self.interactivePopGestureRecognizer.delegate = (id)weakself;
    }
}

#pragma mark - UIGestureRecognizerDelegate
//這個方法是在手勢將要啟用前呼叫:返回YES允許右滑手勢的啟用,返回NO不允許右滑手勢的啟用
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    if (gestureRecognizer == self.interactivePopGestureRecognizer) {
        //遮蔽呼叫rootViewController的滑動返回手勢,避免右滑返回手勢引起當機問題
        if (self.viewControllers.count < 2 ||
 self.visibleViewController == [self.viewControllers objectAtIndex:0]) {
            return NO;
        }
    }
    //這裡就是非右滑手勢呼叫的方法啦,統一允許啟用
    return YES;
}
複製程式碼

   將專案中的使用UINavigationController 替換為UINavigationController基類,自定義返回按鈕設定不變,恢復了右滑返回手勢。注意:導航欄的左側也是支援右滑返回手勢,若有UIViewController基類也可以參照上面設定程式碼調整設定,來消除導航欄的左側小區域的右滑返回。

   一定要實現UIGestureRecognizerDelegate 並做rootViewController 判斷,否則,在rootViewController頁面會存在右滑返回當機的問題。

特定頁面停用右滑手勢

   我們檢視UINavigationController 文件,可以找到

@property(nullable, nonatomic, readonly) UIGestureRecognizer *interactivePopGestureRecognizer NS_AVAILABLE_IOS(7_0) __TVOS_PROHIBITED;
複製程式碼

   可以通過設定頁面的VC.navigationController.interactivePopGestureRecognizer.enabled 來控制當前頁面的右滑返回手勢是否可用。我們可以建立一個UIViewController 的分類建立兩個類方法。

+ (void)popGestureClose:(UIViewController *)VC
{
    // 禁用側滑返回手勢
    if ([VC.navigationController respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        //這裡對新增到右滑檢視上的所有手勢禁用
        for (UIGestureRecognizer *popGesture in VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers) {
            popGesture.enabled = NO;
        }
        //若開啟全屏右滑,不能再使用下面方法,請對陣列進行處理
        //VC.navigationController.interactivePopGestureRecognizer.enabled = NO;
    }
}

+ (void)popGestureOpen:(UIViewController *)VC
{
    // 啟用側滑返回手勢
    if ([VC.navigationController respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
    //這裡對新增到右滑檢視上的所有手勢啟用
        for (UIGestureRecognizer *popGesture in VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers) {
            popGesture.enabled = YES;
        }
        //若開啟全屏右滑,不能再使用下面方法,請對陣列進行處理
        //VC.navigationController.interactivePopGestureRecognizer.enabled = YES;
    }
}
複製程式碼

   具體怎麼使用呢?我們需要在停用右滑返回手勢的頁面實現以下兩個方法,經過多次除錯驗證,必須是以下兩個方法。停用當前頁面後,不影響上級頁面和下級頁面的右滑返回。

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    [UIViewController popGestureClose:self];
}

- (void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    [UIViewController popGestureOpen:self];
}

複製程式碼

方案二 原生態:自定義backBarButtonItem

   網上的思路大多是基於方案一,這是我在研究方案一中回溯思路得出的一個方案,直接利用系統的backBarButtonItem和右滑返回手勢特性,相對更穩定,更高效,我想iOS系統APP的右滑返回設計應是這個“官方思路”。

保留系統的右滑返回手勢

   這裡需要對每個頁面設定自己的backBarButtonItem,就像設定每個頁面的leftBarButtonItem的思路一樣。但是backBarButtonItem是一個特殊的按鈕,可以說只響應頁面的返回和銷燬,表現為只能自定義image和title,不能重寫target 或 action。來讓我們自定義以下backBarButtonItem。參照問題分析的思路,須在AViewController中實現下列參考程式碼:

    UIBarButtonItem *backItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
    //自定義返回按鈕的檢視,如細化返回圖示。
     [self.navigationController.navigationBar setBackIndicatorImage:[UIImage imageNamed:@"navi_back_icon"]];
     [self.navigationController.navigationBar setBackIndicatorTransitionMaskImage:[UIImage imageNamed:@"navi_back_icon"]];
     //設定tintColor 改變自定圖片顏色
     self.navigationController.navigationBar.tintColor = [UIColor whiteColor];
     //設定自定義的返回按鈕
     self.navigationItem.backBarButtonItem = backItem;
複製程式碼

   按照上面的建立思路,已經完成頁面自定義返回按鈕,並保留了右滑返回手勢(注意:導航欄的左側是不只支援右滑返回手勢,這裡和方案一有一點區別)。在AViewController push BViewController 或 CViewController 都不需要在再重定義leftBarButtonItem,來實返回按鈕了。依次實現各個控制器的backBarButtonItem,即可完成整個APP的右滑返回手勢功能,當然以上程式碼我們可以封裝到一個UIViewController基類並在ViewDidLoad方法中來統一設定,或者封裝一個工具方法統一呼叫,當新的頁面頁面需要不同的返回樣式時,在push頁面CViewController之前,重新建立backBarButtonItem覆蓋即可。    **注意:**因系統backBarButtonItem中封裝的UIButton使用的左圖右標題的佈局樣式和通常的UIButton上圖下標題的佈局樣式有一定的差別,造成即使標題為空,返回按鈕的圖示的位置依然偏左,我們可以通過UIBarButtonItem的UIBarButtonSystemItemFixedSpace來調圖示位置或者設定佔位符標題增大手勢響應區域。

特定頁面停用右滑手勢或左側新添按鈕

   怎麼做呢?自定義leftBarButtonItem或leftBarButtonItems,並設定leftItemsSupplementBackButton = YES。參考程式碼:

 //自定義返回按鈕
     UIButton *studySearch = [UIButton buttonWithType:UIButtonTypeCustom];
     [studySearch setImage:[UIImage imageNamed:@"study_search"] forState:UIControlStateNormal];
     [studySearch sizeToFit];
     [studySearch addTarget:self action:@selector(studySearchAction) forControlEvents:UIControlEventTouchUpInside];
    UIBarButtonItem *studySearchItem = [[UIBarButtonItem alloc] initWithCustomView:studySearch];
     self.navigationItem.leftBarButtonItems = @[studySearchItem];
     //是否支援顯示左滑返回按鈕,NO不顯示:leftBarButtonItems覆蓋backBarButtonItem,
     //YES顯示:backBarButtonItem 顯示在leftBarButtonItems左側
     self.navigationItem.leftItemsSupplementBackButton = YES;
複製程式碼

   leftItemsSupplementBackButton必須在自定義leftBarButtonItem或leftBarButtonItems後才有效。

方案三 完全自定義導航欄

   有些專案中的導航欄或導航控制器是完全自定義的,具體的實現的可以參照方案一實施,這裡不再做深入探究。

右滑返回引起手勢的衝突

   方案二不會存在方案一中的卡死現象。iOS系統中,滑動返回手勢其實是一個UIPanGestureRecognizer,UIScrollView的滑動手勢也是UIPanGestureRecognizer,UIPanGestureRecognizer接收順序和UIView的層次結構是一致的。

UINavigationController.view —>  UIViewController.view —>  UIScrollView —>  Screen and User's finger
複製程式碼

   原理:UIScrollView(包括子類UITextView、UITableView、UICollectionView)的panGestureRecognizer先接收到手勢事件,直接處理後不在往下傳遞。實際上這就是兩個panGestureRecognizer共存的問題。scrollView的pan手勢會讓系統的pan手勢失效,當UIScrollView(UICollectionView)有多頁的時候也會出現滑動返回失效的情況,我們需要在scrollView的位置在初始位置的時候,讓兩個手勢同時啟用。 可以建立UIScrollView的類別category,然後在此類別中實現以下方法即可:

#import "UIScrollView+PopGesture.h"

@implementation UIScrollView (PopGesture)

//此方法返回YES時,手勢事件會一直往下傳遞,不論當前層次是否對該事件進行響應。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
    if ([self panBack:gestureRecognizer]) {
        return YES;
    }
    return NO;
}

//location_X可自己定義,其代表的是滑動返回距左邊的有效長度
- (BOOL)panBack:(UIGestureRecognizer *)gestureRecognizer
{
    //是滑動返回距左邊的有效長度
    int location_X = 40;
    if (gestureRecognizer == self.panGestureRecognizer) {
        UIPanGestureRecognizer *pan = (UIPanGestureRecognizer *)gestureRecognizer;
        CGPoint point = [pan translationInView:self];
        UIGestureRecognizerState state = gestureRecognizer.state;
        if (UIGestureRecognizerStateBegan == state || UIGestureRecognizerStatePossible == state) {
            CGPoint location = [gestureRecognizer locationInView:self];
            //下面的是隻允許在第一張時滑動返回生效
            if (point.x > 0 && location.x < location_X && self.contentOffset.x <= 0) {
                return YES;
            }
         //   這是允許每張圖片都可實現滑動返回
         //   int temp1 = location.x;
         //   int temp2 = SCREEN_WIDTH;
         //   NSInteger XX = temp1 % temp2;
         //   if (point.x > 0 && XX < location_X) {
         //      return YES;
         //   }
        }
    }
    return NO;
}

- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    if ([self panBack:gestureRecognizer]) {
        return NO;
    }
    return YES;
}

@end
複製程式碼

右滑返回的全螢幕設定

   隨著手機螢幕的變大,原來右滑返回略顯不夠人性化,尤其是對手小的朋友,如何能愉快的單手玩手機呢。對於app要全屏右滑或保持原生邊緣觸發,各有說辭,這裡不討論其好壞,根據產品需要而定。我們在方案一的基礎上,建立一個螢幕手勢,新增到原來的self.interactivePopGestureRecognizer.view 右滑返回手勢的檢視上,即是講手勢新增到VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers陣列中,新增手勢必須在設定代理之前完成


- (void)viewDidLoad
{
    [super viewDidLoad];
    //設全屏啟動右滑返回手勢,此處可以優化為iPad 上支援全屏
    if ((UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)) {
        id target = self.interactivePopGestureRecognizer.delegate;
        SEL handler = NSSelectorFromString(@"handleNavigationTransition:");
        // 獲取新增系統邊緣觸發手勢的View
        UIView *targetView = self.interactivePopGestureRecognizer.view;
        // 建立pan手勢 作用範圍是全屏
        UIPanGestureRecognizer *fullScreenGes = [[UIPanGestureRecognizer alloc]initWithTarget:target action:handler];
        fullScreenGes.delegate = self;
        [targetView addGestureRecognizer:fullScreenGes];
        // 關閉邊緣觸發手勢 防止和原有邊緣手勢衝突(也可不用關閉)
        [self.interactivePopGestureRecognizer setEnabled:NO];
    }
    //設定右滑返回手勢的代理為自身
    __weak typeof(self) weakself = self;
    if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        self.interactivePopGestureRecognizer.delegate = (id)weakself;
    }
}
複製程式碼

   注意: 系統在self.interactivePopGestureRecognizer.view上已經新增有VC.navigationController.interactivePopGestureRecognizer手勢,也可以在VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers陣列中取出,此時陣列中,有兩個響應手勢。因此對方案一中的手勢控制就要使用陣列形式的處理方式。

for (UIGestureRecognizer *popGesture in VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers) {
            popGesture.enabled = NO;
        }
複製程式碼

總結

   iOS開發都是基於蘋果系統的開發,設定系統級全域性性的功能時,最好選擇系統或在系統的基礎上自定義,儘量少些自以為是的完全自定義,少些奇葩設計,好的內容才是一個產品的核心,好的產品體驗也是使用者留存的粘合劑!

原文

相關文章