如何一行代码完成页面自定义跳转

1,047 阅读6分钟

前言

这篇博文主要是记录一些实现思路,顺便将平时实现转场动画时遇到的一些问题和细节整理下来,也方便巩固下知识。在这里作为示例的转场动画也是目前项目中有使用到的,如果后续还有新的转场方式也会放在这里。

使用

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    MERSecondViewController *controller = [[MERSecondViewController alloc] init];
    CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
    CGFloat screenHeight = [UIScreen mainScreen].bounds.size.height;
    [tableView deselectRowAtIndexPath:indexPath animated:YES];

    // Action Sheet
    if (indexPath.row == MERPresentationAnimationTypeActionSheet) {
        controller.mer_viewSize = CGSizeMake(screenWidth / 6.0 * 5, screenHeight / 5.0 * 2);
        [self presentActionSheetViewController:controller animated:YES completion:nil];

        
    // 侧边滑入
    } else if (indexPath.row == MERPresentationAnimationTypeSlider) {
        controller.mer_viewSize = CGSizeMake(screenWidth / 3.0 * 2, screenHeight);
        [self presentSliderViewController:controller direction:MERSlidePresentationDirectionLeft animated:YES completion:nil];
        
    // 淡入淡出
    } else if (indexPath.row == MERPresentationAnimationTypeFade) {
        [self presentFadePatternViewController:controller animated:YES completion:nil];
        
    // 点扩散
    } else if (indexPath.row == MERPresentationAnimationTypeDiffuse) {
        [self presentDiffuseViewController:controller startPoint:_clickView.lastClickPoint animated:YES completion:nil];
        
    }
}

示例代码在这里

转场的实现步骤

关于 iOS 转场动画的详细解读,可以参考这两篇文章:王巍写的 ViewController切换 和唐巧写的 iOS 视图控制器转场详解 。篇幅较长,写的非常详细。

1.实现转场代理

  • UIViewController 的转场代理为 transitioningDelegate 属性,遵循 <UIViewControllerTransitioningDelegate> 协议;
  • UINavigationController 的转场代理为 delegate 属性,遵循 <UINavigationControllerDelegate> 协议;
  • UITabBarController 的转场代理为 delegate 属性,遵循 <UITabBarControllerDelegate> 协议;

因此我们只需要新建一个代理类,遵循并实现相应的代理,然后赋值给对应的代理属性即可。

其中 Present 转场需要给被 present 出来的 UIViewController 设置代理,实现 present 和 dismiss 的自定义动画。 Push 转场则需要给导航控制器 UINavigationController 设置代理,需要注意设置代理后如果只为限定的 VC 采用自定义动画,需要在代理的实现方法中做区分才行。
Tabbar 转场同理。

本篇主要以 Present 转场举例,下面是 <UIViewControllerTransitioningDelegate> 需要实现的方法

- (UIPresentationController *)presentationControllerForPresentedViewController:(UIViewController *)presented presentingViewController:(UIViewController *)presenting sourceViewController:(UIViewController *)source {
    // 关于 UIPresentationController 类的功能描述可以参照上面贴出的唐巧的博客,主要作用可以总结为自定义 presentedView 的尺寸以及添加动画。例如需要 Present 出来的 VC 并非满屏大小时(参照系统的 ActionSheet 控件),只需要在这里面做简单的设置即可;
}

