Post

iOS UIViewController 转场动画|下拉关闭、上拉渐入与全页右滑返回实作技巧

深入解析 iOS UIViewController 转场动画,解决下拉关闭、上拉渐入与全页右滑返回手势难题,搭配 UIPercentDrivenInteractiveTransition 实现流畅交互动画,提升使用者体验并兼容多版本系统。

iOS UIViewController 转场动画|下拉关闭、上拉渐入与全页右滑返回实作技巧

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


iOS UIViewController 转场二三事

UIViewController 下拉关闭/上拉出现/全页右滑返回 效果全解

前言

一直以来都很好奇诸如 Facebook、Line、Spotify…等等常用的 APP 是如何实作「Present 的 UIViewController 可下拉关闭」、「上拉渐入 UIViewController」、「全页面支援手势右滑返回」这些效果的。

因为这些效果内建都没有,下拉关闭也直到 iOS ≥ 13 才有系统的卡片样式支援。

探索之路

不知道是不会下关键字还是资料本身难找,一直找不到这类功能的实践做法,找到的资料都很含糊零散,只能东拼西凑。

一开始自己研究做法时找到 UIPresentationController 这个 API ,没再深掘其他资料,就用这个方法搭配 UIPanGestureRecognizer 用很土炮的方式完成下拉关闭的效果;一直都觉得哪里怪怪的,感觉会有更好的方式。

直到最近接触新专案拜读 大大的文章 ,扩大眼界才发现有其他 API 更漂亮、更有弹性的做法可以用。

本篇一方面是自我纪录,另一方面希望有帮助到跟我有一样困惑的朋友。

内容有点多,嫌麻烦的可以直接拉到底看范例,或直接下载 Github 专案回来研究!

iOS 13 卡片样式呈现页面

首先讲最新系统内建的效果 iOS ≥ 13 后 UIViewController.present(_:animated:completion:) 默认的 modalPresentationStyle 效果就是 UIModalPresentationAutomatic 片样式呈现页面,若想要保持之前的全页面呈现就要特别指定回 UIModalPresentationFullScreen 即可。

内建行事历新增效果

内建行事历新增效果

如何取消下拉关闭?关闭确认?

更好的使用者体验应该要能在触发下拉关闭时检查有无输入资料,有的话需要提示使用者是否舍弃动作离开。

这部分苹果也帮我们想好了,只需实作 UIAdaptivePresentationControllerDelegate 里的方法即可。

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
import UIKit

class DetailViewController: UIViewController {
    private var onEdit:Bool = true;
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //设置代理
        self.presentationController?.delegate = self
        //if uiviewcontroller embed in navigationController:
        //self.navigationController?.presentationController?.delegate = self
        
        //取消下拉关闭方式(1):
        self.isModalInPresentation = true;
        
    }
    
}

//代理实作
extension DetailViewController: UIAdaptivePresentationControllerDelegate {
    //取消下拉关闭方式(2):
    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
        return false;
    }
    
    //下拉关闭取消时,下拉手势触发
    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
        if (onEdit) {
          let alert = UIAlertController(title: "资料尚未存储", message: nil, preferredStyle: .actionSheet)
          alert.addAction(UIAlertAction(title: "舍弃离开", style: .default) { _ in
              self.dismiss(animated: true)
          })
          alert.addAction(UIAlertAction(title: "继续编辑", style: .cancel, handler: nil))
          self.present(alert, animated: true)      
        } else {
          self.dismiss(animated: true, completion: nil)
        }
    }
}

取消下拉关闭可指定 UIViewController 的变数 isModalInPresentation 为 false 或实作 UIAdaptivePresentationControllerDelegate presentationControllerShouldDismiss 并回传 true 择一都可。

UIAdaptivePresentationControllerDelegate presentationControllerDidAttemptToDismiss 这个方法只有在 下拉关闭取消时 才会呼叫使用。

By the way…

卡片样式呈现页面对系统来说就是 Sheet ,行为上跟 FullScreen 有所不同。

假设今天 RootViewControllerHomeViewController

在卡片样式呈现下 (UIModalPresentationAutomatic) 则:

HomeViewController Present DetailViewController 时…

HomeViewController viewWillDisAppear / viewDidDisAppear 都不会触发。

DetailViewController Dismiss 时…

HomeViewController viewWillAppear / viewDidAppear 都不会触发。

