独立导航器导航栏样式

引言

使用场景

1) 当应用在转场时两个页面的导航栏颜色不同; 如美团的“我的”页面—> 设置页面

2) 转场时前一页面隐藏了导航栏,后一页面显示导航栏;如Twitter“Me”页面 —> 一条Tweet纪录页面

在这两种情况下使用原生的转场效果视觉效果上并不是很友好,很多应用(稍微调查了下手机里使用该转场方式的应用:Mono, Twitter,网易云音乐, 美团,生辰,淘宝等)都是通过类似所示效果显示转场,并支持全屏侧滑Pop页面。

实现思路

主要有三种:

  1. 自定义导航栏视图: 导航器隐藏导航栏,通过addSubview自定义导航栏,灵活性比较高,缺点是改动很大,原生的很多API都无法使用,比如添加导航栏左右按钮,设置title等都需要自己重写支持。
  2. 截图覆盖: 通过在转场时,保存截图,覆盖原有页面。缺点是由于实现方案本身的局限性,push时无法添加交互手势换场,只支持pop交互换场。
  3. 包裹导航栏控制器: 在转场时,对导航栏控制器就行包裹,A—>B时,将导航栏控制器的导航栏隐藏,B则会通过导航栏控制器包裹,由于push时无法push导航栏控制器,因而在B的导航栏控制器外面用UIViewController包裹。从而实现push视图控制器,实现A,B页面的导航栏相互独立。ps: 该思路from jerry的这篇《用Reveal分析网易云音乐的导航控制器切换效果》

JYNavTransition–截图覆盖方式

_奔跑的炸鸡【iOS】让我们一次性解决导航栏的所有问题这篇文章解决了使用导航栏时的一些常见问题:包括自定义返回按钮,调整返回按钮位置,解决自定义返回按钮导致的侧滑手势失效问题,以及全屏侧滑转场效果。作者给出了实现思路并将全屏侧滑转场封装到KLTAnimateNav中,在跟着写的过程中发现代码中存在一些耦合问题,本着整洁代码的原则进行了重写。

代码地址:JYNavTransition

通过截图覆盖方式独立导航器中的viewControllers之间的导航栏样式,并支持全屏侧滑Pop 方式 ps: 欢迎拍砖交流

使用方式

将JYNavTransition文件夹拖入项目中,导航控制器改用JYNavigationController即可

1
2
3
FirstViewController *vc = [[FirstViewController alloc] init];
JYNavigationController *nav = [[JYNavigationController alloc] initWithRootViewController:vc];
self.window.rootViewController = nav;

特性

  • UINavigationController替换为JYNavigationController后,即可支持独立导航器导航栏样式
  • 支持开启或者禁用全屏侧滑手势;
  • push,pop,popToViewController,popToRoot等方法无需做额外操作。

实现细节

转场时通过截图覆盖原有的toView。需要注意的是需要在导航控制器所在的视图窗口上添加截图才可以遮盖原有的toView和fromView.

  1. push:在当前导航器的视图所在的窗口添加当前页面的截图,覆盖当前页面,通过UIView动画将fromView逐渐移出屏幕,动画时间为转场时间。
  2. pop:在当前导航器上添加上一页面的截图覆盖toView,与当前页面的截图覆盖fromView。通过UIView动画进行转场。
  3. 侧滑返回上一页面: 导航栏在push到下一页面时,添加侧滑手势,添加相应的手势动画。

NavController

ViewController转场

转场相关协议

自定义转场相关的API定义在UIViewControllerTransitioning.h中,相关协议为5个

1
2
3
4
5
6
7
8
9
10
11
12
// UIViewControllerTransitioning.h
// 1. 转场代理(required),提供动画控制器与交互控制器,设置展示VC和dismiss VC的动画控制器,并可以设置这两个场景的交互
@protocol UIViewControllerTransitioningDelegate <NSObject>
// 2. 转场上下文(required),提供转场过程中视图控制器等相关的属性,比如fromVC,toVC,containerView,fromVC,toVC开始或者结束的位置以及三个跟转场交互相关的方法
@protocol UIViewControllerContextTransitioning <NSObject>
// 3. 动画控制器(required),协议中必须设置转场所需时间以及转场时执行的动画
@protocol UIViewControllerAnimatedTransitioning <NSObject>
// 4. 交互(optional),控制交互
@protocol UIViewControllerInteractiveTransitioning <NSObject>
// UIViewControllerTransitionCoordinator.h
// 5. 转场协调(optional),协调转场动画与其它动画,可让两者并行执行
@protocol UIViewControllerTransitionCoordinator <UIViewControllerTransitionCoordinatorContext>

UIPercentDrivenInteractiveTransition

