專案概述
- iOS中最常見的動畫無疑是Push和Pop的轉場動畫了,其次是Present和Dismiss的轉場動畫。
如果我們想自定義這些轉場動畫,蘋果其實提供了相關的API,在自定義轉場之前,我們需要了解轉場原理和處理邏輯。下面是自定義轉場的效果:
- 專案地址:CustomPushAndPresent
如果文章和專案對你有幫助,還請給個Star⭐️,你的Star⭐️是我持續輸出的動力,謝謝啦?
Push/Pop轉場
Push/Pop轉場原理
- 在呼叫導航控制器的pushViewController:animated:之前,如果設定了導航控制器的delegate物件,就會呼叫delegate物件的回撥方法
navigationController:animationControllerForOperation:fromViewController:toViewController:
,可在該回撥方法中自定義轉場,該回撥方法需要返回一個遵守UIViewControllerAnimatedTransitioning協議的物件,定義一個類實現UIViewControllerAnimatedTransitioning協議的兩個方法以便自定義Push/Pop轉場,這兩個必須實現的方法如下:
- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext;
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext;
- 用runtime給UIViewController提供一個屬性hr_addTransitionFlag,用於標記是否新增自定義轉場。程式碼如下:
@interface UIViewController (TransitionProperty)
@property (nonatomic, assign) BOOL hr_addTransitionFlag;//是否新增自定義轉場
@end
#import "UIViewController+TransitionProperty.h"
#import <objc/runtime.h>
static NSString *hr_addTransitionFlagKey = @"hr_addTransitionFlagKey";
@implementation UIViewController (TransitionProperty)
- (void)setHr_addTransitionFlag:(BOOL)hr_addTransitionFlag {
objc_setAssociatedObject(self, &hr_addTransitionFlagKey, @(hr_addTransitionFlag), OBJC_ASSOCIATION_ASSIGN);
}
- (BOOL)hr_addTransitionFlag {
return [objc_getAssociatedObject(self, &hr_addTransitionFlagKey) integerValue] == 0 ? NO : YES;
}
@end
上面說過只要給導航控制器設定delegate,則呼叫pushViewController:animated:後,就會執行navigationController:animationControllerForOperation:fromViewController:toViewController:方法,從而展示自定義的Push/Pop轉場,呼叫popViewControllerAnimated:後同理。導航控制器的程式碼如下:
-(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated{
/*給導航控制器設定了delegate,呼叫pushViewController:animated:後,
會去執行navigationController:animationControllerForOperation:fromViewController:toViewController:
*/
self.delegate = (id)viewController;
[super pushViewController:viewController animated:animated];
}
-(UIViewController *)popViewControllerAnimated:(BOOL)animated{
/*給導航控制器設定了delegate,呼叫popViewControllerAnimated:後,
會去執行navigationController:animationControllerForOperation:fromViewController:toViewController:
*/
self.delegate = self.viewControllers.lastObject;
return [super popViewControllerAnimated:animated];
}
自定義轉場
- 這裡自定義一種Push時toView從螢幕頂部往下移動到螢幕中央的轉場,Pop時toView從螢幕中央往下移出螢幕的轉場。實現程式碼如下:
#import <UIKit/UIKit.h>
@interface HRPushAnimatedTransitioning : NSObject <UIViewControllerAnimatedTransitioning,CAAnimationDelegate>
@property(nonatomic, assign)UINavigationControllerOperation operation;
@end
@implementation HRPushAnimatedTransitioning
-(NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext{
return 0.4;
}
-(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext{
UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
//Push/Pop的containerView預設有一個子檢視fromView
UIView *containerView = transitionContext.containerView;
NSLog(@"Push/Pop containerView: %@", containerView.subviews);
//containerView本來有fromView,只需新增toView
[containerView addSubview:toView];
CGRect fromViewStartFrame = [transitionContext initialFrameForViewController:fromVC];
CGRect toViewStartFrame = [transitionContext finalFrameForViewController:toVC];
CGRect fromViewEndFrame = fromViewStartFrame;
CGRect toViewEndFrame = toViewStartFrame;
if (_operation == UINavigationControllerOperationPush) {
toViewStartFrame.origin.y -= toViewEndFrame.size.height;
}else if (_operation == UINavigationControllerOperationPop) {
fromViewEndFrame.origin.y += fromViewStartFrame.size.height;
[containerView sendSubviewToBack:toView];
}
fromView.frame = fromViewStartFrame;
toView.frame = toViewStartFrame;
[UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
fromView.frame = fromViewEndFrame;
toView.frame = toViewEndFrame;
} completion:^(BOOL finished) {
[transitionContext completeTransition:!transitionContext.transitionWasCancelled];
}];
}
處理系統的右滑返回手勢
- iOS7開始蘋果提供了一個滑動返回上一介面的手勢,由於我在pushViewController:animated:方法中設定了導航控制器的delegate,導致右滑返回手勢失效,解決方式是重新設定右滑返回手勢的delegate物件:
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
__weak typeof(self) weakself = self;
if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
/*只要自定義navigationItem的leftBarButtonItem或navigationController,滑動手勢會失效。
因此要重新設定系統自帶的右滑返回手勢的代理為self
*/
self.interactivePopGestureRecognizer.delegate = weakself;
}
}
以上設定後,rootViewController也會響應右滑返回,可能導致一些問題,因此需要禁止rootViewController的右滑返回功能。即導航控制器中的程式碼如下:
#pragma mark - UIGestureRecognizerDelegate
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
if (gestureRecognizer == self.interactivePopGestureRecognizer) {
//遮蔽rootViewController的滑動返回手勢,避免右滑返回手勢引起當機問題
if (self.viewControllers.count <= 1 || self.visibleViewController == [self.viewControllers objectAtIndex:0]) {
return NO;
}
}
return YES;
}
注意右滑返回手勢預設是啟用的,即self.interactivePopGestureRecognizer的enable預設是YES
處理右滑返回手勢的轉場
- 上面雖然實現了自定義Push/Pop轉場,但是用系統自帶滑動手勢pop時並沒有展示我們自定義的Push/Pop轉場效果,展示的依然是系統預設的轉場效果。
原因是當自定義了Push or Pop的轉場,系統呼叫navigationController:animationControllerForOperation:fromViewController:toViewController:方法,該方法如果返回的是非nil物件後,就會執行以下代理方法:
-(id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController
這是蘋果提供給開發者自定義滑動手勢互動轉場的代理方法,返回一個遵守UIViewControllerInteractiveTransitioning協議的物件,該物件需要實現startInteractiveTransition:方法,為此蘋果提供了一個實現該協議的UIPercentDrivenInteractiveTransition類,我們只需定義一個繼承UIPercentDrivenInteractiveTransition類的類,就能滿足返回物件的條件,而不需要是實現startInteractiveTransition:方法。
由於當navigationController:animationControllerForOperation:fromViewController:toViewController返回的物件非nil時,Push和Pop都會回撥navigationController:interactionControllerForAnimationController:
代理方法,而我們重寫該代理方法只是針對右滑返回手勢的轉場,其他情況返回nil,因此需要區分push還是pop。解決方式是在navigationController:animationControllerForOperation:fromViewController:toViewController中儲存當前是push還是pop。程式碼如下:
//用於自定義Push or Pop的轉場
//返回值非nil表示使用自定義的Push or Pop轉場。nil表示使用系統預設的轉場
-(id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC{
if (!self.hr_addTransitionFlag) {
return nil;
}
HRPushAnimatedTransitioning *obj = [[HRPushAnimatedTransitioning alloc] init];
obj.operation = operation;
_operation = operation;
if (operation == UINavigationControllerOperationPush) {
// NSLog(@"_interactive:%@--%@", _interactive, self);
if (_interactive == nil) {
_interactive = [[HRPercentDrivenInteractiveTransition alloc] init];
}
[_interactive addGestureToViewController:self];
}
return obj;
}
//使用自定義的Push or Pop轉場才會回撥該方法,用於自定義滑動手勢的轉場互動方式
//返回值非nil表示可互動處理轉場進度。nil表示無法互動處理轉場進度,直接完成轉場
-(id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController{
if (_operation == UINavigationControllerOperationPush) {
return nil;
}else{
if (_interactive.canInteractive) {
return _interactive;
}else{
return nil;
}
}
}
實現自定義右滑返回手勢的轉場
- HRPercentDrivenInteractiveTransition類的邏輯是:給控制器view新增Pan手勢,當右滑時,計算右滑佔螢幕寬度的百分比percent(可認為是轉場進度引數),然後在右滑開始時,呼叫導航控制器的popViewControllerAnimated:。滑動過程中呼叫updateInteractiveTransition:,傳入轉場進度引數percent。轉場結束時根據轉場進度,判斷是呼叫finishInteractiveTransition(轉場完成,即成功pop到上一介面)還是cancelInteractiveTransition(轉場恢復到起點)。最終程式碼如下:
#import <UIKit/UIKit.h>
//UIPercentDrivenInteractiveTransition實現UIViewControllerInteractiveTransitioning協議
@interface HRPercentDrivenInteractiveTransition : UIPercentDrivenInteractiveTransition
@property (readonly, assign, nonatomic) BOOL canInteractive;
-(void)addGestureToViewController:(UIViewController *)vc;
@end
@interface HRPercentDrivenInteractiveTransition ()
@property (nonatomic, weak) UINavigationController *nav;
@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, assign) CGFloat percent;
@end
@implementation HRPercentDrivenInteractiveTransition
-(void)addGestureToViewController:(UIViewController *)vc{
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panAction:)];
[vc.view addGestureRecognizer:pan];
self.nav = vc.navigationController;
}
-(void)panAction:(UIPanGestureRecognizer *)pan{
_percent = 0.0;
CGFloat totalWidth = pan.view.bounds.size.width;
CGFloat x = [pan translationInView:pan.view].x;
_percent = x/totalWidth;
switch (pan.state) {
case UIGestureRecognizerStateBegan:{
_canInteractive = YES;
[_nav popViewControllerAnimated:YES];
}
break;
case UIGestureRecognizerStateChanged:{
[self updateInteractiveTransition:_percent];
}
break;
case UIGestureRecognizerStateEnded:{
_canInteractive = NO;
[self continueAction];
}
break;
default:
break;
}
}
-(BOOL)isCanInteractive{
return _canInteractive;
}
- (void)continueAction{
if (_displayLink) {
return;
}
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(UIChange)];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)UIChange {
CGFloat timeDistance = 1.5/60;
if (_percent > 0.4) {
_percent += timeDistance;
}else {
_percent -= timeDistance;
}
[self updateInteractiveTransition:_percent];
if (_percent >= 1.0) {
//轉場完成
[self finishInteractiveTransition];
[_displayLink invalidate];
_displayLink = nil;
}
if (_percent <= 0.0) {
//轉場取消
[self cancelInteractiveTransition];
[_displayLink invalidate];
_displayLink = nil;
}
}
Present/Dismiss轉場
Present/Dismiss轉場原理
- 控制器設定transitioningDelegate為自身,遵守UIViewControllerTransitioningDelegate協議,實現協議的present動畫方法和dismiss動畫方法,即如下兩個方法:
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed;
這兩個方法需要返回一個遵守UIViewControllerAnimatedTransitioning協議的物件,定義一個類實現UIViewControllerAnimatedTransitioning協議的兩個方法以便自定義Present/Dismiss轉場。
控制器關鍵程式碼如下:
- (instancetype)init
{
self = [super init];
if (self) {
self.transitioningDelegate = self;
}
return self;
}
//present過渡動畫(非互動)
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source{
HRPresentAnimatedTransitioning *obj = [[HRPresentAnimatedTransitioning alloc] initType:PictureTransitionPresent];
return obj;
}
//dismiss過渡動畫(非互動)
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed{
HRPresentAnimatedTransitioning *obj = [[HRPresentAnimatedTransitioning alloc] initType:PictureTransitionDismiss];
return obj;
}
自定義轉場
- 這裡自定義一種Present時toView從螢幕左邊往右移動到螢幕中央的轉場,dismiss時toView從螢幕中央往右移出螢幕的轉場。實現程式碼如下:
typedef NS_ENUM(NSInteger,PictureTransitionType) {
PictureTransitionPresent = 0,//顯示
PictureTransitionDismiss //消失
};
@interface HRPresentAnimatedTransitioning : NSObject <UIViewControllerAnimatedTransitioning>
- (instancetype)initType:(PictureTransitionType)type;
@end
#import "HRPresentAnimatedTransitioning.h"
@interface HRPresentAnimatedTransitioning ()
@property(nonatomic, assign)PictureTransitionType type;
@end
@implementation HRPresentAnimatedTransitioning
- (instancetype)initType:(PictureTransitionType)type{
self = [super init];
if (self) {
_type = type;
}
return self;
}
-(NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext{
return 0.4;
}
-(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext{
//present時,fromVC是導航控制器,toVC是HRDetailViewController。dismiss時,fromVC是HRDetailViewController,toVC是導航控制器
UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
//Present/Dismiss的containerView預設沒有子檢視
UIView *containerView = transitionContext.containerView;
// NSLog(@"Present/Dismiss containerView:%@", containerView.subviews);
CGRect fromViewStartFrame = [transitionContext initialFrameForViewController:fromVC];
CGRect toViewStartFrame = [transitionContext finalFrameForViewController:toVC];
CGRect fromViewEndFrame = fromViewStartFrame;
CGRect toViewEndFrame = toViewStartFrame;
if (_type == PictureTransitionPresent) {
[containerView addSubview:toView];
toViewStartFrame.origin.x -= toViewEndFrame.size.width;
}else if (_type == PictureTransitionDismiss) {
fromViewEndFrame.origin.x += fromViewStartFrame.size.width;
}
fromView.frame = fromViewStartFrame;
toView.frame = toViewStartFrame;
[UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
fromView.frame = fromViewEndFrame;
toView.frame = toViewEndFrame;
} completion:^(BOOL finished) {
[transitionContext completeTransition:!transitionContext.transitionWasCancelled];
}];
}
@end