[iOS]UINavigationController 全屏 pop 之為控制器新增左滑 push

NewPan發表於2019-02-22

宣告:我為這個框架寫了四篇文章:

第一篇:[iOS]UINavigationController全屏pop之為每個控制器自定義UINavigationBar

第二篇:[iOS]UINavigationController全屏pop之為每個控制器新增底部聯動檢視

第三篇:[iOS]UINavigationController全屏pop之為控制器新增左滑push

第四篇:[iOS]調和 pop 手勢導致 AVPlayer 播放卡頓

[iOS]UINavigationController 全屏 pop 之為控制器新增左滑 push

框架特性

✅ 全屏 pop 手勢支援

✅ 全屏 push 到繫結的控制器支援

✅ 為每個控制器定製 UINavigationBar 支援(包括設定顏色和透明度等)

✅ 為每個控制器新增底部聯動檢視支援

✅ 自定義 pop 手勢範圍支援(從螢幕最左側開始計算寬度)

✅ 為單個控制器關閉 pop 手勢支援

✅ 為所有控制器關閉 pop 手勢支援

❤️ 噹噹前控制器使用 AVPlayer 播放視訊的時候, 使用自定義的 pop 動畫以保證 AVPlayer 流暢播放.

[iOS]UINavigationController 全屏 pop 之為控制器新增左滑 push

這是“UINavigationController全屏pop”系列的第三篇文章,這次將講述如何實現左滑push到繫結的控制器中,並且帶有push動畫。如果你沒有看過我之前的兩篇文章,建議你從第一篇開始看。或者你也可以直接去我的Github上檢視 JPNavigationController 的原始碼。

01、引子

用過新聞軟體的朋友應該都知道,比方說網易新聞,你如果在它的新聞詳情頁左滑,它會出現一個 push 動畫開啟評論頁面。這次我們就來討論,在基於之前的封裝基礎上如何實現這個功能。

02、世面上現有的APP都是怎麼實現的?

左滑 push 到下一個頁面的功能,藉助於 Reveal 觀察,大致可以分為兩類:

  • 第一類,以網易新聞、騰訊新聞、鳳凰新聞等主流新聞為代表的 APP,他們都在新聞詳情頁繫結了左滑手勢 push 到評論頁面的功能。
  • 第二類,以 Instagram、Snapchat 為代表的國外社交 APP,他們在UITabBarController 的某些分支上整合了左滑和右滑手勢繫結切換到不同的控制器的功能。

通過 Reveal 觀察發現,第一類左滑手勢的功能是整合到了當前控制器對應的 UINavigationController 上。而第二類是採用讓 window 的根控制器上整合一個 UICollectionView,然後把每個控制器的 view 新增到 UICollectionViewCell 上,這樣就可以實現左滑以及右滑切換到不同的控制器的效果。第二類和我常見的新聞頁面的子欄目切換是一個道理,相信大家都會實現的。我們現在要講的就是怎麼將左滑手勢的功能是整合到了當前控制器對應的 UINavigationController

[iOS]UINavigationController 全屏 pop 之為控制器新增左滑 push

iOS 現在主流的框架結構是像上圖這樣的,如果要像第二類 APP 那樣實現左滑功能,勢必需要重新架構專案,這對於很多成熟的 APP 來說,工作量還是比較繁重的。所以值得嘗試的方案是,在不改變現有專案架構的前提下實現左滑 push 功能。也就是說,要把左滑手勢繫結到對應的導航控制器上。

03、AOP面向切面程式設計思想

iOS 工程師都知道 runtime,也就是執行時,得益於 Objective-Cruntime 的特性,我們可以動態的為類新增方法,以及替換系統的實現等。如果把這種行為抽象成為一個更高階的思想的話,就是所謂的 AOP(AOP 是Aspect Oriented Program的首字母縮寫),也就是面向切面程式設計。關於 AOP 具體可以看 維基百科 上的解釋,或者 知乎 上的回答。這個框架也是基於 AOP 思想的,所以能夠在不侵入使用者的專案的條件下實現以上的特性。

04、大致思路

  • 01.首先我們要拿到使用者左滑 left-slip 這個事件
  • 02.接下來要詢問使用者是否需要給左滑手勢繫結對應的 push 事件
  • 03.如果使用者繫結了事件,此時應該建立要push到的控制器
  • 04.跟蹤使用者的手勢,驅動過渡動畫
  • 05.完成 push 手勢

05、具體實現

5.1. 首先我們要拿到使用者左滑 left-slip 這個事件