遵循UIViewControllerInteractiveTransitioning协议的一个类,可以用一个百分比控制交互转场的过程。

模态转场

Modal转场时识别是Present还是Dismiss

X1: containerView.subViews; X2: toVC.isBeingPresented; X3:fromVC.isBeingDismissed

fromView toView containerView对象 X1 X2 X3
A—> B A B 0x7f8eb945f830 A YES NO
B —> A B A 0x7f8eb945f830 B NO YES

因而可以通过ViewController的isBeingPresented和isBeingDismissed属性识别到底是Present还是dismiss场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext {
UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *fromView = fromVC.view;
UIView *toView = toVC.view;
// 转场发生的容器, A --> B 时则需在containerView中添加B视图
UIView *containerView = [transitionContext containerView];
CGRect finalToVC = [transitionContext finalFrameForViewController:toVC];// toVC切换结束后的frame
NSTimeInterval duration = [self transitionDuration:transitionContext];
// A(fromVC) --> B(toVC) 调用presentViewController 因而B的isBeingPresented为YES
if (toVC.isBeingPresented) {
[containerView addSubview:toView];// 必须将toView添加到containerView容器中,否则黑屏(containerView的背景颜色默认为黑色)。
toView.frame = CGRectOffset(finalToVC, 0, [[UIScreen mainScreen] bounds].size.height/2);// 偏移为Y值,因而B将从底下弹起,可以根据offset修改谈起的方向 finalToVC为最后的frame,{0,0,kAppWidth,kAppHeight}
[UIView animateWithDuration:duration
delay:0
usingSpringWithDamping:0.5
initialSpringVelocity:0.5
options:UIViewAnimationOptionCurveLinear
animations:^{
toView.frame = finalToVC;
} completion:^(BOOL finished) {
//动画完成或者取消之后必须得调用的方法,系统接收到这个消息后将对控制器的状态进行维护,参数为向这个context报告切换是否完成
[transitionContext completeTransition:YES];
}];
}
// B(fromVC) --> A(toVC) B调用了dismissViewControllerAnimated:completion 因而B的isBeingDismissed为YES
if (fromVC.isBeingDismissed) {
[containerView addSubview:toView];
[containerView sendSubviewToBack:toView];
[UIView animateWithDuration:duration
animations:^{
fromView.bounds = CGRectMake(0, 0, 1, fromView.frame.size.height);
} completion:^(BOOL finished) {
[transitionContext completeTransition:![transitionContext transitionWasCancelled]];
}];
}
}

了解转场时的视图结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext {
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
CGRect screenBounds = [[UIScreen mainScreen] bounds];
CGRect finalFrame = [transitionContext finalFrameForViewController:toVC];
toVC.view.frame = CGRectOffset(finalFrame, 0, screenBounds.size.height);
UIView *containerView = [transitionContext containerView];
// beforeAddSubview
[containerView addSubview:toVC.view];
// afterAddSubview
NSTimeInterval duration = [self transitionDuration:transitionContext];
[UIView animateWithDuration:duration
delay:0.0
usingSpringWithDamping:0.5
initialSpringVelocity:0.5
options:UIViewAnimationOptionCurveLinear
animations:^{
toVC.view.frame = finalFrame;
} completion:^(BOOL finished) {
// Into Completion
[transitionContext completeTransition:YES];
// afterCompleteTransition
}];
}

备注:表格中显示的是ContainerView.subviews。X1: beforeAddSubview; X2:afterAddSubview; X3: Into Completion; X4: afterCompleteTransition

X1 X2 X3 X4 异常
A —> B: (Custom) nil b b b
B—>A:(Custom)addSubview b a,b a,b a 白屏
B—>A(Custom)不addSubview / / b nil
A —> B:(Full) a a,b a,b b
B —> A:(Full)addSubview b b,a a,b nil
B —> A:(Full)不addSubview / / b nil 闪现白屏

Custom模式

Custom模式下containerView并没有担任父视图。Presentation后A视图并没有从视图结构中移除。在Dismissal过程中并不需要将A视图添加到containerView中,即可正常显示。如果添加到containerView,则会出现闪现A视图,然后出现白屏。

猜测:默认屏幕显示A视图,presentation后,添加containerView在A视图之上。 Dismissal过程中,只需将containerView从视图结构中移除即可正常显示。所以,当把A视图添加到containerView中,则在动画执行过程中会出现A视图,但一旦动画执行完毕,UIkit会将containerView移除,而本身在containerView底下的A视图由于添加到containerView中,因而屏幕上就没有A视图。出现白屏。

FullScreen模式

