需求

App 中想加入一个订阅页面,期望 iOS 上按 bottom sheet 展示,iPad 上居中显示,同时支持横屏和竖屏,效果如下:

subscription in iPhone subscription in iPad

iOS 15 提供了 UISheetPresentationController 可以令 Bottom Sheet 更方便,具体可参考How to present a Bottom Sheet in iOS 15还有Presenting sheets with UIKit using a UISheetPresentationController

UISheetPresentationController 不满足需求因为它只有 mediumlarge 两种高度,并且宽度是满屏,不符合需求。 通过 OpenAI 问答,找到了可以满足需求的方法来在一个页面中弹出订阅页面。

然后不出意外的,新问题又出现了:想要在另外一个页面中也弹出同样的订阅页面,当时这两个页面的父类是不同 UIViewController,也就是说没办法给他们定义一个共同的定制化的 UIViewController 子类,而 Swift 又不支持多继承,尝试了带默认实现的 Protocol,无法实现。最终还是通过询问 AI 找到了实现方法:

dns analysis

最终实现:

  • SubscriptionSheetPresenter.swift

共享的 presenter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class SubscriptionSheetPresenter: NSObject {
    static let instance = SubscriptionSheetPresenter()

    func presentBottomSheetForSubscription(from vc: UIViewController) {
        // storyboard 中定义了定制页面的布局
        let dashboard = UIStoryboard.init(name: "Main", bundle: nil)
        guard let subscriptionVC = dashboard.instantiateViewController(withIdentifier: "subscriptionViewController") as? SubscriptionViewController else {
            return
        }
        subscriptionVC.modalPresentationStyle = .custom
        subscriptionVC.transitioningDelegate = self
        vc.present(subscriptionVC, animated: true, completion: nil)
    }
}

// MARK: - Bottom Sheet for Subscription
extension SubscriptionSheetPresenter: UIViewControllerTransitioningDelegate {
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
        return SubscriptionSheetPresentationController(presentedViewController: presented, presenting: presenting)
    }
}
  • SubscriptionViewController.swift

Storyboard 中订阅页面的 UIViewController class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class SubscriptionViewController: UIViewController {
    @IBOutlet weak var monthlyBuy: UIButton!
    @IBOutlet weak var yearlyBuy: UIButton!

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        updateUI()
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        updateUI()
    }

    private func updateUI() {
        // update layour for diff device (iPhone/iPad) and view (Landscape/Protraint)
        // ...
    }
  • SubscriptionSheetPresentationController.swift

定制订阅页面的宽度和高度

 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
class SubscriptionSheetPresentationController: UIPresentationController {
    override var frameOfPresentedViewInContainerView: CGRect {
        let screenWidth = UIScreen.main.bounds.width
        let screenHeight = UIScreen.main.bounds.height

         // default is iPhone+portrait for non-SE
        var width: CGFloat = screenWidth
        var height: CGFloat = screenHeight * 2/3
        var x: CGFloat = 0
        var yOffset = 1.0

        if UIDevice.current.userInterfaceIdiom == .phone {
            if UIDevice.current.orientation.isLandscape {
                width = screenWidth / 2
                height = screenHeight * 0.95
            } else {
                if screenHeight < 700 {
                    height = screenHeight * 2.5/3
                }
            }
        } else { // iPad or Mac
            if UIDevice.current.orientation.isLandscape {
                width = screenWidth / 2.5
                height = screenHeight * 0.65
            } else {
                width = screenWidth / 1.8
                height = screenHeight * 0.5
            }
            yOffset = 0.5
        }

        x = (screenWidth - width)/2

        return CGRect(x: x,
                      y: (containerView.bounds.height - height) * yOffset,
                      width: width,
                      height: height)
    }

    override func containerViewWillLayoutSubviews() {
        super.containerViewWillLayoutSubviews()
        if let presentedView = presentedView {
            presentedView.frame = frameOfPresentedViewInContainerView
        }
    }
}

在需要弹出订阅页面的 UIViewController 中直接调用即可:

  • <Any>ViewController.swift
1
2
3
4
5
class <Any>ViewController: <XXX>ViewController {
    func showSubscription() {
        SubscriptionSheetPresenter.instance.presentBottomSheetForSubscription(from: self)
    }
}
  • <Another>ViewController.swift
1
2
3
4
5
class <Anyother>ViewController: <YYY>ViewController {
    func goPremium() {
        SubscriptionSheetPresenter.instance.presentBottomSheetForSubscription(from: self)
    }
}