之前我的第二篇文章說過,框架裡使用 UIPanGestureRecognizer 代替了系統的手勢,所以我們能夠在 UIPanGestureRecognizer 的代理方法中拿到使用者是否左滑了。

  -(BOOL)gestureRecognizerShouldBegin:(UIPanGestureRecognizer *)gestureRecognizer{

        // System pop action.
        SEL action = NSSelectorFromString(@"handleNavigationTransition:");
    
        CGPoint translation = [gestureRecognizer velocityInView:gestureRecognizer.view];
        if (translation.x<0) {
            // left-slip --> push.
            UIViewController *rootVc = [UIApplication sharedApplication].keyWindow.rootViewController;
            UIImage *snapImage = [JPSnapTool snapShotWithView:rootVc.view];
            NSDictionary *dict = @{
                                   @"snapImage" : snapImage,
                                   @"navigationController" : self.navigationController
                                   };
            [[NSNotificationCenter defaultCenter]postNotificationName:@"NavigationDidSrolledLeft" object:dict userInfo:nil];
            [gestureRecognizer removeTarget:_target action:action];
            return YES;
        }
        else{
            // right-slip --> pop.
            [[NSNotificationCenter defaultCenter]postNotificationName:@"NavigationDidSrolledRight" object:self.navigationController userInfo:nil];
            [gestureRecognizer addTarget:_target action:action];
        }    
    }
    
複製程式碼

5.2. 接下來要詢問使用者是否需要給左滑手勢繫結對應的push事件

首先我們應該建立一個協議,只要遵守協議,並實現協議方法,每個控制器就都能擁有push功能。

    /*!
     * ~english
     * Just follow the JPNavigationControllerDelegate protocol and override the delegate-method in this protocol use [self.navigationController pushViewController:aVc animated:YES] if need push gesture transition animation when left-slip.
     * You should preload the data of next viewController need to display for a good user experience.
     *
     * ~chinese
     * 如果需要在某個介面實現push左滑手勢動畫, 只需要遵守這個協議, 並且實現以下這個的協議方法, 在協議方法裡使用[self.navigationController pushViewController:aVc animated:YES], 就可擁有左滑push動畫了.
     * 關於資料預載入, 為了獲得良好的使用者體驗, 建議在push之前就把要push到的頁面的資料請求到本地, push過去直接能展示資料.
     */
    @protocol JPNavigationControllerDelegate <NSObject>

    @required
    /*!
     * ~english
     * The delegate method need to override if need push gesture transition animation when left-slip.
     *
     * ~chinese
     * 實現push左滑手勢需要實現的代理方法.
     */
    -(void)jp_navigationControllerDidPushLeft;

    @end
複製程式碼

因為我們希望在每個頁面都能擁有繫結左滑 push 的功能,所以我們可以把詢問使用者是否需要 push 的代理繫結到每個控制器的 navigationController 上。

    /*!
     * ~english
     * The delegate for function of left-slip to push next viewController.
     *
     * ~chinese
     * 實現左滑left-slip push到下一個控制器的代理.
     */
    @property(nonatomic)id<JPNavigationControllerDelegate> jp_delegate;
    
複製程式碼

5.3. 如果使用者繫結了事件,此時應該建立要 push 到的控制器

由於之前已經為每個控制器新增了檢查是否需要 push 動畫的入口。所以,當檢測到使用者 push 的時候,應該開始檢查使用者是否遵守了協議並實現了協議方法,從而決定是否需要建立 push 動畫。

    -(void)didPushLeft:(JPNavigationInteractiveTransition *)navInTr{
    
        // Find the displaying warp navigation controller first now when left-slip, check this navigation is overrided protocol method or not after, if yes, call this method.
        // 左滑push的時候, 先去找到當前在視窗的用於包裝的導航控制器, 再檢查這個控制器有沒有遵守左滑push協議, 看這個介面有沒有實現左滑調起push的代理方法, 如果實現了, 就執行代理方法.
    
        NSArray *childs = self.childViewControllers;
        JPWarpViewController *warp = (JPWarpViewController *)childs.lastObject;
        JPWarpNavigationController *nav = (JPWarpNavigationController *)warp.childViewControllers.firstObject;
        if (nav) {
            if ([nav.jp_delegate respondsToSelector:@selector(jp_navigationControllerDidPushLeft)]) {
                [nav.jp_delegate jp_navigationControllerDidPushLeft];
            }
        }
    }
複製程式碼

當檢測到使用者需要 push 動畫的時候,我們就要開始準備 push 動畫了。我們把 pop 動畫交給系統的時候,是需要把根導航控制器(JPNavigationController)的 delegate 置為 nil 的,並且需要為我們自定義的 UIPanGestureRecognizer 新增 target,這個我在第一篇文章已經講過了。由於pop已經交給系統處理,所以這裡只負責處理push動畫。系統是沒有push動畫的,所以我們要自己動手來實現。要想代理系統的push動畫,我們需要成為根導航控制器(JPNavigationController)的代理,遵守協議,並且實現兩個require的代理方法。