⚠️ 因 XCODE 11 之后版本打包的 iOS ≥ 13 APP 预设 Present 都会使用卡片样式 (UIModalPresentationAutomatic)

如果之前有把一些逻辑放在 viewWillAppear/viewWillDisappear/viewDidAppear/viewDidDisappear 的要多加检查注意! ⚠️

看完系统内建的,来看本篇重头戏吧!如何自干这些效果?

哪里可做转场动画?

首先先整理哪里可以做视窗切换转场动画。

UITabBarController/UIViewController/UINavigationController

UITabBarController/UIViewController/UINavigationController

UITabBarController 切换时

我们可以在 UITabBarController 设定 delegate 然后实作 animationControllerForTransitionFrom 方法,就能在切换 UITabBarController 时对内容套用自订转场特效。

系统预设无动画,上方展示图的是淡入淡出切换特效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import UIKit

class MainTabBarViewController: UITabBarController {

    override func viewDidLoad() {
        super.viewDidLoad()
        self.delegate = self
        
    }
    
}

extension MainTabBarViewController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //return UIViewControllerAnimatedTransitioning
    }
}

UIViewController Present/Dismiss 时

理所当然,在 Present/Dismiss UIViewController 时可以指定要套用的动画效果,不然就不会有此篇文章了XD;不过值得一提的是,如果只是单纯要做 Present 动画没有要做手势控制,可以直接使用 UIPresentationController 方便快速 (详见文末参考资料)。

系统预设是上滑出现下滑消失!自己客制的话可以加入淡入、圆角、出现位置控制…等效果。

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
import UIKit

class HomeAddViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.modalPresentationStyle = .custom
        self.transitioningDelegate = self
    }
    
}

extension HomeAddViewController: UIViewControllerTransitioningDelegate {
    
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //回传 nil 即走预设动画
        return //UIViewControllerAnimatedTransitioning Present时要套用的动画
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //回传 nil 即走预设动画
        return //UIViewControllerAnimatedTransitioning Dismiss时要套用的动画
    }
}

任何 UIViewController 都能实作 transitioningDelegate 告知 Present/Dismiss 动画; UITabBarViewControllerUINavigationControllerUITableViewController ….都可

UINavigationController Push/Pop 时

UINavigationController 大概是最不太需要会改动画的,因为系统预设的左滑出现右滑返回动画已经是最好的效果,能想得到要做这部分的客制可能可以用来做无缝 UIViewController 左右切换效果。

因为我们要做全页都可手势返回,需要配合自订 POP 动画,所以需要自己实作一个返回动画效果。

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
import UIKit

class HomeNavigationController: UINavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.delegate = self
    }

}

extension HomeNavigationController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        
        if operation == .pop {
            return //UIViewControllerAnimatedTransitioning 返回时要套用的动画
        } else if operation == .push {
            return //UIViewControllerAnimatedTransitioning push时要套用的动画
        }
        
        //回传 nil 即走预设动画
        return nil
    }
}

交互非交互动画?

再讲动画实作、手势控制前,先讲一下何谓交互与非交互。

交互动画: 手势触发动画,如 UIPanGestureRecognizer

非交互动画: 系统呼叫动画,如 self.present( )

怎么实作动画效果?

讲完哪里可以做,再来看怎么做动画效果。

我们需要实作 UIViewControllerAnimatedTransitioning 这个 Protocol 并在里面对视窗做动画。

一般转场动画: UIView.animate

直接使用 UIView.animate 做动画处理,此时的 UIViewControllerAnimatedTransitioning 需要实作 transitionDuration 告知动画时长、 animateTransition 实作动画内容这两个方法。

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
42
43
44
45
import UIKit

class SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning {
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.4
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        
        //可用参数:
        //取得要展示的目标 UIViewController 的 View 内容:
        let toView = transitionContext.view(forKey: .to)
        //取得要展示的目标 UIViewController:
        let toViewController = transitionContext.viewController(forKey: .to)
        //取得要展示的目标 UIViewController 的 View 的初始化 Frame 资讯:
        let toInitalFrame = transitionContext.initialFrame(for: toViewController!)
        //取得要展示的目标 UIViewController 的 View 的最终 Frame 资讯:
        let toFinalFrame = transitionContext.finalFrame(for: toViewController!)
        
