mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* feat: add `hasPaywall` getter to `Offering` Returns `true` if the offering has either a v1 or a v2 paywall * docs: update some methods references changing `PaywallData` with "paywall", as `PaywallData` only refers to v1 paywalls * docs: improve reference of `Offering`'s `paywall` and `paywallComponents` to mention the new `hasPaywall` property * lint * tests: add unit tests that check the `hasPaywall` property of `Offering` * api tests: add `paywallComponents` and `hasPaywall` to SwiftAPITester
435 lines
17 KiB
Swift
435 lines
17 KiB
Swift
//
|
|
// Copyright RevenueCat Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the MIT License (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://opensource.org/licenses/MIT
|
|
//
|
|
// PaywallViewController.swift
|
|
//
|
|
// Created by Nacho Soto on 8/1/23.
|
|
|
|
// swiftlint:disable file_length
|
|
|
|
import SwiftUI
|
|
|
|
#if canImport(UIKit) && !os(tvOS) && !os(watchOS)
|
|
|
|
import RevenueCat
|
|
|
|
import UIKit
|
|
|
|
/// A view controller for displaying the paywall for an `Offering`.
|
|
///
|
|
/// - Seealso: ``PaywallView`` for `SwiftUI`.
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
|
|
@objc(RCPaywallViewController)
|
|
public class PaywallViewController: UIViewController {
|
|
|
|
/// See ``PaywallViewControllerDelegate`` for receiving purchase events.
|
|
@objc public final weak var delegate: PaywallViewControllerDelegate?
|
|
|
|
private final var shouldBlockTouchEvents: Bool
|
|
private final var dismissRequestedHandler: ((_ controller: PaywallViewController) -> Void)?
|
|
|
|
private var configuration: PaywallViewConfiguration {
|
|
didSet {
|
|
// Overriding the configuration requires re-creating the `HostingViewController`.
|
|
// This is used by some Hybrid SDKs that require modifying the content after creation.
|
|
self.hostingController = self.createHostingController()
|
|
}
|
|
}
|
|
|
|
/// Initialize a `PaywallViewController` with an optional `Offering`.
|
|
/// - Parameter offering: The `Offering` containing the desired paywall to display.
|
|
/// `Offerings.current` will be used by default.
|
|
/// - Parameter displayCloseButton: Set this to `true` to automatically include a close button.
|
|
/// - Parameter shouldBlockTouchEvents: Whether to interecept all touch events propagated through this VC
|
|
/// - Parameter dismissRequestedHandler: If this is not set, the paywall will close itself automatically
|
|
/// after a successful purchase. Otherwise use this handler to handle dismissals of the paywall
|
|
@objc
|
|
public convenience init(
|
|
offering: Offering? = nil,
|
|
displayCloseButton: Bool = false,
|
|
shouldBlockTouchEvents: Bool = false,
|
|
dismissRequestedHandler: ((_ controller: PaywallViewController) -> Void)? = nil
|
|
) {
|
|
self.init(
|
|
offering: offering,
|
|
fonts: DefaultPaywallFontProvider(),
|
|
displayCloseButton: displayCloseButton,
|
|
shouldBlockTouchEvents: shouldBlockTouchEvents,
|
|
dismissRequestedHandler: dismissRequestedHandler
|
|
)
|
|
}
|
|
|
|
/// Initialize a `PaywallViewController` with an optional `Offering` and ``PaywallFontProvider``.
|
|
/// - Parameter offering: The `Offering` containing the desired paywall to display.
|
|
/// `Offerings.current` will be used by default.
|
|
/// - Parameter fonts: A ``PaywallFontProvider``.
|
|
/// - Parameter displayCloseButton: Set this to `true` to automatically include a close button.
|
|
/// - Parameter shouldBlockTouchEvents: Whether to interecept all touch events propagated through this VC
|
|
/// - Parameter dismissRequestedHandler: If this is not set, the paywall will close itself automatically
|
|
/// after a successful purchase. Otherwise use this handler to handle dismissals of the paywall
|
|
public convenience init(
|
|
offering: Offering? = nil,
|
|
fonts: PaywallFontProvider,
|
|
displayCloseButton: Bool = false,
|
|
shouldBlockTouchEvents: Bool = false,
|
|
dismissRequestedHandler: ((_ controller: PaywallViewController) -> Void)? = nil
|
|
) {
|
|
self.init(
|
|
content: .optionalOffering(offering),
|
|
fonts: fonts,
|
|
displayCloseButton: displayCloseButton,
|
|
shouldBlockTouchEvents: shouldBlockTouchEvents,
|
|
dismissRequestedHandler: dismissRequestedHandler
|
|
)
|
|
}
|
|
|
|
/// Initialize a `PaywallViewController` with an offering identifier.
|
|
/// - Parameter offeringIdentifier: The identifier for the offering with paywall to display.
|
|
/// - Parameter fonts: A ``PaywallFontProvider``.
|
|
/// - Parameter displayCloseButton: Set this to `true` to automatically include a close button.
|
|
/// - Parameter shouldBlockTouchEvents: Whether to interecept all touch events propagated through this VC
|
|
/// - Parameter dismissRequestedHandler: If this is not set, the paywall will close itself automatically
|
|
/// after a successful purchase. Otherwise use this handler to handle dismissals of the paywall
|
|
public convenience init(
|
|
offeringIdentifier: String,
|
|
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
|
|
displayCloseButton: Bool = false,
|
|
shouldBlockTouchEvents: Bool = false,
|
|
dismissRequestedHandler: ((_ controller: PaywallViewController) -> Void)? = nil
|
|
) {
|
|
self.init(
|
|
content: .offeringIdentifier(offeringIdentifier),
|
|
fonts: fonts,
|
|
displayCloseButton: displayCloseButton,
|
|
shouldBlockTouchEvents: shouldBlockTouchEvents,
|
|
dismissRequestedHandler: dismissRequestedHandler
|
|
)
|
|
}
|
|
|
|
init(
|
|
content: PaywallViewConfiguration.Content,
|
|
fonts: PaywallFontProvider,
|
|
displayCloseButton: Bool,
|
|
shouldBlockTouchEvents: Bool,
|
|
dismissRequestedHandler: ((_ controller: PaywallViewController) -> Void)?
|
|
) {
|
|
self.shouldBlockTouchEvents = shouldBlockTouchEvents
|
|
self.dismissRequestedHandler = dismissRequestedHandler
|
|
let handler = PurchaseHandler.default()
|
|
|
|
self.configuration = .init(
|
|
content: content,
|
|
mode: Self.mode,
|
|
fonts: fonts,
|
|
displayCloseButton: displayCloseButton,
|
|
purchaseHandler: handler
|
|
)
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
// swiftlint:disable:next missing_docs
|
|
public required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
if self.hostingController == nil {
|
|
self.hostingController = self.createHostingController()
|
|
}
|
|
}
|
|
|
|
public override func viewDidDisappear(_ animated: Bool) {
|
|
if self.isBeingDismissed {
|
|
self.delegate?.paywallViewControllerWasDismissed?(self)
|
|
}
|
|
super.viewDidDisappear(animated)
|
|
}
|
|
|
|
/// - Warning: For internal use only
|
|
@objc(updateWithOffering:)
|
|
public func update(with offering: Offering) {
|
|
self.configuration.content = .offering(offering)
|
|
}
|
|
|
|
/// - Warning: For internal use only
|
|
@objc(updateWithOfferingIdentifier:)
|
|
public func update(with offeringIdentifier: String) {
|
|
self.configuration.content = .offeringIdentifier(offeringIdentifier)
|
|
}
|
|
|
|
/// - Warning: For internal use only
|
|
@objc(updateWithDisplayCloseButton:)
|
|
public func update(with displayCloseButton: Bool) {
|
|
self.configuration.displayCloseButton = displayCloseButton
|
|
}
|
|
|
|
/// - Warning: For internal use only
|
|
@objc(updateFontWithFontName:)
|
|
public func updateFont(with fontName: String) {
|
|
self.configuration.fonts = CustomPaywallFontProvider(fontName: fontName)
|
|
}
|
|
|
|
// Overriding touches conditionally to deal with https://github.com/RevenueCat/purchases-flutter/issues/1023
|
|
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
if !shouldBlockTouchEvents {
|
|
super.touchesBegan(touches, with: event)
|
|
}
|
|
}
|
|
|
|
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
if !shouldBlockTouchEvents {
|
|
super.touchesMoved(touches, with: event)
|
|
}
|
|
}
|
|
|
|
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
if !shouldBlockTouchEvents {
|
|
super.touchesEnded(touches, with: event)
|
|
}
|
|
}
|
|
|
|
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
if !shouldBlockTouchEvents {
|
|
super.touchesCancelled(touches, with: event)
|
|
}
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
class var mode: PaywallViewMode {
|
|
return .fullScreen
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private var hostingController: UIHostingController<PaywallContainerView>? {
|
|
willSet {
|
|
guard let oldController = self.hostingController else { return }
|
|
|
|
oldController.willMove(toParent: nil)
|
|
oldController.view.removeFromSuperview()
|
|
oldController.removeFromParent()
|
|
}
|
|
|
|
didSet {
|
|
guard let newController = self.hostingController else { return }
|
|
|
|
self.addChild(newController)
|
|
|
|
self.view.subviews.forEach { $0.removeFromSuperview() }
|
|
|
|
self.view.addSubview(newController.view)
|
|
newController.didMove(toParent: self)
|
|
|
|
NSLayoutConstraint.activate([
|
|
newController.view.topAnchor.constraint(equalTo: self.view.topAnchor),
|
|
newController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
|
newController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
|
newController.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
|
|
])
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - PaywallViewControllerDelegate
|
|
|
|
/// Delegate for ``PaywallViewController``.
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
|
|
@objc(RCPaywallViewControllerDelegate)
|
|
public protocol PaywallViewControllerDelegate: AnyObject {
|
|
|
|
/// Notifies that a purchase has started in a ``PaywallViewController``.
|
|
@objc(paywallViewControllerDidStartPurchase:)
|
|
optional func paywallViewControllerDidStartPurchase(_ controller: PaywallViewController)
|
|
|
|
/// Notifies that a purchase has started in a ``PaywallViewController``.
|
|
@objc(paywallViewController:didStartPurchaseWithPackage:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didStartPurchaseWith package: Package)
|
|
|
|
/// Notifies that a purchase has completed in a ``PaywallViewController``.
|
|
@objc(paywallViewController:didFinishPurchasingWithCustomerInfo:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didFinishPurchasingWith customerInfo: CustomerInfo)
|
|
|
|
/// Notifies that a purchase has completed in a ``PaywallViewController``.
|
|
@objc(paywallViewController:didFinishPurchasingWithCustomerInfo:transaction:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didFinishPurchasingWith customerInfo: CustomerInfo,
|
|
transaction: StoreTransaction?)
|
|
|
|
/// Notifies that a purchase has been cancelled in a ``PaywallViewController``.
|
|
@objc(paywallViewControllerDidCancelPurchase:)
|
|
optional func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController)
|
|
|
|
/// Notifies that the purchase operation has failed in a ``PaywallViewController``.
|
|
@objc(paywallViewController:didFailPurchasingWithError:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didFailPurchasingWith error: NSError)
|
|
|
|
/// Notifies that a restore has started in a ``PaywallViewController``.
|
|
@objc(paywallViewControllerDidStartRestore:)
|
|
optional func paywallViewControllerDidStartRestore(_ controller: PaywallViewController)
|
|
|
|
/// Notifies that the restore operation has completed in a ``PaywallViewController``.
|
|
///
|
|
/// - Warning: Receiving a ``CustomerInfo``does not imply that the user has any entitlements,
|
|
/// simply that the process was successful. You must verify the ``CustomerInfo/entitlements``
|
|
/// to confirm that they are active.
|
|
@objc(paywallViewController:didFinishRestoringWithCustomerInfo:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didFinishRestoringWith customerInfo: CustomerInfo)
|
|
|
|
/// Notifies that the restore operation has failed in a ``PaywallViewController``.
|
|
@objc(paywallViewController:didFailRestoringWithError:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didFailRestoringWith error: NSError)
|
|
|
|
/// Notifies that the ``PaywallViewController`` was dismissed.
|
|
@objc(paywallViewControllerWasDismissed:)
|
|
optional func paywallViewControllerWasDismissed(_ controller: PaywallViewController)
|
|
|
|
/// For internal use only.
|
|
@objc(paywallViewController:didChangeSizeTo:)
|
|
optional func paywallViewController(_ controller: PaywallViewController,
|
|
didChangeSizeTo size: CGSize)
|
|
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
|
|
private extension PaywallViewController {
|
|
|
|
// swiftlint:disable:next function_body_length
|
|
func createHostingController() -> UIHostingController<PaywallContainerView> {
|
|
var onRequestedDismissal: (() -> Void)?
|
|
if let dismissRequestedHandler = self.dismissRequestedHandler {
|
|
onRequestedDismissal = { [weak self] in
|
|
guard let self = self else { return }
|
|
dismissRequestedHandler(self)
|
|
}
|
|
}
|
|
|
|
let container = PaywallContainerView(
|
|
configuration: self.configuration,
|
|
purchaseStarted: { [weak self] package in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewControllerDidStartPurchase?(self)
|
|
self.delegate?.paywallViewController?(self, didStartPurchaseWith: package)
|
|
},
|
|
purchaseCompleted: { [weak self] transaction, customerInfo in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewController?(self, didFinishPurchasingWith: customerInfo)
|
|
self.delegate?.paywallViewController?(self,
|
|
didFinishPurchasingWith: customerInfo,
|
|
transaction: transaction)
|
|
},
|
|
purchaseCancelled: { [weak self] in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewControllerDidCancelPurchase?(self)
|
|
},
|
|
restoreCompleted: { [weak self] customerInfo in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewController?(self, didFinishRestoringWith: customerInfo)
|
|
},
|
|
purchaseFailure: { [weak self] error in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewController?(self, didFailPurchasingWith: error)
|
|
},
|
|
restoreStarted: { [weak self] in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewControllerDidStartRestore?(self)
|
|
},
|
|
restoreFailure: { [weak self] error in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewController?(self, didFailRestoringWith: error)
|
|
},
|
|
requestedDismissal: onRequestedDismissal,
|
|
onSizeChange: { [weak self] in
|
|
guard let self else { return }
|
|
self.delegate?.paywallViewController?(self, didChangeSizeTo: $0)
|
|
}
|
|
)
|
|
|
|
let controller = UIHostingController(rootView: container)
|
|
|
|
// make the background of the container clear so that if there are cutouts, they don't get
|
|
// overridden by the hostingController's view's background.
|
|
controller.view.backgroundColor = .clear
|
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
return controller
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
|
|
private struct PaywallContainerView: View {
|
|
|
|
var configuration: PaywallViewConfiguration
|
|
|
|
let purchaseStarted: PurchaseOfPackageStartedHandler
|
|
let purchaseCompleted: PurchaseCompletedHandler
|
|
let purchaseCancelled: PurchaseCancelledHandler
|
|
let restoreCompleted: PurchaseOrRestoreCompletedHandler
|
|
let purchaseFailure: PurchaseFailureHandler
|
|
let restoreStarted: RestoreStartedHandler
|
|
let restoreFailure: PurchaseFailureHandler
|
|
let requestedDismissal: (() -> Void)?
|
|
|
|
let onSizeChange: (CGSize) -> Void
|
|
|
|
var body: some View {
|
|
PaywallView(configuration: self.configuration)
|
|
.onPurchaseStarted(self.purchaseStarted)
|
|
.onPurchaseCompleted(self.purchaseCompleted)
|
|
.onPurchaseCancelled(self.purchaseCancelled)
|
|
.onPurchaseFailure(self.purchaseFailure)
|
|
.onRestoreStarted(self.restoreStarted)
|
|
.onRestoreCompleted(self.restoreCompleted)
|
|
.onRestoreFailure(self.restoreFailure)
|
|
.onSizeChange(self.onSizeChange)
|
|
.applyIfLet(self.requestedDismissal, apply: { view, requestedDismissal in
|
|
view.onRequestedDismissal(requestedDismissal)
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension View {
|
|
@ViewBuilder func applyIf<Content: View>(_ condition: Bool, apply: (Self) -> Content) -> some View {
|
|
if condition {
|
|
apply(self)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension View {
|
|
@ViewBuilder func applyIfLet<T, Content: View>(_ value: T?, apply: (Self, T) -> Content) -> some View {
|
|
if let value = value {
|
|
apply(self, value)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
// swiftlint:enable file_length
|