Skip to content

Commit

Permalink
Paywalls: support for multiple PaywallViewModes (#2834)
Browse files Browse the repository at this point in the history
This adds 2 new modes: `.square` and `.banner`:

![iOS16-testSquarePaywall
1](https://github.com/RevenueCat/purchases-ios/assets/685609/5d1a0a52-e02b-4877-90ac-fdeb2dcea058)
![iOS16-testBannerPaywall
1](https://github.com/RevenueCat/purchases-ios/assets/685609/a9f208d8-f920-4abd-a914-1dd86e940b1a)

-------

I've updated `Example1Template` to support both as an example, and added
that as snapshot tests.

This also adds a new constructor to `PaywallView` that doesn't require
passing an `Offering` or `PaywallData`, it automatically loads that from
`Purchases.shared`. With that, embedding these mini-paywalls is trivial:

```swift
struct MyApp: View {
  var body: some View {
    MyOtherViews()
    if !customerInfo.hasPro {
      PaywallView(mode: .card)
    }
  }
}
```

I've added these as examples to the testing app:

![simulator_screenshot_84270E1A-F7C6-4C5D-8773-F4F2E646CB0F](https://github.com/RevenueCat/purchases-ios/assets/685609/e368cab3-55af-4a87-88aa-b678deb5d2e5)
  • Loading branch information
NachoSoto committed Sep 7, 2023
1 parent c6e4624 commit 6f22d8c
Show file tree
Hide file tree
Showing 11 changed files with 432 additions and 77 deletions.
22 changes: 22 additions & 0 deletions RevenueCatUI/Data/PaywallViewMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,26 @@ public enum PaywallViewMode {
/// Paywall is displayed full-screen, with as much information as available.
case fullScreen

/// Paywall is displayed with a square aspect ratio. It can be embedded inside any other SwiftUI view.
case card

/// Paywall is displayed in a condensed format. It can be embedded inside any other SwiftUI view.
case banner

/// The default ``PaywallViewMode``: ``PaywallViewMode/fullScreen``.
public static let `default`: Self = .fullScreen

}

extension PaywallViewMode: CaseIterable {}

extension PaywallViewMode {

var isFullScreen: Bool {
switch self {
case .fullScreen: return true
case .card, .banner: return false
}
}

}
162 changes: 136 additions & 26 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,52 +9,145 @@ import SwiftUI
public struct PaywallView: View {

private let mode: PaywallViewMode
private let offering: Offering
private let paywall: PaywallData
private let introEligibility: TrialOrIntroEligibilityChecker?
private let purchaseHandler: PurchaseHandler?

/// Create a view for the given offering and paywal.
@State
private var offering: Offering?
@State
private var paywall: PaywallData?

/// Create a view that loads the `Offerings.current`.
/// - Warning: `Purchases` must have been configured prior to displaying it.
public init(mode: PaywallViewMode, offering: Offering, paywall: PaywallData) {
public init(mode: PaywallViewMode = .default) {
self.init(
offering: nil,
paywall: nil,
mode: mode,
introEligibility: Purchases.isConfigured ? .init() : nil,
purchaseHandler: Purchases.isConfigured ? .init() : nil
)
}

/// Create a view for the given offering and paywal.
/// - Warning: `Purchases` must have been configured prior to displaying it.
public init(offering: Offering,
paywall: PaywallData,
mode: PaywallViewMode = .default) {
self.init(
offering: offering,
paywall: paywall,
mode: mode,
introEligibility: Purchases.isConfigured ? .init() : nil,
purchaseHandler: Purchases.isConfigured ? .init() : nil
)
}

init(
mode: PaywallViewMode = .fullScreen,
offering: Offering,
paywall: PaywallData,
offering: Offering?,
paywall: PaywallData?,
mode: PaywallViewMode = .default,
introEligibility: TrialOrIntroEligibilityChecker?,
purchaseHandler: PurchaseHandler?
) {
self.mode = mode
self.offering = offering
self.paywall = paywall
self._offering = .init(initialValue: offering)
self._paywall = .init(initialValue: paywall)
self.introEligibility = introEligibility
self.purchaseHandler = purchaseHandler
self.mode = mode
}

// swiftlint:disable:next missing_docs
public var body: some View {
if let checker = self.introEligibility, let purchaseHandler = self.purchaseHandler {
self.paywall
.createView(for: self.offering, mode: self.mode)
.environmentObject(checker)
.environmentObject(purchaseHandler)
if let offering = self.offering {
if let paywall = self.paywall {
LoadedOfferingPaywallView(
offering: offering,
paywall: paywall,
mode: mode,
introEligibility: checker,
purchaseHandler: purchaseHandler
)
} else {
DebugErrorView("Offering '\(offering.identifier)' has no configured paywall",
releaseBehavior: .emptyView)
}
} else {
self.loadingView
.task {
// Fix-me: better error handling
self.offering = try? await Purchases.shared.offerings().current
self.paywall = self.offering?.paywall
}
}
} else {
DebugErrorView("Purchases has not been configured.", releaseBehavior: .fatalError)
}
}

@ViewBuilder
private var loadingView: some View {
ProgressView()
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private struct LoadedOfferingPaywallView: View {

private let offering: Offering
private let paywall: PaywallData
private let mode: PaywallViewMode
private let introEligibility: TrialOrIntroEligibilityChecker
private let purchaseHandler: PurchaseHandler

init(
offering: Offering,
paywall: PaywallData,
mode: PaywallViewMode,
introEligibility: TrialOrIntroEligibilityChecker,
purchaseHandler: PurchaseHandler
) {
self.offering = offering
self.paywall = paywall
self.mode = mode
self.introEligibility = introEligibility
self.purchaseHandler = purchaseHandler
}

var body: some View {
let view = self.paywall
.createView(for: self.offering, mode: self.mode)
.environmentObject(self.introEligibility)
.environmentObject(self.purchaseHandler)

if let aspectRatio = self.mode.aspectRatio {
view.aspectRatio(aspectRatio, contentMode: .fit)
} else {
DebugErrorView("Purchases has not been configured.",
releaseBehavior: .fatalError)
view
}
}

}

// MARK: - Extensions

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension PaywallViewMode {

var aspectRatio: CGFloat? {
switch self {
case .fullScreen: return nil
case .card: return 1
case .banner: return 8
}
}

}

// MARK: -

#if DEBUG

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
Expand All @@ -67,21 +160,38 @@ struct PaywallView_Previews: PreviewProvider {
let offering = TestData.offeringWithNoIntroOffer

if let paywall = offering.paywall {
PaywallView(
mode: .fullScreen,
offering: offering,
paywall: paywall,
introEligibility: TrialOrIntroEligibilityChecker
.producing(eligibility: .eligible)
.with(delay: .seconds(1)),
purchaseHandler: .mock()
.with(delay: .seconds(1))
)
ForEach(PaywallViewMode.allCases, id: \.self) { mode in
PaywallView(
offering: offering,
paywall: paywall,
mode: mode,
introEligibility: Self.introEligibility,
purchaseHandler: Self.purchaseHandler
)
.previewLayout(mode.layout)
}
} else {
Text("Preview not correctly setup, offering has no paywall!")
}
}

private static let introEligibility: TrialOrIntroEligibilityChecker = .producing(eligibility: .eligible)
.with(delay: .seconds(1))
private static let purchaseHandler: PurchaseHandler = .mock()
.with(delay: .seconds(1))

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension PaywallViewMode {

var layout: PreviewLayout {
switch self {
case .fullScreen: return .device
case .card, .banner: return .sizeThatFits
}
}

}

#endif
Loading

0 comments on commit 6f22d8c

Please sign in to comment.