声明:我为这个框架写了四篇文章: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 为不透明的颜色值也是同样的会出错.