        //取得当前 UIViewController 的 View 内容:
        let fromView = transitionContext.view(forKey: .from)
        //取得当前 UIViewController:
        let fromViewController = transitionContext.viewController(forKey: .from)
        //取得当前 UIViewController 的 View 的初始化 Frame 资讯:
        let fromInitalFrame = transitionContext.initialFrame(for: fromViewController!)
        //取得当前 UIViewController 的 View 的最终 Frame 资讯: (在关闭动画时可以取得之前显示动画时的最终Frame)
        let fromFinalFrame = transitionContext.finalFrame(for: fromViewController!)
        
        //toView.frame.origin.y = UIScreen.main.bounds.size.height
        
        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
            //toView.frame.origin.y = 0
        }) { (_) in
            if (!transitionContext.transitionWasCancelled) {
                //动画没中断
            }
            
            // 告知系统动画完成
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
        
    }
    
}

To 跟 From:

假设今天 HomeViewControllerPresent/Push DetailViewController 时,

From = HomeViewController / To = DetailViewController

DetailViewControllerDismiss/Pop 时,

From = DetailViewController / To = HomeViewController

⚠️⚠️⚠️⚠️⚠️

官方建议从 transitionContext.view 拿 View 使用,而不是从 transitionContext.viewController 拿 .view 使用。

但这边有个问题,就是在做 Present/Dismiss 动画时当 modalPresentationStyle = .custom

Present 时使用 transitionContext.view(forKey: .from) 会是 nil

Dismiss 时使用 transitionContext.view(forKey: .to) 也会是 nil

还是需要从 viewController.view 拿值来用。

⚠️⚠️⚠️⚠️⚠️

transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 动画完成必须呼叫,否则 画面会卡死

但因 UIView.animate 若无可执行动画就不会 Call completion 造成前述方法未被呼叫;所以务必确保动画是会执行的 (EX: y从100到0)。

ℹ️ℹ️ℹ️ℹ️ℹ️

参与动画的 ToView/FromView ,若因 View 较为复杂或动画时有些问题;可改用 snapshotView(afterScreenUpdates:) 截图作为动画展示,先截图然后 transitionContext.containerView.addSubview(snapShotView) 上去图层,接著隐藏原本的 ToView/FromView (isHidden = true) ,在动画结束时在 snapShotView.removeFromSuperview() 和恢复显示原本的 ToView/FromView (isHidden = true)

可中断、继续的转场动画: UIViewPropertyAnimator

另外也可以使用 iOS ≥ 10 新的动画类别来实作动画效果, 看个人习惯或是动画要做到多细节来做选择, 虽然官方的建议是有交互就使用 UIViewPropertyAnimator不管是交互非交互(手势控制) 一般都使用 UIView.animate 即可UIViewPropertyAnimator 的转场动画能做到中断继续的效果,虽然我不知道实际能应用在哪,有兴趣的朋友可参考 此篇文章

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
42
43
44
45
46
47
48
import UIKit

class FadeInFadeOutTransition: NSObject, UIViewControllerAnimatedTransitioning {
    
    private var animatorForCurrentTransition: UIViewImplicitlyAnimating?

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
        
        //当前有转场动画时直接返回
        if let animatorForCurrentTransition = animatorForCurrentTransition {
            return animatorForCurrentTransition
        }
        
        //参数同前述
        
        //fromView.frame.origin.y = 100
        
        let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: .linear)
        
        animator.addAnimations {
            //fromView.frame.origin.y = 0
        }
        
        animator.addCompletion { (position) in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
        
        //抓著动画
        self.animatorForCurrentTransition = animator
        return animator
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.4
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //如果是非交互会走这,就让它也走交互的动画
        let animator = self.interruptibleAnimator(using: transitionContext)
        animator.startAnimation()
    }
    
    func animationEnded(_ transitionCompleted: Bool) {
        //动画完成,清空
        self.animatorForCurrentTransition = nil
    }
    
}

交互情况下 (后面讲控制会细提),会使用 interruptibleAnimator 方法的动画;非交互的情况则还是使用 animateTransition 方法。

因为能继续、中断的特性;所以 interruptibleAnimator 是有可能会重复呼叫使用的;所以我们需要用一个全域变数做存取返回。

Murmur… 其实我本来是想全都改用新的 UIViewPropertyAnimator 也想推荐大家都用新的来做,但我遇到一个很奇怪的问题,就是在做全页手势返回 Pop 动画时,若手势放开,动画归位,上方的 Navigation Bar 的 Item 会淡入淡出闪一下…找不到解,但回去用 UIView.animate 就没这问题;如果有地方没注意到欢迎跟我说<( _ _ )>。

