Skip to content

Commit

Permalink
Paywalls: moved purchasing state to PurchaseHandler (#2923)
Browse files Browse the repository at this point in the history
This allows `PaywallView` to handle state handling during a purchase:
instead of the previous behavior where only `PurchaseButton` would
disable itself, now the entire `PaywallView` is disabled.

I extracted `PackageButtonStyle` so any template with multi-package
selection can get this same behavior: the package selection also becomes
disabled, as well as any other button (like restore purchases).

I've also cleaned up the templates since we no longer needed the
"Content" inside view, and they can be simplified with a single type.

By making `PurchaseHandler` `@MainActor` we also ensure that all these
state transitions lead to UI changes happening exclusively on the main
thread.
  • Loading branch information
NachoSoto committed Sep 1, 2023
1 parent ff4adac commit 2ba03ef
Show file tree
Hide file tree
Showing 15 changed files with 132 additions and 102 deletions.
1 change: 1 addition & 0 deletions RevenueCatUI/Data/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ import SwiftUI
enum Constants {

static let defaultAnimation: Animation = .easeIn(duration: 0.2)
static let fastAnimation: Animation = .easeIn(duration: 0.1)

}
7 changes: 6 additions & 1 deletion RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftUI
@available(watchOS, unavailable, message: "RevenueCatUI does not support watchOS yet")
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(macCatalyst, unavailable, message: "RevenueCatUI does not support Catalyst yet")
@MainActor
public struct PaywallView: View {

private let mode: PaywallViewMode
Expand All @@ -22,6 +23,7 @@ public struct PaywallView: View {
/// an error will be displayed.
/// - Warning: `Purchases` must have been configured prior to displaying it.
/// If you want to handle that, you can use ``init(offering:mode:)`` instead.
@MainActor
public init(mode: PaywallViewMode = .default) {
self.init(
offering: nil,
Expand All @@ -35,6 +37,7 @@ public struct PaywallView: View {
/// - Note: if `offering` does not have a current paywall, or it fails to load due to invalid data,
/// a default paywall will be displayed.
/// - Warning: `Purchases` must have been configured prior to displaying it.
@MainActor
public init(offering: Offering, mode: PaywallViewMode = .default) {
self.init(
offering: offering,
Expand All @@ -44,6 +47,7 @@ public struct PaywallView: View {
)
}

@MainActor
init(
offering: Offering?,
mode: PaywallViewMode = .default,
Expand Down Expand Up @@ -158,7 +162,7 @@ struct LoadedOfferingPaywallView: View {
self._introEligibility = .init(
wrappedValue: .init(introEligibilityChecker: introEligibility)
)
self.purchaseHandler = purchaseHandler
self._purchaseHandler = .init(initialValue: purchaseHandler)
}

var body: some View {
Expand All @@ -170,6 +174,7 @@ struct LoadedOfferingPaywallView: View {
.environmentObject(self.introEligibility)
.environmentObject(self.purchaseHandler)
.hidden(if: self.shouldHidePaywall)
.disabled(self.purchaseHandler.actionInProgress)

if let aspectRatio = self.mode.aspectRatio {
view.aspectRatio(aspectRatio, contentMode: .fit)
Expand Down
26 changes: 24 additions & 2 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import StoreKit
import SwiftUI

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
@MainActor
final class PurchaseHandler: ObservableObject {

typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData
Expand All @@ -18,8 +19,17 @@ final class PurchaseHandler: ObservableObject {
private let purchaseBlock: PurchaseBlock
private let restoreBlock: RestoreBlock

/// Whether a purchase or restore is currently in progress
@Published
var purchased: Bool = false
fileprivate(set) var actionInProgress: Bool = false

/// Whether a purchase was successfully completed.
@Published
fileprivate(set) var purchased: Bool = false

/// Whether a restore was successfully completed.
@Published
fileprivate(set) var restored: Bool = false

convenience init(purchases: Purchases = .shared) {
self.init { package in
Expand All @@ -43,6 +53,11 @@ final class PurchaseHandler: ObservableObject {
extension PurchaseHandler {

func purchase(package: Package) async throws -> PurchaseResultData {
withAnimation(Constants.fastAnimation) {
self.actionInProgress = true
}
defer { self.actionInProgress = false }

let result = try await self.purchaseBlock(package)

if !result.userCancelled {
Expand All @@ -55,7 +70,14 @@ extension PurchaseHandler {
}

func restorePurchases() async throws -> CustomerInfo {
return try await self.restoreBlock()
self.actionInProgress = true
defer { self.actionInProgress = false }

let result = try await self.restoreBlock()

self.restored = true

return result
}

/// Creates a copy of this `PurchaseHandler` wrapping the purchase and restore blocks.
Expand Down
53 changes: 17 additions & 36 deletions RevenueCatUI/Templates/MultiPackageBoldTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,20 @@ import SwiftUI
struct MultiPackageBoldTemplate: TemplateViewType {

private let configuration: TemplateViewConfiguration
@EnvironmentObject
private var introEligibility: IntroEligibilityViewModel

init(_ configuration: TemplateViewConfiguration) {
self.configuration = configuration
}

var body: some View {
MultiPackageTemplateContent(configuration: self.configuration,
introEligibility: self.introEligibility.allEligibility)
}

}

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

private var configuration: TemplateViewConfiguration
private var introEligibility: [Package: IntroEligibilityStatus]
private var localization: [Package: ProcessedLocalizedConfiguration]

@State
private var selectedPackage: Package

@EnvironmentObject
private var introEligibilityViewModel: IntroEligibilityViewModel
@EnvironmentObject
private var purchaseHandler: PurchaseHandler

init(configuration: TemplateViewConfiguration, introEligibility: [Package: IntroEligibilityStatus]) {
init(_ configuration: TemplateViewConfiguration) {
self._selectedPackage = .init(initialValue: configuration.packages.default.content)

self.configuration = configuration
self.introEligibility = introEligibility
self.localization = Dictionary(
uniqueKeysWithValues: configuration.packages.all
.lazy
Expand Down Expand Up @@ -72,7 +54,7 @@ private struct MultiPackageTemplateContent: View {
purchaseHandler: self.purchaseHandler)
}
}
.animation(.easeInOut(duration: 0.1), value: self.selectedPackage)
.animation(Constants.fastAnimation, value: self.selectedPackage)
.frame(maxHeight: .infinity)
.multilineTextAlignment(.center)
.frame(maxHeight: .infinity)
Expand Down Expand Up @@ -105,13 +87,15 @@ private struct MultiPackageTemplateContent: View {
private var packages: some View {
VStack(spacing: 8) {
ForEach(self.configuration.packages.all, id: \.content.id) { package in
let isSelected = self.selectedPackage === package.content

Button {
self.selectedPackage = package.content
} label: {
self.packageButton(package, selected: self.selectedPackage === package.content)
self.packageButton(package, selected: isSelected)
.contentShape(Rectangle())
}
.buttonStyle(PackageButtonStyle())
.buttonStyle(PackageButtonStyle(isSelected: isSelected))
}
}
.padding(.bottom)
Expand Down Expand Up @@ -175,11 +159,11 @@ private struct MultiPackageTemplateContent: View {
private var subscribeButton: some View {
PurchaseButton(
package: self.selectedPackage,
purchaseHandler: self.purchaseHandler,
colors: self.configuration.colors,
localization: self.selectedLocalization,
introEligibility: self.introEligibility[self.selectedPackage],
mode: self.configuration.mode
mode: self.configuration.mode,
purchaseHandler: self.purchaseHandler
)
}

Expand Down Expand Up @@ -214,6 +198,12 @@ private struct MultiPackageTemplateContent: View {
.padding(.top)
}

// MARK: -

private var introEligibility: [Package: IntroEligibilityStatus] {
return self.introEligibilityViewModel.allEligibility
}

private var selectedBackgroundColor: Color { self.configuration.colors.accent2Color }

private static let iconSize: CGFloat = 100
Expand All @@ -224,7 +214,7 @@ private struct MultiPackageTemplateContent: View {
// MARK: - Extensions

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

func localization(for package: Package) -> ProcessedLocalizedConfiguration {
// Because of how packages are constructed this is known to exist
Expand All @@ -236,12 +226,3 @@ private extension MultiPackageTemplateContent {
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private struct PackageButtonStyle: ButtonStyle {

func makeBody(configuration: ButtonStyleConfiguration) -> some View {
configuration.label
}

}
34 changes: 11 additions & 23 deletions RevenueCatUI/Templates/OnePackageStandardTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,15 @@ import SwiftUI
struct OnePackageStandardTemplate: TemplateViewType {

private let configuration: TemplateViewConfiguration
@EnvironmentObject
private var introEligibility: IntroEligibilityViewModel

init(_ configuration: TemplateViewConfiguration) {
self.configuration = configuration
}

var body: some View {
OnePackageTemplateContent(configuration: self.configuration,
introEligibility: self.introEligibility.singleEligibility)
}

}

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

private var configuration: TemplateViewConfiguration
private var introEligibility: IntroEligibilityStatus?
private var localization: ProcessedLocalizedConfiguration

@EnvironmentObject
private var introEligibilityViewModel: IntroEligibilityViewModel
@EnvironmentObject
private var purchaseHandler: PurchaseHandler

init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) {
init(_ configuration: TemplateViewConfiguration) {
self.configuration = configuration
self.introEligibility = introEligibility
self.localization = configuration.packages.single.localization
}

Expand Down Expand Up @@ -128,14 +110,20 @@ private struct OnePackageTemplateContent: View {
private var button: some View {
PurchaseButton(
package: self.configuration.packages.single.content,
purchaseHandler: self.purchaseHandler,
colors: self.configuration.colors,
localization: self.localization,
introEligibility: self.introEligibility,
mode: self.configuration.mode
mode: self.configuration.mode,
purchaseHandler: self.purchaseHandler
)
}

// MARK: -

private var introEligibility: IntroEligibilityStatus? {
return self.introEligibilityViewModel.singleEligibility
}

private static let imageAspectRatio = 1.1

}
Expand Down
36 changes: 10 additions & 26 deletions RevenueCatUI/Templates/OnePackageWithFeaturesTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,15 @@ import SwiftUI
struct OnePackageWithFeaturesTemplate: TemplateViewType {

private let configuration: TemplateViewConfiguration
@EnvironmentObject
private var introEligibility: IntroEligibilityViewModel

init(_ configuration: TemplateViewConfiguration) {
self.configuration = configuration
}

var body: some View {
OnePackageWithFeaturesTemplateContent(
configuration: self.configuration,
introEligibility: self.introEligibility.singleEligibility
)
}

}

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

private var configuration: TemplateViewConfiguration
private var introEligibility: IntroEligibilityStatus?
private var localization: ProcessedLocalizedConfiguration
private let localization: ProcessedLocalizedConfiguration

@EnvironmentObject
private var introEligibilityViewModel: IntroEligibilityViewModel
@EnvironmentObject
private var purchaseHandler: PurchaseHandler

init(configuration: TemplateViewConfiguration, introEligibility: IntroEligibilityStatus?) {
init(_ configuration: TemplateViewConfiguration) {
self.configuration = configuration
self.introEligibility = introEligibility
self.localization = configuration.packages.single.localization
}

Expand Down Expand Up @@ -84,11 +64,11 @@ private struct OnePackageWithFeaturesTemplateContent: View {

PurchaseButton(
package: self.configuration.packages.single.content,
purchaseHandler: self.purchaseHandler,
colors: self.configuration.colors,
localization: self.localization,
introEligibility: self.introEligibility,
mode: self.configuration.mode
mode: self.configuration.mode,
purchaseHandler: self.purchaseHandler
)
.padding(.bottom)

Expand Down Expand Up @@ -117,6 +97,10 @@ private struct OnePackageWithFeaturesTemplateContent: View {
.edgesIgnoringSafeArea(.all)
}

private var introEligibility: IntroEligibilityStatus? {
return self.introEligibilityViewModel.singleEligibility
}

@ScaledMetric(relativeTo: .title)
private var iconSize = 55

Expand Down
12 changes: 4 additions & 8 deletions RevenueCatUI/Views/AsyncButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,17 @@ struct AsyncButton<Label>: View where Label: View {
@State
private var error: NSError?

@State
private var inProgress: Bool = false

init(action: @escaping Action, @ViewBuilder label: () -> Label) {
init(
action: @escaping Action,
@ViewBuilder label: () -> Label
) {
self.action = action
self.label = label()
}

var body: some View {
Button {
Task<Void, Never> {
self.inProgress = true
defer { self.inProgress = false }

do {
try await self.action()
} catch let error as NSError {
Expand All @@ -42,7 +39,6 @@ struct AsyncButton<Label>: View where Label: View {
} label: {
self.label
}
.disabled(self.inProgress)
.displayError(self.$error)
}

Expand Down
Loading

0 comments on commit 2ba03ef

Please sign in to comment.