我們在第一個方法裡檢查是否是push操作,如果是,我們就要返回我們自定義的push動畫物件。同時,我們需要手勢驅動動畫過程,所以,我們需要建立手勢監控者來負責在使用者滑動的時候更新動畫,也就是第二個方法。

    - (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                  animationControllerForOperation:(UINavigationControllerOperation)operation
                                               fromViewController:(UIViewController *)fromVC
                                                 toViewController:(UIViewController *)toVC {
    
        // If the animation operation now is push, return custom transition.
        // 判斷如果當前執行的是Push操作,就返回我們自定義的push動畫物件。
    
        if (self.isGesturePush && operation == UINavigationControllerOperationPush) {
            self.transitioning.snapImage = self.snapImage;
            return self.transitioning;
        }
        return nil;
    }

    - (id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController
                         interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController {

        // If the animationController is custom push instance, return interactivePopTransition to manage transition progress.
       // 判斷動畫物件animationController是我們自定義的Push動畫物件,那麼就返回interactivePopTransition來監控動畫完成度。

        if (self.isGesturePush && [animationController isKindOfClass:[JPPushnimatedTransitioning class]]) {
            return self.interactivePopTransition;
        }
    
        return nil;
    }
複製程式碼

建立手勢監控者的程式碼如下:

    - (void)handleControllerPop:(UIPanGestureRecognizer *)recognizer {
    
        // This method be called when pan gesture start, because entrust system handle pop, so only handle push here.
        // Calculate the percent of the point origin-X / screen width, alloc UIPercentDrivenInteractiveTransition instance when push start, and check user is overrided the protocol method or not, if overrided, then start push and, set start percent = 0.
        // Refresh the slip percent when pan gesture changed.
        // Judge the slip percent is more than the JPPushBorderlineDelta when pan gesture end.
        // 當使用者滑動的時候就會來到這個方法, 由於pop已經交給系統處理, 所以這裡只負責處理push動畫.
        // 先計算使用者滑動的點佔螢幕寬度的百分比, 當push開始的時候, 建立百分比手勢驅動過渡動畫, 檢查使用者有沒有在這個介面設定需要push, 如果設定了, 就開始push, 並把起點百分比置為0.
        // 在使用者滑動的過程中更新手勢驅動百分比.
        // 在滑動結束的時候, 判斷停止點是否已達到約定的需要pop的範圍.
    
        CGFloat progress = [recognizer translationInView:recognizer.view].x / recognizer.view.bounds.size.width;
        CGPoint translation = [recognizer velocityInView:recognizer.view];
        if (recognizer.state == UIGestureRecognizerStateBegan) {
            self.isGesturePush = translation.x<0 ? YES : NO;
        }    
        if (self.isGesturePush) {
            progress = -progress;
        }
        progress = MIN(1.0, MAX(0.0, progress));
    
        if (recognizer.state == UIGestureRecognizerStateBegan) {
            if (self.isGesturePush) {
                if ([self.delegate respondsToSelector:@selector(didPushLeft:)]) {
                    self.interactivePopTransition = [[UIPercentDrivenInteractiveTransition alloc] init];
                    self.interactivePopTransition.completionCurve = UIViewAnimationCurveEaseOut;
                    [self.delegate didPushLeft:self];
                    [self.interactivePopTransition updateInteractiveTransition:0];
                }
            }
        }
        else if (recognizer.state == UIGestureRecognizerStateChanged) {
            [self.interactivePopTransition updateInteractiveTransition:progress];
        }
        else if (recognizer.state == UIGestureRecognizerStateEnded || recognizer.state == UIGestureRecognizerStateCancelled) {
            if (progress > JPPushBorderlineDelta) {
                [self.interactivePopTransition finishInteractiveTransition];
            }
            else {
                [self.interactivePopTransition cancelInteractiveTransition];
            }
        
            self.interactivePopTransition = nil;
            self.isGesturePush = NO;
            
            // Set root navigation controller`s delegate be nil for follow user`s gesture.
            // 置空導航控制器代理, 等待使用者下一次滑動.
        
            self.nav.delegate = nil;
        }
    }
複製程式碼

5.4. 跟蹤使用者的手勢,驅動過渡動畫

還記得上面那個左滑 push 的動畫嗎?你可能覺得和系統預設的 pop 動畫相比,就是把系統的 pop 動畫反過來,就成了 push 動畫了。如果你能這麼想,那恭喜你,你的直覺很對!!其實,我們很多時候做很多東西都是在模仿系統的實現,在猜系統這個效果究竟是怎麼實現的,然後再一步一步驗證我們的想法是否正確。

[iOS]UINavigationController 全屏 pop 之為控制器新增左滑 push

當你開啟我的 demo 執行的時候,你看到的是左邊的那個樣子,現在我告訴你,實際上它的圖層關係是右邊的這個樣子。也就說,在使用者左滑的那一刻我們需要將準備右圖做動畫需要的元素,包括當前控制器的 View 的截圖 B,要 push 到的控制器的 View 的截圖 C,然後把它們按照這個圖層關係新增到系統提供給我們用來做動畫的容器中。 再在動畫提供者中告訴系統,我們需要做動畫的兩個元素 B 和 C 在動畫起始的時候的 frame,以及在動畫終點的時候這兩個元素的 frame。這個手勢驅動的過程,因為我們已經把這個監聽過程交給手勢監控者,並返還給系統處理了,所以這個過程系統會幫我們處理好。

但是問題是,為什麼我們要用截圖的方式,而不是直接用兩個控制器的 View 來做動畫? 這麼做的原因就是,噹噹前視窗有顯示 tabBar 的時候,tabBar 圖層是在動畫容器圖層之上的,所以我們無法優雅的做百分手勢驅動。所以採取這種方式。但是系統的 pop 手勢不是用截圖的形式,而是直接使用兩個控制器的View來做動畫,就像下面這樣,但是由於許可權問題,我們不可能像系統那樣做,但是也不排除有同學想到巧妙的辦法來實現。

[iOS]UINavigationController 全屏 pop 之為控制器新增左滑 push

下面看下動畫提供者的原始碼:

    - (void)animateTransitionEvent {
    
        // Mix shadow for toViewController` view.
        CGFloat scale = [UIScreen mainScreen].scale/2.0;
        [self.containerView insertSubview:self.toViewController.view aboveSubview:self.fromViewController.view];
        UIImage *snapImage = [JPSnapTool mixShadowWithView:self.toViewController.view];
    
        // Alloc toView`s ImageView
        UIImageView *ivForToView = [[UIImageView alloc]initWithImage:snapImage];
        [self.toViewController.view removeFromSuperview];
        ivForToView.frame = CGRectMake(JPScreenWidth, 0, snapImage.size.width, JPScreenHeight);
        [self.containerView insertSubview:ivForToView aboveSubview:self.fromViewController.view];
    
        //  Alloc fromView`s ImageView
        UIImageView *ivForSnap = [[UIImageView alloc]initWithImage:self.snapImage];
        ivForSnap.frame = CGRectMake(0, 0, JPScreenWidth, JPScreenHeight);
        [self.containerView insertSubview:ivForSnap belowSubview:ivForToView];
    
        // Hide tabBar if need.
        UIViewController *rootVc = [UIApplication sharedApplication].keyWindow.rootViewController;
        if ([rootVc isKindOfClass:[UITabBarController class]]) {
            UITabBarController *r = (UITabBarController *)rootVc;
            UITabBar *tabBar = r.tabBar;
            tabBar.hidden = YES;
        }
    
        self.fromViewController.view.hidden = YES;
        [UIView animateWithDuration:self.transitionDuration animations:^{
        
            // Interative transition animation.
            ivForToView.frame = CGRectMake(-shadowWidth*scale, 0, snapImage.size.width, JPScreenHeight);
            ivForSnap.frame = CGRectMake(-moveFactor*JPScreenWidth, 0, JPScreenWidth, JPScreenHeight);
        
        }completion:^(BOOL finished) {
        
            self.toViewController.view.frame = CGRectMake(0, 0, JPScreenWidth, JPScreenHeight);
            [self.containerView insertSubview:self.toViewController.view belowSubview:ivForToView];
            [ivForToView removeFromSuperview];
            [ivForSnap removeFromSuperview];
            self.fromViewController.view.hidden = NO;
            [self completeTransition];
        }];
    }
複製程式碼

5.5. 完成 push 手勢

到了這裡,基本上已經完成了 push 功能了。只需要在手勢結束的時候告訴系統,是 push 成功還是失敗就可以了。

if (progress > JPPushBorderlineDelta) { 
    [self.interactivePopTransition finishInteractiveTransition]; 
 }
 else { 
       [self.interactivePopTransition cancelInteractiveTransition]; 
 }
複製程式碼

06、注意

注意: tabBar 的 translucent 預設為 YES, 使用 JPNavigationCotroller 不能修改 tabBar 的透明屬性. 這是因為 Xcode 9 以後, 蘋果對導航控制器內部做了一些修改, 一旦將 tabBar 設為不透明, 當前架構下的 UI 就會錯亂, 設定 tabBar 的 backgroundImage 為不透明圖片, 或者設定 backgroundColor 為不透明的顏色值也是一樣的會出錯.

NewPan 的文章集合

下面這個連結是我所有文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有原始碼。

NewPan 的文章集合索引

如果你有問題,除了在文章最後留言,還可以在微博 @盼盼_HKbuy 上給我留言,以及訪問我的 Github

相關文章