Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paywalls: extracted PurchaseButton #2839

Merged
merged 3 commits into from
Jul 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import RevenueCat

#if DEBUG

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
internal enum TestData {

static let productWithIntroOffer = TestStoreProduct(
Expand Down Expand Up @@ -112,6 +113,10 @@ internal enum TestData {
callToActionForeground: "#000000"
)

#if canImport(SwiftUI) && canImport(UIKit)
static let colors: PaywallData.Configuration.Colors = .combine(light: Self.lightColors, dark: Self.darkColors)
#endif

static let customerInfo: CustomerInfo = {
let json = """
{
Expand Down Expand Up @@ -145,11 +150,11 @@ internal enum TestData {
return try! decoder.decode(CustomerInfo.self, from: Data(json.utf8))
}()

private static let localization: PaywallData.LocalizedConfiguration = .init(
static let localization: PaywallData.LocalizedConfiguration = .init(
title: "Ignite your child's curiosity",
subtitle: "Get access to all our educational content trusted by thousands of parents.",
callToAction: "Purchase for {{ price }}",
callToActionWithIntroOffer: nil,
callToActionWithIntroOffer: "Purchase for {{ price_per_month }} per month",
offerDetails: "{{ price_per_month }} per month",
offerDetailsWithIntroOffer: "Start your {{ intro_duration }} trial, then {{ price_per_month }} per month"
)
Expand Down Expand Up @@ -198,10 +203,11 @@ extension PurchaseHandler {

/// Creates a copy of this `PurchaseHandler` with a delay.
func with(delay: Duration) -> Self {
return .init { [purchaseBlock = self.purchaseBlock] in
try? await Task.sleep(for: delay)
return self.map { purchaseBlock in {
try? await Task.sleep(for: delay)

return try await purchaseBlock($0)
return try await purchaseBlock($0)
}
}
Comment on lines -201 to 211
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small refactor because I made the block private.

}
}
Expand Down
9 changes: 9 additions & 0 deletions RevenueCatUI/Helpers/ColorInformation+MultiScheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ extension PaywallData.Configuration.ColorInformation {
return light
}

return .combine(light: light, dark: dark)
}

}

extension PaywallData.Configuration.Colors {

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
static func combine(light: Self, dark: Self) -> Self {
return .init(
background: .init(light: light.background, dark: dark.background),
foreground: .init(light: light.foreground, dark: dark.foreground),
Expand Down
15 changes: 14 additions & 1 deletion RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ private struct LoadedOfferingPaywallView: View {
private let paywall: PaywallData
private let mode: PaywallViewMode
private let introEligibility: TrialOrIntroEligibilityChecker
private let purchaseHandler: PurchaseHandler

@ObservedObject
private var purchaseHandler: PurchaseHandler

init(
offering: Offering,
Expand All @@ -121,6 +123,7 @@ private struct LoadedOfferingPaywallView: View {
.createView(for: self.offering, mode: self.mode)
.environmentObject(self.introEligibility)
.environmentObject(self.purchaseHandler)
.hidden(if: self.shouldHidePaywall)

if let aspectRatio = self.mode.aspectRatio {
view.aspectRatio(aspectRatio, contentMode: .fit)
Expand All @@ -129,6 +132,16 @@ private struct LoadedOfferingPaywallView: View {
}
}

private var shouldHidePaywall: Bool {
switch self.mode {
case .fullScreen:
return false

case .card, .banner:
return self.purchaseHandler.purchased
}
}

Comment on lines +135 to +144
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aboedo this looks pretty good for now :) and it gets animated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if we should even consider making that variable public so that you can use it in the app

}

// MARK: - Extensions
Expand Down
23 changes: 20 additions & 3 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@

import RevenueCat
import StoreKit
import SwiftUI

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

typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData

let purchaseBlock: PurchaseBlock
private let purchaseBlock: PurchaseBlock

@Published
var purchased: Bool = false

convenience init(purchases: Purchases = .shared) {
self.init { package in
Expand All @@ -27,11 +31,24 @@ final class PurchaseHandler: ObservableObject {

}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension PurchaseHandler {

func purchase(package: Package) async throws -> PurchaseResultData {
return try await self.purchaseBlock(package)
let result = try await self.purchaseBlock(package)

if !result.userCancelled {
withAnimation(Constants.defaultAnimation) {
self.purchased = true
}
}
Comment on lines +40 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love that even this bit is something that we're handling now


return result
}

/// Creates a copy of this `PurchaseHandler` wrapping the purchase block
func map(_ block: @escaping (@escaping PurchaseBlock) -> PurchaseBlock) -> Self {
return .init(purchase: block(self.purchaseBlock))
}

}
71 changes: 8 additions & 63 deletions RevenueCatUI/Templates/Example1Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,35 +146,14 @@ private struct Example1TemplateContent: View {

@ViewBuilder
private var button: some View {
let package = self.configuration.packages.single.content

AsyncButton { @MainActor in
let cancelled = try await self.purchaseHandler.purchase(package: package).userCancelled

if !cancelled {
self.dismiss()
}
} label: {
IntroEligibilityStateView(
textWithNoIntroOffer: self.localization.callToAction,
textWithIntroOffer: self.localization.callToActionWithIntroOffer,
introEligibility: self.introEligibility
)
.foregroundColor(self.configuration.colors.callToActionForegroundColor)
.tint(self.configuration.colors.callToActionForegroundColor)
.frame(
maxWidth: self.configuration.mode.fullWidthButton
? .infinity
: nil
)
}
.font(self.configuration.mode.buttonFont)
.fontWeight(.semibold)
.tint(self.configuration.colors.callToActionBackgroundColor.gradient)
.buttonStyle(.borderedProminent)
.buttonBorderShape(self.configuration.mode.buttonBorderShape)
.controlSize(self.configuration.mode.buttonSize)
.frame(maxWidth: .infinity)
PurchaseButton(
package: self.configuration.packages.single.content,
purchaseHandler: self.purchaseHandler,
colors: self.configuration.colors,
localization: self.localization,
introEligibility: self.introEligibility,
mode: self.configuration.mode
)
}

private static let imageAspectRatio = 1.1
Expand Down Expand Up @@ -222,38 +201,4 @@ private extension PaywallViewMode {
}
}

var buttonFont: Font {
switch self {
case .fullScreen, .card: return .title2
case .banner: return .footnote
}
}

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

var buttonSize: ControlSize {
switch self {
case .fullScreen: return .large
case .card: return .regular
case .banner: return .small
}
}

var buttonBorderShape: ButtonBorderShape {
switch self {
case .fullScreen:
#if os(macOS)
return .roundedRectangle
#else
return .capsule
#endif
case .card, .banner: return .roundedRectangle
}
}

}
2 changes: 1 addition & 1 deletion RevenueCatUI/Views/AsyncButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import SwiftUI
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
struct AsyncButton<Label>: View where Label: View {

typealias Action = @Sendable () async throws -> Void
typealias Action = @Sendable @MainActor () async throws -> Void
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ensures that the action is always @MainActor so usages don't have to make it explicit.


private let action: Action
private let label: Label
Expand Down
144 changes: 144 additions & 0 deletions RevenueCatUI/Views/PurchaseButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// PurchaseButton.swift
//
//
// Created by Nacho Soto on 7/18/23.
//

import RevenueCat
import SwiftUI

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

let package: Package
let purchaseHandler: PurchaseHandler
let colors: PaywallData.Configuration.Colors
let localization: ProcessedLocalizedConfiguration
let introEligibility: IntroEligibilityStatus?
let mode: PaywallViewMode

@Environment(\.dismiss)
private var dismiss

var body: some View {
self.button
}

private var button: some View {
AsyncButton {
let cancelled = try await self.purchaseHandler.purchase(package: self.package).userCancelled

if !cancelled, case .fullScreen = self.mode {
self.dismiss()
}
} label: {
IntroEligibilityStateView(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this could be more appropriately named something like CTAText view

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's a bad name, but it's not just for the CTA.
From the docstring:

/// A view that can process intro eligibility and display different data based on the result.

It's used for CTA but also for other labels. It's just a label that uses a loading intro eligibility and then decides what to display. Better name suggestions welcome.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually that's what you approved: #2837
I'll hold on in case you have a suggestion for a better name though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging this, but let me know if you can think of a better name.

textWithNoIntroOffer: self.localization.callToAction,
textWithIntroOffer: self.localization.callToActionWithIntroOffer,
introEligibility: self.introEligibility
)
.foregroundColor(self.colors.callToActionForegroundColor)
.tint(self.colors.callToActionForegroundColor)
.frame(
maxWidth: self.mode.fullWidthButton
? .infinity
: nil
)
}
.font(self.mode.buttonFont.weight(.semibold))
.tint(self.colors.callToActionBackgroundColor.gradient)
.buttonBorderShape(self.mode.buttonBorderShape)
.controlSize(self.mode.buttonSize)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels somewhat confusing that self.mode.fullWidthButton doesn't refer to this width but rather the one for the button's label

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's just an implementation detail for how SwiftUI's layout works.

}

}

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

var buttonFont: Font {
switch self {
case .fullScreen, .card: return .title2
case .banner: return .footnote
}
}
Comment on lines +62 to +67
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how do you envision this scaling to different templates?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably would need to inject this font to PurchaseButton instead.


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

var buttonSize: ControlSize {
switch self {
case .fullScreen: return .large
case .card: return .regular
case .banner: return .small
}
}

var buttonBorderShape: ButtonBorderShape {
switch self {
case .fullScreen:
#if os(macOS)
return .roundedRectangle
#else
return .capsule
#endif
case .card, .banner:
return .roundedRectangle
}
}

}

// MARK: - Previews

#if DEBUG && canImport(SwiftUI) && canImport(UIKit)

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
struct PurchaseButton_Previews: PreviewProvider {

private struct Preview: View {

var mode: PaywallViewMode

@State
private var eligibility: IntroEligibilityStatus?

var body: some View {
PurchaseButton(
package: Self.package,
purchaseHandler: Self.purchaseHandler,
colors: TestData.colors,
localization: TestData.localization.processVariables(with: Self.package),
introEligibility: self.eligibility,
mode: self.mode
)
.task {
self.eligibility = await Self.eligibilityChecker.eligibility(for: Self.package)
}
}

private static let package: Package = TestData.packageWithIntroOffer
private static let eligibilityChecker: TrialOrIntroEligibilityChecker = .producing(eligibility: .eligible)
.with(delay: .seconds(0.3))
private static let purchaseHandler: PurchaseHandler = .mock()
.with(delay: .seconds(0.5))

}

static var previews: some View {
ForEach(PaywallViewMode.allCases, id: \.self) { mode in
Preview(mode: mode)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having small previewable views makes it easy to iterate on each small component.

.previewLayout(.sizeThatFits)
}
}

}

#endif