问题图; + 按钮是上一页的

问题图; + 按钮是上一页的

所以保险起见还是用旧的方式吧!

实际会依照不同的动画效果建立个别的 Class,若觉得很档案杂,可参考文末包好的方案;或是将同个连贯(Present+Dismii)动画放在一起。

transitionCoordinator

另外如果需要更细致的控制,例如 ViewController 里面有某个元件需要配合转场动画改变;可在 UIViewController 中使用 transitionCoordinator 进行协作,这部分我没用到;有兴趣可参考 此篇文章

怎么控制动画?

这边就是前述所说的「交互」,实际就是手势控制;本篇最重要的章节,因为我们的要做的是手势操作与转场动画的连动功能,才能达成我们要的下拉关闭、全页返回功能。

控制代理设置:

同前面 ViewController 代理动画设计,交互处理的类也需要在代理中告知 ViewController

UITabBarController: 无 UINavigationController (Push/Pop):

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
import UIKit

class HomeNavigationController: UINavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.delegate = self
    }

}

extension HomeNavigationController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        
        if operation == .pop {
            return //UIViewControllerAnimatedTransitioning 返回时要套用的动画
        } else if operation == .push {
            return //UIViewControllerAnimatedTransitioning push时要套用的动画
        }
        //回传 nil 即走预设动画
        return nil
    }
    
    //新增交互代理方法:
    func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        //这边无法得知是Pop还是Push 只能从要做的动画本身做判断
        if animationController is push时套用的动画 {
            return //UIPercentDrivenInteractiveTransition push动画的交互控制方法
        } else if animationController is 返回时套用的动画 {
            return //UIPercentDrivenInteractiveTransition pop动画的交互控制方法
        }
        //回传 nil 即不做交互处理
        return nil
    }
}

UIViewController (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
import UIKit

class HomeAddViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.modalPresentationStyle = .custom
        self.transitioningDelegate = self
    }
    
}

extension HomeAddViewController: UIViewControllerTransitioningDelegate {
    
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        //return nil 即不做交互处理
        return //UIPercentDrivenInteractiveTransition Dismiss时交互控制方法
    }
    
    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        //return nil 即不做交互处理
        return //UIPercentDrivenInteractiveTransition Present时交互控制方法
    }
    
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //回传 nil 即走预设动画
        return //UIViewControllerAnimatedTransitioning Present时要套用的动画
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //回传 nil 即走预设动画
        return //UIViewControllerAnimatedTransitioning Dismiss时要套用的动画
    }
    
}

⚠️⚠️⚠️⚠️⚠️

有实作 interactionControllerFor … 这些方法,就算动画是非交互(EX: self.present 系统呼叫转场) 也会 Call 这些方法处理;我们需要控制的是里面的 wantsInteractiveStart 参数(下面介绍)。

动画交互处理类 UIPercentDrivenInteractiveTransition:

再来讲核心要实作的 UIPercentDrivenInteractiveTransition

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import UIKit

class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
    
    //要加手势控制交互的UIView
    private var interactiveView: UIView!
    //当前的UIViewController
    private var presented: UIViewController!
    //当托拉超过多少%后就完成执行,否则复原
    private let thredhold: CGFloat = 0.4
    
    //不同转场效果可能需要不同资讯,可自订
    convenience init(_ presented: UIViewController, _ interactiveView: UIView) {
        self.init()
        self.interactiveView = interactiveView
        self.presented = presented
        setupPanGesture()
        
        //默认值,告知系统当前非交互动画
        wantsInteractiveStart = false
    }

    private func setupPanGesture() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        panGesture.maximumNumberOfTouches = 1
        panGesture.delegate = self
        interactiveView.addGestureRecognizer(panGesture)
    }

    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
        switch sender.state {
        case .began:
            //reset 手势位置
            sender.setTranslation(.zero, in: interactiveView)
            //告知系统当前开始的是手势触发的交互动画
            wantsInteractiveStart = true
            
            //在手势began时呼叫要做的转场效果(不会直接执行,系统会抓住)
            //然后转场效果有设对应的动画就会跳到 UIViewControllerAnimatedTransitioning 处理
            // animated 一定为 true 否则没动画
            
            //Dismiss:
            self.presented.dismiss(animated: true, completion: nil)
            //Present:
            //self.present(presenting,animated: true)
            //Push:
            //self.navigationController.push(presenting)
            //Pop:
            //self.navigationController.pop(animated: true)
        
        case .changed:
            //手势滑动的位置计算 对应动画完成百分比 0~1
            //实际依动画类型不同,计算方式不同
            let translation = sender.translation(in: interactiveView)
            guard translation.y >= 0 else {
                sender.setTranslation(.zero, in: interactiveView)
                return
            }
            let percentage = abs(translation.y / interactiveView.bounds.height)
            
            //update UIViewControllerAnimatedTransitioning 动画百分比
            update(percentage)
        case .ended:
            //手势放开完成时,看完成度有没有超过 thredhold
            wantsInteractiveStart = false
            if percentComplete >= thredhold {
              //有,告知动画完成
              finish()
            } else {
              //无,告知动画归位复原
              cancel()
            }
        case .cancelled, .failed:
          //取消、错误时
          wantsInteractiveStart = false
          cancel()
        default:
          wantsInteractiveStart = false
          return
        }
    }
}

