在默认情况下,导航栏返回按钮长这个样子
导航栏默认返回按钮导航栏左上角的返回按钮,其文本默认为上一个ViewController的标题,如果上一个ViewController没有标题,则为Back(中文环境下为“返回”)。
在默认情况下,导航栏返回的点击交互和滑动交互如下
默认导航栏交互这些东西不需要任何设置和操作,因此也没有其他需要说明的地方。
绝大多数情况下,我们都需要根据产品需求自定义左上角的返回按钮,虽然这对大多数开发者来说不是什么难事,但依然有几个问题值得注意。
替换返回按钮非常简单,只需要在ViewController中创建一个UIBarButtonItem和一张图片,并为按钮添加相应的点击事件即可,代码如下
- (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. UIButton * leftBtn = [UIButton buttonWithType:UIButtonTypeSystem]; leftBtn.frame = CGRectMake(0, 0, 25,25); [leftBtn setBackgroundImage:[UIImage imageNamed:@"nav_back"] forState:UIControlStateNormal]; [leftBtn addTarget:self action:@selector(leftBarBtnClicked:) forControlEvents:UIControlEventTouchUpInside]; self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]initWithCustomView:leftBtn]; } - (void)leftBarBtnClicked:(UIButton *)btn { [self.navigationController popViewControllerAnimated:YES]; }我们来看一眼效果
替换返回按钮我们可以看到,上面的按钮是有点偏右的,那如果我们想调整按钮的位置该怎么做呢?设置Frame显然是行不通的,因为导航栏的NavigationItem是个比较特殊的View,我们无法通过简单的调整Frame来的调整左右按钮的位置。但是在苹果提供的UIButtonBarItem 中有个叫做UIBarButtonSystemItemFixedSpace的控件,利用它,我们就可以轻松调整返回按钮的位置。具体使用方法如下
//创建返回按钮 UIButton * leftBtn = [UIButton buttonWithType:UIButtonTypeSystem]; leftBtn.frame = CGRectMake(0, 0, 25,25); [leftBtn setBackgroundImage:[UIImage imageNamed:@"icon_back"] forState:UIControlStateNormal]; [leftBtn addTarget:self action:@selector(leftBarBtnClicked:) forControlEvents:UIControlEventTouchUpInside]; UIBarButtonItem * leftBarBtn = [[UIBarButtonItem alloc]initWithCustomView:leftBtn];; //创建UIBarButtonSystemItemFixedSpace UIBarButtonItem * spaceItem = [[UIBarButtonItem alloc]initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace target:nil action:nil]; //将宽度设为负值 spaceItem.width = -15; //将两个BarButtonItem都返回给NavigationItem self.navigationItem.leftBarButtonItems = @[spaceItem,leftBarBtn];我们来看一眼效果
调整返回按钮位置可以看到,我们的返回按钮已经紧靠着屏幕边缘。
这个方法同样适用于调整导航栏右侧的按钮
如果使用自定义的按钮去替换系统默认返回按钮,会出现滑动返回手势失效的情况。解决方法也很简单,只需要重新添加导航栏的interactivePopGestureRecognizer的delegate即可。 首先为ViewContoller添加UIGestureRecognizerDelegate协议
然后设置代理
self.navigationController.interactivePopGestureRecognizer.delegate = self;至此,我们已经将返回按钮替换为我们的自定义按钮,并使滑动返回重新生效。接下来,我们继续来解决交互上的问题。
这个一个很常见的需求,网上解决方案也很多,这里将本人常用的方法贴到这里。仅供参考 实现全屏滑动返回仅需在导航栏给导航栏添加UIGestureRecognizerDelegate协议,并在ViewDidLoad中写入如下代码
// 获取系统自带滑动手势的target对象 id target = self.interactivePopGestureRecognizer.delegate; // 创建全屏滑动手势,调用系统自带滑动手势的target的action方法 UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:target action:@selector(handleNavigationTransition:)]; // 设置手势代理,拦截手势触发 pan.delegate = self; // 给导航控制器的view添加全屏滑动手势 [self.view addGestureRecognizer:pan]; // 禁止使用系统自带的滑动手势 self.interactivePopGestureRecognizer.enabled = NO;我们来看一眼效果(注意鼠标位置)
全屏滑动返回.gif成功
这种方法的原理其实很简单,其实就是自定义一个全屏滑动手势,并将滑动事件设置为系统滑动事件,然后禁用系统滑动手势即可。handleNavigationTransition就是系统滑动的方法,虽然系统并未提供接口,但是我们我们可以通过runtime找到这个方法,因此直接调用即可。两位,不必担心什么私有API之类的问题,苹果如果按照方法名去判断是否使用私有API,那得误伤多少App。
本部分文字代码都较多,不想看这么多废话的同学请直接翻到末尾,文末附有下载地址,导入项目后,继承即可生效。
在改变了导航栏样式,实现了全屏滑动返回之后,我们有了一个看起来还不错的导航栏。但是我们滑动时的切换依然是系统自带的动画,如果遇到前一个界面的NavigationBar为透明或前后两个Bar颜色不一样,这种渐变式的动画看起来就会不太友好,尤其当前后两个界面其中一个界面的NavigationBar为透明或隐藏时,其效果更是惨不忍睹。
这个问题,其实很多App,比如天猫、美团等都通过一种“整体返回”的效果来解决这个问题。效果如下:
整体滑动返回这种解决方案等于将两个NavigationBar独立开来,因此可以相对完美的解决导航栏滑动切换中的种种Bug。 接下来,我们来看看如何实现这种效果。
以我个人的认知,实现这个效果有三种基本思路:
使用UINavigationController自带的setNavigationBarHidden: animated:方法来实现,每次push或pop时,在当前控制器的viewWillDisappear:中设置隐藏,在要跳转的控制器的viewWillAppear:中设置导航栏显示。在每次Push前对当前页面进行截图并保存到数组,Pop时取数组最后一个元素显示,滑动结束后调用系统Pop方法并删除最后一张截图。使用iOS 7之后开放的,UIViewControllerAnimatedTransitioning协议,来实现自定义导航栏转场动画及交互。以上三种方法,方法一十分繁琐,而且会有很多莫名其妙的BUG,直接pass。
在iOS的交互中,push一般通过按钮的点击事件或View的tap事件触发,而pop则可能通过事件触发,也可能通过右滑手势触发。因此,我们将这个我们要实现的动画效果分为交互效果和无交互效果两种,下面我们将使用方法2和方法3提供的思路,分别实现这两种效果,这样就能较为完美的解决Push和Pop的动画问题。
}
在一开始基本原理地方,我们说过pop时要删除最后一张截图,用来保证数组中的最后一张截图是上一个控制器,但是很多情况下我们可能调用的是导航栏的popToViewController: animated:方法或popToRootViewControllerAnimated:来返回,这种情况下,我们删除的可能就不是一张截图,因此我们需要分别重写这些Pop方法,去确定我们要删除多少张图片,代码如下
- (UIViewController *)popViewControllerAnimated:(BOOL)animated { [_screenshotImgs removeLastObject]; return [super popViewControllerAnimated:animated]; } - (NSArray<UIViewController *> *)popToViewController:(UIViewController *)viewController animated:(BOOL)animated { for (NSInteger i = self.viewControllers.count - 1; i > 0; i--) { if (viewController == self.viewControllers[i]) { break; } [_screenshotImgs removeLastObject]; } return [super popToViewController:viewController animated:animated]; } - (NSArray<UIViewController *> *)popToRootViewControllerAnimated:(BOOL)animated { [_screenshotImgs removeAllObjects]; return [super popToRootViewControllerAnimated:animated]; }在上面代码中,我们使用的是侧滑手势,并将相应区域设置为屏幕左侧。 之所以不用全屏滑动,是因为全屏滑动手势在有些时候会和其他手势冲突,如果冲突的是我们自定义的手势,自然好解决,但如果是系统手势,如TableView的左滑菜单操作,这个事情就很蛋疼的。 但是如果必须要做全屏滑动手势的话,我们可以对代码稍作修改,某些控制器中屏蔽手势。
首先给导航栏添加禁用名单数组并配置
... @property(nonatomic,copy)NSArray * forbiddenArray; ... - (void)viewDidLoad { [super viewDidLoad]; //原来代码 ... //将手势禁用,之后在Push时根据条件开启 self.panGestureRec.enabled = enable //将需要禁用手势的控制器的类名加到这个数组 self.forbiddenArray = @[@"SCViewController",@"ManageAddressViewController"]; } - (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated { //在指定控制器中禁用手势 解决滑动返回手势和某些手势冲突问题 BOOL enable = YES; for (NSString * string in self.forbiddenArray) { NSString * className = NSStringFromClass([viewController class]); if ([string isEqualToString:className]) { enable = NO; } } self.panGestureRec.enabled = enable; //原有代码 ... } - (UIViewController *)popViewControllerAnimated:(BOOL)animated { NSInteger count = self.viewControllers.count; NSString * className = nil; if (count >= 2) { className = NSStringFromClass([self.viewControllers[count -2] class]); } BOOL enable = YES; for (NSString * string in self.forbiddenArray) { if ([string isEqualToString:className]) { enable = NO; } } self.panGestureRec.enabled = enable; //原有代码 ... return [super popViewControllerAnimated:animated]; }到了这里,我们已经完成了交互式的切换动画,效果跟开头一样,就不再截图。接下来我们来解决另一个大Boss-非交互式动画
这里我们就要用到之前说的UIViewControllerAnimatedTransitioning来实现。限于篇幅,这里不再详细介绍这部分的基础知识,大家可以移步这两篇博客做一个初步的了解
向 UINavigationController 的传统动画说”再见” — 自定义过场动画(一) iOS 7:自定义导航转场动画以及更多
注:FromVC代表即将消失的视图控制器,ToVC表示将要展示的视图控制器
我们要实现的效果: Push的时候,FromVC往左移动,ToVC从屏幕右侧出现跟随FromVC左移直至FromVC消失,此时ToVC刚好完整显示在屏幕上。 Pop的时候,FromVC向右移动,ToVC从屏幕边缘出现跟随FromVC向右移动直至FromVC消失,此时ToVC刚好完整显示在屏幕上
实现的时候,我们依然需要将Push和Pop分开讨论 先说Pop 1.和交互式动画一样,每次Push时对屏幕截屏并保存,Pop的再次截屏但不保存 2.把Pop时截取的图片作为FromVC展示,把Push到这个界面时截取的图片作为ToVC展示 3.并对两张图片做位移动画,动画结束后移除两张图片
然后是Push 1.Push时先对当前屏幕截屏。 2.将截取的图片保存方便Pop回来时使用,并把这张图片作为这次Push的FromVC保存。 3.获取当前导航栏控制器对象,调整其Transform属性中的位移参数作为ToVC展示 4.对截图和导航栏做位移,动画结束后直接移除截屏图片
首先,在Push结束之前,我们是无法知道ToVC具体是什么样子,系统的截屏方法对于未加载出来的View是无能为力的,而UIView的 snapshotViewAfterScreenUpdates:方法又无法带着导航栏一起映射到一个新的View上,因此视觉效果很差。 正好在Pop的时候,为了达到想要的动画效果,用来展示的两张图片都需要放到导航栏的View上,因此在Push的时候我们就直接将导航栏的View做一个放射变换,当然,这也就意味着,当我们Push的时候,截屏就不能再放到导航栏上,而是应该放到它的“更上一层“ -- UITabbarController的View上
根据上述实现原理,我们可以知道,我们的主要工作重点在于打造一个合适的动画控制器。更准确的说,我们需要实现的细节都在UIViewControllerAnimatedTransitioning中,由于之前解释的很详细,这里我直接贴上相应代码供参考
-(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext { UIImageView * screentImgView = [[UIImageView alloc]initWithFrame:CGRectMake(0, 0, ScreenWidth, ScreenHeight)]; UIImage * screenImg = [self screenShot]; screentImgView.image =screenImg; //取出fromViewController,fromView和toViewController,toView UIViewController * fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; // UIView * fromView = [transitionContext viewForKey:UITransitionContextFromViewKey]; UIViewController * toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; UIView * toView = [transitionContext viewForKey:UITransitionContextToViewKey]; CGRect fromViewEndFrame = [transitionContext finalFrameForViewController:fromViewController]; fromViewEndFrame.origin.x = ScreenWidth; CGRect fromViewStartFrame = fromViewEndFrame; CGRect toViewEndFrame = [transitionContext finalFrameForViewController:toViewController]; CGRect toViewStartFrame = toViewEndFrame; UIView * containerView = [transitionContext containerView]; if (self.navigationOperation == UINavigationControllerOperationPush) { [self.screenShotArray addObject:screenImg]; //toViewStartFrame.origin.x += ScreenWidth; [containerView addSubview:toView]; toView.frame = toViewStartFrame; UIView * nextVC = [[UIView alloc]initWithFrame:CGRectMake(ScreenWidth, 0, ScreenWidth, ScreenHeight)]; //[nextVC addSubview:[toView snapshotViewAfterScreenUpdates:YES]]; [self.navigationController.tabBarController.view insertSubview:screentImgView atIndex:0]; //[self.navigationController.tabBarController.view addSubview:nextVC]; nextVC.layer.shadowColor = [UIColor blackColor].CGColor; nextVC.layer.shadowOffset = CGSizeMake(-0.8, 0); nextVC.layer.shadowOpacity = 0.6; self.navigationController.view.transform = CGAffineTransformMakeTranslation(ScreenWidth, 0); [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ //toView.frame = toViewEndFrame; self.navigationController.view.transform = CGAffineTransformMakeTranslation(0, 0); screentImgView.center = CGPointMake(-ScreenWidth/2, ScreenHeight / 2); //nextVC.center = CGPointMake(ScreenWidth/2, ScreenHeight / 2); } completion:^(BOOL finished) { [nextVC removeFromSuperview]; [screentImgView removeFromSuperview]; [transitionContext completeTransition:YES]; }]; } if (self.navigationOperation == UINavigationControllerOperationPop) { fromViewStartFrame.origin.x = 0; [containerView addSubview:toView]; UIImageView * lastVcImgView = [[UIImageView alloc]initWithFrame:CGRectMake(-ScreenWidth, 0, ScreenWidth, ScreenHeight)]; lastVcImgView.image = [self.screenShotArray lastObject]; screentImgView.layer.shadowColor = [UIColor blackColor].CGColor; screentImgView.layer.shadowOffset = CGSizeMake(-0.8, 0); screentImgView.layer.shadowOpacity = 0.6; [self.navigationController.tabBarController.view addSubview:lastVcImgView]; [self.navigationController.tabBarController.view addSubview:screentImgView]; // fromView.frame = fromViewStartFrame; [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ screentImgView.center = CGPointMake(ScreenWidth * 3 / 2 , ScreenHeight / 2); lastVcImgView.center = CGPointMake(ScreenWidth/2, ScreenHeight/2); //fromView.frame = fromViewEndFrame; } completion:^(BOOL finished) { //[self.navigationController setNavigationBarHidden:NO]; [lastVcImgView removeFromSuperview]; [screentImgView removeFromSuperview]; [self.screenShotArray removeLastObject]; [transitionContext completeTransition:YES]; }]; } } - (void)removeLastScreenShot { [self.screenShotArray removeLastObject]; } - (UIImage *)screenShot { // 将要被截图的view,即窗口的根控制器的view(必须不含状态栏,默认ios7中控制器是包含了状态栏的) UIViewController *beyondVC = self.navigationController.view.window.rootViewController; // 背景图片 总的大小 CGSize size = beyondVC.view.frame.size; // 开启上下文,使用参数之后,截出来的是原图(YES 0.0 质量高) UIGraphicsBeginImageContextWithOptions(size, YES, 0.0); // 要裁剪的矩形范围 CGRect rect = CGRectMake(0, 0, ScreenWidth, ScreenHeight); //注:iOS7以后renderInContext:由drawViewHierarchyInRect:afterScreenUpdates:替代 [beyondVC.view drawViewHierarchyInRect:rect afterScreenUpdates:NO]; // 从上下文中,取出UIImage UIImage *snapshot = UIGraphicsGetImageFromCurrentImageContext(); // 千万记得,结束上下文(移除栈顶的基于当前位图的图形上下文) UIGraphicsEndImageContext(); // 返回截取好的图片 return snapshot; }注:removeLastScreenShot需要在使用滑动手势Pop后调用,用来清除动画控制器中保存的截图,否则当交互式和非交互式动画交替使用时,会出现截图混乱的问题。
我们将动画持续时间调制两秒,观察一下效果
完成效果.gif这篇文章开始于四个月之前,中间由于个人以及工作原因拖了又拖,终于在最近补完,逻辑混乱之处请见谅。
制作完成的导航栏和动画控制器的下载地址 导航栏和动画控制器下载地址 使用方法: 1.将这四个文件导入工程 2.将需要动画的导航栏继承KLTNavigationController即可