Swift实现一个交互友好&灵活自定义的弹框

前言

在我们平时日常开发中,经常会遇到各种样式的弹框。你是否也经常遇到呢?你是如何实现的?
本文介绍使用UIPresentationController,结合自定义转场动效,实现一个高度自定义的弹框,这也是苹果比较推荐的一种实现方式。

预备知识

开始之前,我们要了解下几个知识点:

  • UIPresentationController
  • UIViewControllerTransitioningDelegate
  • UIViewControllerAnimatedTransitioning

1、UIPresentationController是什么?官方文档中介绍如下:

An object that manages the transition animations and the presentation of view controllers onscreen.

简单来说,它可以管理转场动画和模态出来的窗口控制器。详细信息可以参考:UIPresentationController文档

2、UIViewControllerTransitioningDelegate定义了转场代理方法,可以指定PresentedDismissed动画,以及UIPresentationController

3、UIViewControllerAnimatedTransitioning就是转场动画协议,我们可以遵守该协议,实现转场动画。

实现

1、自定义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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
struct ZCXPopoup {}

extension ZCXPopoup {

class PresentationController: UIPresentationController {

override func presentationTransitionWillBegin() {
guard let containerView else { return }
dimmingView.frame = containerView.bounds
dimmingView.alpha = 0.0
containerView.insertSubview(dimmingView, at: 0)

// 背景蒙层淡入动画
presentedViewController.transitionCoordinator?.animate { _ in
self.dimmingView.alpha = 1.0
}
}

override func dismissalTransitionWillBegin() {
// 背景蒙层淡出动画,以及移除操作
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
}, completion: { _ in
self.dimmingView.removeFromSuperview()
})
}

override var frameOfPresentedViewInContainerView: CGRect { UIScreen.main.bounds }

override func containerViewWillLayoutSubviews() {

guard let containerView else { return }
dimmingView.frame = containerView.bounds

guard let presentedView else { return }
presentedView.frame = frameOfPresentedViewInContainerView
}

// MARK: -

/// 背景蒙层
private lazy var dimmingView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
return view
}()
}
}

代码比较简单,主要的工作就是添加了一个背景蒙层,以及蒙层的动画交互处理,加上子视图尺寸的控制。

注:上面的ZCXPopoup结构体没有实际作用,仅仅是为了区分命名空间。

2、UIViewControllerAnimatedTransitioning实现类实现

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
extension ZCXPopoup {

class TransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {

private var isOpen: Bool = false

convenience init(isOpen: Bool = false) {
self.init()
self.isOpen = isOpen
}

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
transitionContext?.isAnimated == true ? 0.5 : 0
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

guard let fromView = transitionContext.viewController(forKey: .from)?.view else { return }
guard let toView = transitionContext.viewController(forKey: .to)?.view else { return }

if isOpen {
transitionContext.containerView.addSubview(toView)
toView.transform = .init(scaleX: 0.7, y: 0.7)
toView.alpha = 0
}

UIView.animate(
withDuration: transitionDuration(using: transitionContext),
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.7,
options: []) {
if self.isOpen {
toView.transform = .identity
toView.alpha = 1
} else {
fromView.transform = .init(scaleX: 0.7, y: 0.7)
fromView.alpha = 0
}
} completion: { _ in
let wasCancelled = transitionContext.transitionWasCancelled
transitionContext.completeTransition(!wasCancelled)
}
}
}
}

这个实现类的内容也较简单,主要是设置转场动画时长,以及实现转场动画,转场动画分为进场(present)和出场(dismiss)动画。

3、UIViewControllerTransitioningDelegate实现类实现

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
extension ZCXPopoup {

class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {

func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
PresentationController(presentedViewController: presented, presenting: presenting)
}

func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
TransitionAnimator(isOpen: true)
}

func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
TransitionAnimator(isOpen: false)
}
}
}

在该实现类中,实现代理方法,分别返回自定义的PresentationController TransitionAnimator 即可。

4、为控制器增加一个扩展,方便使用弹框交互

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
extension UIViewController {

/// 转场类型,方便后续扩展
@objc public enum TransitioningType: Int {
case none = 0
case popup = 1
}

/// 设置转场类型
@objc public var transitioningType: TransitioningType {
get { getAssociatedObject() as? TransitioningType ?? .none }
set {
if newValue == .popup {
transitioningDelegate = self.popupTransitioningDelegate
modalPresentationStyle = .custom
}
setAssociatedObject(newValue)
}
}

/// transitioningDelegate 实现类,需要被持有
private var popupTransitioningDelegate: ZCXPopoup.TransitioningDelegate {
lazyVarAssociatedObject { ZCXPopoup.TransitioningDelegate() }
}
}

到这里,一个轻量级的弹窗管理就封装好了。我们就可以给任意一个控制器加上这个交互。

自定义弹框

上面只是封装了弹框的交互,那么我们要怎么实现一个弹框呢?
很简单,具体来说就是,创建一个控制器,将其view设置成透明,然后在其中间加上弹框内容视图contentView。然后,设置控制器的transitioningType = .popup,使用present方式打开即可。

这里大家可能会问,为什么不直接修改控制器的preferredContentSize,而是弄了一个背景透明的全屏控制器。这个问题非常好,欢迎留言讨论。

设置转场类型和打开弹框:

1
2
3
4
5
6
@IBAction func showPopoup(_ sender: Any) {
let sb = UIStoryboard(name: "DemoViewController", bundle: nil)
guard let controller = sb.instantiateInitialViewController() else { return }
controller.transitioningType = .popup
present(controller, animated: true)
}

关闭弹框:

1
2
3
4
5
class DemoViewController: UIViewController {
@IBAction func dismiss(_ sender: Any) {
dismiss(animated: true)
}
}

Popup.gif

总结

上述方法,可以将弹框的交互独立封装出来,具体的业务弹框只需要实现好UI和交互事件,以及相应功能即可,弹框的打开和关闭,使用presentdismiss即可。
可以看到,弹框交互和业务可以完全解耦,这也是能做到弹框的高度可定制的核心。我们可以将这个交互沉淀到基础库,用来规范项目中弹框的统一交互。

源码

ZCXPopoup

参考

UIPresentationController
UIViewControllerAnimatedTransitioning
UIViewControllerTransitioningDelegate


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0