FullScreen模式下containerView担任父视图。 Presentation后A视图会从视图结构(containerView)中移除。dismissal过程中需要将A视图添加到containerView中。 但如果不主动添加也没什么事情,但dismiss的过程中会出现闪现白屏,然后再显示A视图。 猜测是: dismissal过程中,动画执行后执行completeTransition,则会将containerView中的子视图移除,此时屏幕上显示的是白屏。 当animateTransition代理执行完之后,判断containerView是否为空,如果为空,则添加toView到containerView上,因而会闪现A视图。

小结

Custom模式下,Dismissal时无需添加子视图到containerView.FullScreen模式下,Dismissal需要添加子视图到containerView。

导航器转场

Push时视图上方被遮盖

在使用过程中,主页面Push到B页面后,明明有3个cell,但在转场过程中只显示2个cell,结束后才显示3个。一开始是以为TableView有问题,后面才发现是转场动画有问题,cell少了一个并不是tableView没有正确显示,而是在转场过程中由于tableView的origin为(0,0),导致第一行cell被导航栏遮盖。

1
2
3
4
5
CGRect fromVStartFrame = [transitionContext initialFrameForViewController:fromViewController];
CGRect fromVEndFrame = [transitionContext finalFrameForViewController:fromViewController];
CGRect toVStartFrame = [transitionContext initialFrameForViewController:toViewController];
CGRect toVEndFrame = [transitionContext finalFrameForViewController:toViewController];

以下为转场过程中通过转场上下文获取的FromVStartFrame,fromVEndFrame,toVStartFrame,toVEndFrame。Push时fromVCStartFrame的y为64是因为A页面有导航栏,高度为455是因为A页面有导航栏与TabBar,因而页面高度需减去导航栏与TabBar的高度。toEndFrame的高度为504是因为B页面无TabBar。

fromVStartFrame toVStartFrame fromVEndFrame toVEndFrame
A—>B(present) {{0, 0}, {320, 568}} {{0, 0}, {0, 0}} {{0, 0}, {0, 0}} {{0, 0}, { 320, 568}}
B—>A(dismiss) {{0, 0}, { 320, 568}} {{0, 0}, {0, 0}} {{0, 0}, { 320, 568}} {{0, 0}, { 320, 568}}
A—>B(Push) {{0, 64}, { 320, 455}} {{0, 0}, {0, 0}} {{0, 0}, {0, 0}} {{0, 64}, { 320, 504}}
B—>A(Pop) {{0, 64}, { 320, 504}} {{0, 0}, {0, 0}} {{0, 0}, {0, 0}} {{0, 64}, { 320, 455}}

之前在实现JYNavTransition时由于测试的视图直接设置背景色为单色,没有发现在Push转场时需将toVC.view的位置设为toVEndFrame。修改后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// from JYNavTransition
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext {
UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
self.navigationController = (JYNavigationController *)toViewController.navigationController;
[[transitionContext containerView] addSubview:toViewController.view];
UIImageView *fromImageView = [[UIImageView alloc] initWithFrame:[UIScreen mainScreen].bounds];
if (self.navigationControllerOperation == UINavigationControllerOperationPush) {
CGRect toViewEndFrame = [transitionContext finalFrameForViewController:toViewController];
CGRect toViewStartFrame = toViewEndFrame;
// 设置toVC.view.frame为toVEndFrame
toViewController.view.frame = toViewStartFrame;
fromImageView.image = self.navigationController.screenShots.lastObject;
[self.navigationController.view.window insertSubview:fromImageView atIndex:0];
self.navigationController.view.transform = CGAffineTransformMakeTranslation(kAppWidth, 0);
[UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
self.navigationController.view.transform = CGAffineTransformMakeTranslation(0, 0);
fromImageView.center = CGPointMake(-kAppWidth/2, kAppHeight/2);
} completion:^(BOOL finished) {
[fromImageView removeFromSuperview];
[transitionContext completeTransition:YES];
}];
} else {
// .... Pop时转场动画
}
}

参考资料

WWDC 2013 Session笔记 - iOS7中的ViewController切换—Onev // 自定义Modal转场Demo

View Controller 转场—Objc中国 // 自定义导航栏控制器转场Demo

iOS 视图控制器转场详解

【iOS】让我们一次性解决导航栏的所有问题

用Reveal分析网易云音乐的导航控制器切换效果

相关开源项目

KLTAnimateNav // 通过截图方式实现,支持全屏返回

JTNavigationController // 通过包裹导航栏控制器实现,支持全屏返回

FDFullscreenPopGesture // 使用原生的 UINavigationController,在 - (void)viewWillAppear 中做处理,支持全屏返回

RTRootNavigationController // 细节完善的比较好,支持unwind,Interface Bulider