//当UIViewController内有UIScrollView元件(UITableView/UICollectionView/WKWebView....),防止手势冲突
//当里面的UIScrollView元件已滑到顶部,则启用交互转场的手势操作
extension PullToDismissInteractive: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if let scrollView = otherGestureRecognizer.view as? UIScrollView {
            if scrollView.contentOffset.y <= 0 {
                return true
            } else {
                return false
            }
        }
        return true
    }
    
}

*关于 sender.setTranslation( .zero, in:interactiveView) 原因的补充点我<

我们需要依据不同的手势操作效果,实作不同的 Class;若是同个连贯(Present+Dismii)的操作也可包在一起。

⚠️⚠️⚠️⚠️⚠️

wantsInteractiveStart 务必处于符合的状态 ,若在交互动画时告知 wantsInteractiveStart = false 也会造成卡画面;

要退出重进 APP 才会恢复正。

⚠️⚠️⚠️⚠️⚠️

interactiveView 也一定要是 isUserInteractionEnabled = true

可以多加设置确保一下!

组合

当我们把这里个 Delegate 设好、 Class 建好后就能做到我们想要的功能了。 再来不啰唆,直接上完成范例。

自制下拉关闭页面效果

自制下拉的好处在能支援市面所有 iOS 版本、可控制盖板百分比、控制触发关闭位置、客制化动画效果。

点右上方 + Present 页面

点右上方 + Present 页面

这是一个 HomeViewController Present HomeAddViewControllerHomeAddViewController 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import UIKit

class HomeViewController: UIViewController {

    @IBAction func addButtonTapped(_ sender: Any) {
        guard let homeAddViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "HomeAddViewController") as? HomeAddViewController else {
            return
        }
        
        //transitioningDelegate 可指定目标ViewController处理或当前的ViewController处理
        homeAddViewController.transitioningDelegate = homeAddViewController
        homeAddViewController.modalPresentationStyle = .custom
        self.present(homeAddViewController, animated: true, completion: nil)
    }

}
import UIKit

class HomeAddViewController: UIViewController {

    private var pullToDismissInteractive:PullToDismissInteractive!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //绑定转场交互资讯
        self.pullToDismissInteractive = PullToDismissInteractive(self, self.view)
    }
    
}

extension HomeAddViewController: UIViewControllerTransitioningDelegate {
    
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return pullToDismissInteractive
    }
    
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return PresentAndDismissTransition(false)
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return PresentAndDismissTransition(true)
    }
    
    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        //这边无Present操作手势
        return nil
    }
}
import UIKit

class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
    
    private var interactiveView: UIView!
    private var presented: UIViewController!
    private var completion:(() -> Void)?
    private let thredhold: CGFloat = 0.4
    
    convenience init(_ presented: UIViewController, _ interactiveView: UIView,_ completion:(() -> Void)? = nil) {
        self.init()
        self.interactiveView = interactiveView
        self.completion = completion
        self.presented = presented
        setupPanGesture()
        
        wantsInteractiveStart = false
    }

    private func setupPanGesture() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        panGesture.maximumNumberOfTouches = 1
        panGesture.delegate = self
        interactiveView.addGestureRecognizer(panGesture)
    }

    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
        switch sender.state {
        case .began:
            sender.setTranslation(.zero, in: interactiveView)
            wantsInteractiveStart = true
            
            self.presented.dismiss(animated: true, completion: self.completion)
        case .changed:
            let translation = sender.translation(in: interactiveView)
            guard translation.y >= 0 else {
                sender.setTranslation(.zero, in: interactiveView)
                return
            }

            let percentage = abs(translation.y / interactiveView.bounds.height)
            update(percentage)
        case .ended:
            if percentComplete >= thredhold {
                finish()
            } else {
                wantsInteractiveStart = false
                cancel()
            }
        case .cancelled, .failed:
            wantsInteractiveStart = false
            cancel()
        default:
            wantsInteractiveStart = false
            return
        }
    }
}

