iOS UIViewController 转场动画|下拉关闭、上拉渐入与全页右滑返回实作技巧
深入解析 iOS UIViewController 转场动画,解决下拉关闭、上拉渐入与全页右滑返回手势难题,搭配 UIPercentDrivenInteractiveTransition 实现流畅交互动画,提升使用者体验并兼容多版本系统。
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
有所不同。
假设今天
RootViewController
是HomeViewController
在卡片样式呈现下 (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 切换时
我们可以在 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
动画;UITabBarViewController
、UINavigationController
、UITableViewController
….都可
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:
假设今天
HomeViewController
要Present/Push
DetailViewController
时,
From = HomeViewController / To = DetailViewController
DetailViewController
要Dismiss/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
若无可执行动画就不会 Callcompletion
造成前述方法未被呼叫;所以务必确保动画是会执行的 (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 页面
这是一个 HomeViewController
Present HomeAddViewController
和 HomeAddViewController
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
然后将 target
、 action
都指定到原生的 interactivePopGestureRecognizer
, action: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 范例下载:
参考资料:
用UIPresentationController来写一个简洁漂亮的底部弹出控件 (单纯只做Present 动画效果可直接用这个)
若需要参考优雅的程式码封装使用:
有任何问题及指教欢迎 与我联络 。
本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。