聲明:我爲這個框架寫了四篇文章:git
第一篇:[iOS]UINavigationController全屏pop之爲每一個控制器自定義UINavigationBargithub
第二篇:[iOS]UINavigationController全屏pop之爲每一個控制器添加底部聯動視圖編程
✅ 全屏 pop 手勢支持框架
✅ 全屏 push 到綁定的控制器支持ide
✅ 爲每一個控制器定製 UINavigationBar 支持(包括設置顏色和透明度等)post
✅ 爲每一個控制器添加底部聯動視圖支持動畫
✅ 自定義 pop 手勢範圍支持(從屏幕最左側開始計算寬度)ui
✅ 爲單個控制器關閉 pop 手勢支持
✅ 爲全部控制器關閉 pop 手勢支持
❤️ 噹噹前控制器使用 AVPlayer 播放視頻的時候, 使用自定義的 pop 動畫以保證 AVPlayer 流暢播放.
這是「UINavigationController全屏pop」系列的第三篇文章,此次將講述如何實現左滑push到綁定的控制器中,而且帶有push動畫。若是你沒有看過我以前的兩篇文章,建議你從第一篇開始看。或者你也能夠直接去個人Github上查看 JPNavigationController 的源碼。
用過新聞軟件的朋友應該都知道,比方說網易新聞,你若是在它的新聞詳情頁左滑,它會出現一個 push 動畫打開評論頁面。此次咱們就來討論,在基於以前的封裝基礎上如何實現這個功能。
左滑 push 到下一個頁面的功能,藉助於 Reveal 觀察,大體能夠分爲兩類:
UITabBarController
的某些分支上集成了左滑和右滑手勢綁定切換到不一樣的控制器的功能。經過 Reveal
觀察發現,第一類左滑手勢的功能是集成到了當前控制器對應的 UINavigationController
上。而第二類是採用讓 window 的根控制器上集成一個 UICollectionView
,而後把每一個控制器的 view
添加到 UICollectionViewCell
上,這樣就能夠實現左滑以及右滑切換到不一樣的控制器的效果。第二類和我常見的新聞頁面的子欄目切換是一個道理,相信你們都會實現的。咱們如今要講的就是怎麼將左滑手勢的功能是集成到了當前控制器對應的 UINavigationController
上。
iOS 如今主流的框架結構是像上圖這樣的,若是要像第二類 APP 那樣實現左滑功能,勢必須要從新架構項目,這對於不少成熟的 APP 來講,工做量仍是比較繁重的。因此值得嘗試的方案是,在不改變現有項目架構的前提下實現左滑 push 功能。也就是說,要把左滑手勢綁定到對應的導航控制器上。
iOS 工程師都知道 runtime
,也就是運行時,得益於 Objective-C
的 runtime
的特性,咱們能夠動態的爲類添加方法,以及替換系統的實現等。若是把這種行爲抽象成爲一個更高級的思想的話,就是所謂的 AOP
(AOP 是Aspect Oriented Program的首字母縮寫),也就是面向切面編程。關於 AOP
具體能夠看 維基百科 上的解釋,或者 知乎 上的回答。這個框架也是基於 AOP
思想的,因此可以在不侵入用戶的項目的條件下實現以上的特性。
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];
}
}
複製代碼
首先咱們應該建立一個協議,只要遵照協議,並實現協議方法,每一個控制器就都能擁有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;
複製代碼
因爲以前已經爲每一個控制器添加了檢查是否須要 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;
}
}
複製代碼
還記得上面那個左滑 push 的動畫嗎?你可能以爲和系統默認的 pop 動畫相比,就是把系統的 pop 動畫反過來,就成了 push 動畫了。若是你能這麼想,那恭喜你,你的直覺很對!!其實,咱們不少時候作不少東西都是在模仿系統的實現,在猜系統這個效果到底是怎麼實現的,而後再一步一步驗證咱們的想法是否正確。
當你打開個人 demo 運行的時候,你看到的是左邊的那個樣子,如今我告訴你,實際上它的圖層關係是右邊的這個樣子。也就說,在用戶左滑的那一刻咱們須要將準備右圖作動畫須要的元素,包括當前控制器的 View 的截圖 B,要 push 到的控制器的 View 的截圖 C,而後把它們按照這個圖層關係添加到系統提供給咱們用來作動畫的容器中。 再在動畫提供者中告訴系統,咱們須要作動畫的兩個元素 B 和 C 在動畫起始的時候的 frame,以及在動畫終點的時候這兩個元素的 frame。這個手勢驅動的過程,由於咱們已經把這個監聽過程交給手勢監控者,並返還給系統處理了,因此這個過程系統會幫咱們處理好。
可是問題是,爲何咱們要用截圖的方式,而不是直接用兩個控制器的 View 來作動畫? 這麼作的緣由就是,噹噹前窗口有顯示 tabBar 的時候,tabBar 圖層是在動畫容器圖層之上的,因此咱們沒法優雅的作百分手勢驅動。因此採起這種方式。可是系統的 pop 手勢不是用截圖的形式,而是直接使用兩個控制器的View來作動畫,就像下面這樣,可是因爲權限問題,咱們不可能像系統那樣作,可是也不排除有同窗想到巧妙的辦法來實現。
下面看下動畫提供者的源碼:
- (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]; }]; } 複製代碼
到了這裏,基本上已經完成了 push 功能了。只須要在手勢結束的時候告訴系統,是 push 成功仍是失敗就能夠了。
if (progress > JPPushBorderlineDelta) {
[self.interactivePopTransition finishInteractiveTransition];
}
else {
[self.interactivePopTransition cancelInteractiveTransition];
}
複製代碼
注意: tabBar 的 translucent 默認爲 YES, 使用 JPNavigationCotroller 不能修改 tabBar 的透明屬性. 這是由於 Xcode 9 之後, 蘋果對導航控制器內部作了一些修改, 一旦將 tabBar 設爲不透明, 當前架構下的 UI 就會錯亂, 設置 tabBar 的 backgroundImage 爲不透明圖片, 或者設置 backgroundColor 爲不透明的顏色值也是同樣的會出錯.