extension PullToDismissInteractive: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if let scrollView = otherGestureRecognizer.view as? UIScrollView {
            if scrollView.contentOffset.y <= 0 {
                return true
            } else {
                return false
            }
        }
        return true
    }
    
}

以上就能达到如图的效果,这边因教学展示不想弄的路太复杂,所以程式码很丑,还有很多优化整合的空间。

值得一提的是…

iOS ≥ 13,如果遇到 View 内容有 UITextView,在做下拉关闭动画时,动画当中 UITextView 的文字内容会一片空白;造成体验会闪一下 (影片范例)

这边的解决方案是在做动画时用 snapshotView(afterScreenUpdates:) 截图取代原本的 View 图层。

全页右滑返回

在寻找全画面都能手势右滑返回的解决方案时,找到个 Tricky 的方法: 直接在画面上加一个 UIPanGestureRecognizer 然后将 targetaction 都指定到原生的 interactivePopGestureRecognizeraction:handleNavigationTransition*详细方法点我<

没错!看起来就很 Private API,感觉审核会被拒;而且不确定 Swift 是否可用,应该有用到 OC 才有的 Runtime 特性。

还是走正规的吧:

ㄧ样使用本篇的方式,我们在 navigationController POP 返回时自行处理;添加一个全页右滑手势控制配合自订右滑动画,即可!

其他省略,只贴关键的动画跟交互处理类别:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import UIKit

class SwipeBackInteractive: UIPercentDrivenInteractiveTransition {
    
    private var interactiveView: UIView!
    private var navigationController: UINavigationController!

    private let thredhold: CGFloat = 0.4
    
    convenience init(_ navigationController: UINavigationController, _ interactiveView: UIView) {
        self.init()
        self.interactiveView = interactiveView
        
        self.navigationController = navigationController
        setupPanGesture()
        
        wantsInteractiveStart = false
    }

    private func setupPanGesture() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        panGesture.maximumNumberOfTouches = 1
        interactiveView.addGestureRecognizer(panGesture)
    }

    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
        
        switch sender.state {
        case .began:
            sender.setTranslation(.zero, in: interactiveView)
            wantsInteractiveStart = true
            
            self.navigationController.popViewController(animated: true)
        case .changed:
            let translation = sender.translation(in: interactiveView)
            guard translation.x >= 0 else {
                sender.setTranslation(.zero, in: interactiveView)
                return
            }

            let percentage = abs(translation.x / interactiveView.bounds.width)
            update(percentage)
        case .ended:
            if percentComplete >= thredhold {
                finish()
            } else {
                wantsInteractiveStart = false
                cancel()
            }
        case .cancelled, .failed:
            wantsInteractiveStart = false
            cancel()
        default:
            wantsInteractiveStart = false
            return
        }
    }
}

上拉渐入 UIViewController

在View上上拉渐入+下拉关闭,就是在做类似 Spotify 的播放器转场效果了!

这部分较为繁琐,但原理一样,这边就不 PO 出来了,有兴趣的朋友可参考 GitHub 范例内容。

要说哪里要注意,大概就是 在上拉渐入时,动画要确保是使用「.curveLinear 线性」否则会出现上拉不跟手的问题 ;拉的程度跟显示的位置不是正比。

完成!

完成图

完成图

此篇很长,也花了我许久时间整理制作,感谢您的耐心阅读。

全篇 GitHub 范例下载:

参考资料:

  1. Draggable view controller? Interactive view controller!

  2. 系统学习iOS动画之四:视图控制器的转场动画

  3. 系统学习iOS动画之五:使用UIViewPropertyAnimator

  4. 用UIPresentationController来写一个简洁漂亮的底部弹出控件 (单纯只做Present 动画效果可直接用这个)

若需要参考优雅的程式码封装使用:

  1. Swift: https://github.com/Kharauzov/SwipeableCards

  2. Objective-C: https://github.com/saiday/DraggableViewControllerDemo

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.