- (id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {
		// Present 动画执行时需要提供的动画控制器
}

- (id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
    // Dismiss 动画执行时需要提供的动画控制器
		// ps: 方便的做法是共用一个动画控制器,通过 Bool 值区别是 Present 或者 Dismiss,来区别动画实现细节
}

- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator {
    // 为转场增加手势控制
}


2.转场的动画控制器

无论是上面哪种控制器的转场代理,均需要提供一个实现了 <UIViewControllerAnimatedTransitioning> 协议的动画控制器,一般新建一个继承自NSObject的类遵循并实现这个协议即可。
该协议主要实现两个方法:

// 转场动画的时间
-(NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext;
// 转场动画的具体实现
-(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext;

核心点在于 -(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext; 方法的实现。

3.转场动画实现的相关细节

上下文参数 transitionContext 遵循了 <UIViewControllerContextTransitioning> 协议,开发者们可以根据上下文拿到实现动画所需要的重要的信息:

// 动画发生的容器 View
transitionContext.containerView;

// 转场前后两个 ViewController
[transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
[transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];

// 转场前后两个 View (即为 ViewController.view)
[transitionContext viewForKey:UITransitionContextFromViewKey];
[transitionContext viewForKey:UITransitionContextToViewKey];

// 控制器在转场前后的 frame
[transitionContext initialFrameForViewController:(UIViewController *)vc];
[transitionContext finalFrameForViewController:(UIViewController *)vc];

// 动画执行结束一定要调用这个方法
-(void)completeTransition:(BOOL)didComplete;

总的来说就是系统提供给了开发者一个 containerView,以及将要进行动画的前后两个视图控制器的 View,由开发者来自行实现动画,并在动画结束时调用 -(void)completeTransition:(BOOL)didComplete 方法告知动画结束。因此对于开发者来说,问题简化为了容器 View 上的两个子 View 如何展现动画的简单问题。

这里有几点细节需要注意:

  1. Present / Push 动画需要手动将 UITransitionContextToViewKey 对应的 View addSubview 到 containerView 中。
  2. 动画的实现可以采用 CALayer 动画或者 UIView 动画,但是实测在 iOS 11 下,CALayer 动画无法通过手势控制器实时控制动画进度,不清楚是不是 bug。
  3. 动画结束请一定要调用 -(void)completeTransition:(BOOL)didComplete

这里贴一个简单的栗子

- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 0.4; // 动画执行时间
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    // isPresentation 属性为初始化时传入,区别是 Present 还是 Dismiss
    NSString *key = self.isPresentation ? UITransitionContextToViewControllerKey : UITransitionContextFromViewControllerKey;
    UIViewController *controller = [transitionContext viewControllerForKey:key];

    if (self.isPresentation) {
        [transitionContext.containerView addSubview:controller.view];
    }
    
    CGRect presentedFrame = [transitionContext finalFrameForViewController:controller];
		CGRect dismissedFrame.origin.y = transitionContext.containerView.bounds.size.height;

    
    CGRect initialFrame = self.isPresentation ? dismissedFrame : presentedFrame;
    CGRect finalFrame = self.isPresentation ? presentedFrame : dismissedFrame;
    
    NSTimeInterval duration = [self transitionDuration:transitionContext];
    controller.view.frame = initialFrame;
    
    [UIView animateWithDuration:duration delay:0.f usingSpringWithDamping:1.f initialSpringVelocity:5.f options:UIViewAnimationOptionCurveEaseInOut animations:^{
        controller.view.frame = finalFrame;
    } completion:^(BOOL finished) {
        [transitionContext completeTransition:finished];
    }];

}

4.手势驱动改变动画进度

系统提供了一个实现 UIViewControllerInteractiveTransitioning 协议的UIPercentDrivenInteractiveTransition类,所以我们只要继承这个类,添加手势并在手势实现的方法中告知当前视图的百分比,通过此逻辑来驱动视图,在调用类中定义的一些方法就很容易实现视图的交互。

核心方法有三个:

// 更新动画百分比
-(void)updateInteractiveTransition:(CGFloat)percentComplete;
// 取消视图交互,返回动画执行前的状态
-(void)cancelInteractiveTransition;
// 继续完成动画,更新到完成后的状态
-(void)finishInteractiveTransition;

实现方式通常如下:

@interface MERPresentationInteractive ()
@property (nonatomic, weak) UIViewController *dismissedVC;
@property (nonatomic, strong) UIScreenEdgePanGestureRecognizer *panGesture;
@end

@implementation MERPresentationInteractive

- (instancetype)init {
    self = [super init];
    if (self) {
        _isInteracting = NO;
    }
    return self;
}

- (void)setDismissGestureRecognizerToViewController:(UIViewController *)viewController {
		// 为被 Present 出来的 VC 添加滑动手势
    _dismissedVC = viewController;
    UIViewController *vc = viewController;
    if ([viewController isKindOfClass:[UINavigationController class]]) {
        UINavigationController *navi = (UINavigationController *)viewController;
        vc = navi.topViewController;
    }
    if (!_panGesture) {
        _panGesture = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
        _panGesture.edges = UIRectEdgeLeft;
    }
    if (![[vc.view gestureRecognizers] containsObject:_panGesture]) {
        [vc.view addGestureRecognizer:_panGesture];
    }
}

- (void)handlePan:(UIScreenEdgePanGestureRecognizer*)recognizer {
    
    if (recognizer.state == UIGestureRecognizerStateBegan) {
        _isInteracting = YES;
        [_dismissedVC dismissViewControllerAnimated:YES completion:nil]; // 开始执行动画
    }
    else if (recognizer.state == UIGestureRecognizerStateChanged) {
        if (!_isInteracting) {
            return;
        }
        CGFloat progress = [recognizer translationInView:[UIApplication sharedApplication].keyWindow].x / ([UIApplication sharedApplication].keyWindow.bounds.size.width * 1.0);
        progress = MIN(1.0, MAX(0.0, progress));
        
        [self updateInteractiveTransition:progress]; // 根据手势实时更新动画进度
    }
    else if (recognizer.state == UIGestureRecognizerStateEnded || recognizer.state == UIGestureRecognizerStateCancelled) {
        if (!_isInteracting) {
            return;
        }
        
        CGFloat progress = [recognizer translationInView:[UIApplication sharedApplication].keyWindow].x / (_dismissedVC.view.bounds.size.width * 1.0);
        progress = MIN(1.0, MAX(0.0, progress));
        
        if (@available(iOS 11.0,*)) {
				// 此处由于在实现点扩散转场动画中,iOS 11下执行取消仍然会完成动画,因此对iOS 11区别处理了
            self.completionSpeed = 1 - progress;
            [self finishInteractiveTransition];
        } else {
            CGPoint velocity = [recognizer velocityInView:[UIApplication sharedApplication].keyWindow];
						// 根据进度和速度方向来确定完成和取消的阈值,因人而异,可随意调整
            if ((progress > 0.25 && velocity.x > 0) || progress > 0.5) {
                NSLog(@"Pop完成");
                self.completionSpeed = 1;
                [self finishInteractiveTransition];
            } else {
                NSLog(@"Pop取消");
                [self updateInteractiveTransition:0.f];
                [self cancelInteractiveTransition];
            }
        }
        _isInteracting = NO;
    }
}
@end

5.在分类中使用

新建 UIViewController 的分类,新增自定义的 Present 方法,在实现中为被 Present 的 ViewController 添加转场代理,并设置 UIModalPresentationStyleUIModalPresentationCustom

例如这样:

- (void)presentFadePatternViewController:(UIViewController *)viewControllerToPresent
                                animated:(BOOL)flag
                              completion:(void (^)(void))completion {
    
    MERGraduallyFadePresentationManager *graduallyFadePresentationManager = [[MERGraduallyFadePresentationManager alloc] init];
    
    viewControllerToPresent.modalPresentationStyle = UIModalPresentationCustom;
    viewControllerToPresent.transitioningDelegate = graduallyFadePresentationManager;
    
    [self presentViewController:viewControllerToPresent animated:flag completion:completion];
}

后续的拓展,只需要根据需求,新增动画代理控制器、转场代理控制器,然后像这样修改 ViewControllertransitioningDelegate 即可。

目前发现的坑

问题主要集中在 iOS 11 及 iOS 11系统以下,动画的展示细节可能会有不同,也不清楚苹果又重构了他们什么代码实现……因此做转场请一定要在不同的系统环境下都跑一次看看。

Github地址

文章参考

ViewController切换
iOS 视图控制器转场详解
iOS开发 - 自定义转场