Skip to content

Commit

Permalink
Paywalls: new PaywallFooterView to replace modes (#3051)
Browse files Browse the repository at this point in the history
## Motivation

It was a little unclear how and when to use the different `PaywallView`
modes (`.fullscreen`, `.card`, and `.condensedCard`). The actual
functionality of the modes is great but they seemed like three different
components in one view and it was just trying to do too much for one
public API.

This change will...
- Remove modes from being a public parameter in `PaywallView` and only
allow it to be full screen
- Create a new `PaywallFooterView` which will combine `.footer`
(previously `.card`) and `.condensedFooter` (previously
`.condensedCard`)
- Removed the `modes` parameter from `PaywallView`

### New `PaywallFooterView`

- Wraps `PaywallView` and will only choose between `.footer` and
`.footerCondensed`
- Currently offers a `condensed` parameter but this might be switched to
being a remote setting inside of `PaywalData`

### New `.paywallFooter()` view modifier

- Easy way for developers to place `PaywallFooterView` where it is
supposed to be placed
- This places the view in `.safeAreaInset(edge: .bottom)`
- This works on any view but is ideal in `ScrollView` as it will
automatically handle adjusting the scroll insets with the paywalls size

### New footer structure and animation (with some hacks)

- No parts of `PaywallFooterView` uses `.overlay` anymore
  - The whole grows in `.condensedFooter` when "All Plans" is toggled

## Demo

### My sample app


https://github.com/RevenueCat/purchases-ios/assets/401294/17c52bba-956d-404e-b991-a2272e7fc4f1

### Simple sample app 


https://github.com/RevenueCat/purchases-ios/assets/401294/4ea94156-8367-4dea-9640-0cce8ffa1deb

---------

Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
  • Loading branch information
joshdholtz and NachoSoto committed Sep 1, 2023
1 parent 7977299 commit 42ce3fc
Show file tree
Hide file tree
Showing 13 changed files with 330 additions and 76 deletions.
2 changes: 1 addition & 1 deletion RevenueCatUI/Data/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ enum Constants {

static let defaultAnimation: Animation = .easeInOut(duration: 0.2)
static let fastAnimation: Animation = .easeInOut(duration: 0.1)
static let displayAllPlansAnimation: Animation = .easeInOut(duration: 0.2)
static let toggleAllPlansAnimation: Animation = .spring(response: 0.35, dampingFraction: 0.7)

static let defaultCornerRadius: CGFloat = 20

Expand Down
44 changes: 16 additions & 28 deletions RevenueCatUI/Modifiers/FooterHidingModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ extension View {

func hideFooterContent(
_ configuration: TemplateViewConfiguration,
hide: Bool,
offset: CGFloat
hide: Bool
) -> some View {
return self.modifier(FooterHidingModifier(configuration: configuration,
hide: hide,
offset: offset))
hide: hide))
}

}
Expand All @@ -31,38 +29,28 @@ private struct FooterHidingModifier: ViewModifier {

var configuration: TemplateViewConfiguration
var hide: Bool
var offset: CGFloat

func body(content: Content) -> some View {
switch self.configuration.mode {
case .fullScreen, .footer:
case .fullScreen:
// These modes don't support hiding the content
content
.padding(.vertical)

case .condensedFooter:
// "Hidden view" so it doesn't contribute to size calculation
Rectangle()
.frame(height: VersionDetector.iOS15 ? 1 : 0) // Note: height "0" breaks iOS 15
.hidden()
.frame(maxWidth: .infinity)
.overlay(alignment: .bottom) {
// Content is displayed as an overlay so it's rendered over user's content
content
.padding(.vertical)
.padding(.bottom, Constants.defaultCornerRadius * 2.0)
.background(self.configuration.backgroundView)
.onSizeChange(.vertical) { self.height = $0 }
.opacity(self.hide ? 0 : 1)
.offset(
y: self.hide
? self.offset
: Constants.defaultCornerRadius * 3.0
)
.frame(height: self.hide ? 0 : nil)
.blur(radius: self.hide ? Self.blurRadius : 0)
}
case .footer:
content

case .condensedFooter:
content
.onSizeChange(.vertical) { if $0 > 0 { self.height = $0 } }
.opacity(self.hide ? 0 : 1)
.offset(
y: self.hide
? self.height
: 0
)
.frame(height: self.hide ? 0 : nil)
.blur(radius: self.hide ? Self.blurRadius : 0)
}
}

Expand Down
18 changes: 11 additions & 7 deletions RevenueCatUI/Modifiers/ViewExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,21 @@ extension View {

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@ViewBuilder
func scrollableIfNecessary(_ axis: Axis = .vertical) -> some View {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
ViewThatFits(in: axis.scrollViewAxis) {
self

ScrollView(axis.scrollViewAxis) {
func scrollableIfNecessary(_ axis: Axis = .vertical, enabled: Bool = true) -> some View {
if enabled {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
ViewThatFits(in: axis.scrollViewAxis) {
self

ScrollView(axis.scrollViewAxis) {
self
}
}
} else {
self.modifier(ScrollableIfNecessaryModifier(axis: axis))
}
} else {
self.modifier(ScrollableIfNecessaryModifier(axis: axis))
self
}
}
}
Expand Down
89 changes: 89 additions & 0 deletions RevenueCatUI/PaywallFooterView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// PaywallFooterView.swift
//
//
// Created by Josh Holtz on 8/21/23.
//

import RevenueCat
import SwiftUI

/// A SwiftUI view for displaying a `PaywallData` for an `Offering`.
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(watchOS, unavailable, message: "RevenueCatUI does not support watchOS yet")
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet")
@available(macCatalyst, unavailable, message: "RevenueCatUI does not support Catalyst yet")
internal struct PaywallFooterView: View {

private let condensed: Bool
private let fonts: PaywallFontProvider
private let introEligibility: TrialOrIntroEligibilityChecker?
private let purchaseHandler: PurchaseHandler?

private var offering: Offering?

/// Create a view that loads the `Offerings.current`.
/// - Note: If loading the current `Offering` fails (if the user is offline, for example),
/// 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.
init(
condensed: Bool = false,
fonts: PaywallFontProvider = DefaultPaywallFontProvider()
) {
self.init(
offering: nil,
condensed: condensed,
fonts: fonts,
introEligibility: .default(),
purchaseHandler: .default()
)
}

/// Create a view for the given `Offering`.
/// - 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.
init(
offering: Offering,
condensed: Bool = false,
fonts: PaywallFontProvider = DefaultPaywallFontProvider()
) {
self.init(
offering: offering,
condensed: condensed,
fonts: fonts,
introEligibility: .default(),
purchaseHandler: .default()
)
}

internal init(
offering: Offering?,
condensed: Bool,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
introEligibility: TrialOrIntroEligibilityChecker?,
purchaseHandler: PurchaseHandler?
) {
self.offering = offering
self.introEligibility = introEligibility
self.purchaseHandler = purchaseHandler
self.condensed = condensed
self.fonts = fonts
}

var body: some View {
PaywallView(
offering: self.offering,
mode: self.condensed ? .condensedFooter : .footer,
fonts: self.fonts,
introEligibility: self.introEligibility,
purchaseHandler: self.purchaseHandler
)
}

}
6 changes: 1 addition & 5 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,12 @@ public struct PaywallView: View {
/// - Note: If loading the current `Offering` fails (if the user is offline, for example),
/// 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.
/// If you want to handle that, you can use ``init(offering:)`` instead.
public init(
mode: PaywallViewMode = .default,
fonts: PaywallFontProvider = DefaultPaywallFontProvider()
) {
self.init(
offering: nil,
mode: mode,
fonts: fonts,
introEligibility: .default(),
purchaseHandler: .default()
Expand All @@ -46,12 +44,10 @@ public struct PaywallView: View {
/// - Warning: `Purchases` must have been configured prior to displaying it.
public init(
offering: Offering,
mode: PaywallViewMode = .default,
fonts: PaywallFontProvider = DefaultPaywallFontProvider()
) {
self.init(
offering: offering,
mode: mode,
fonts: fonts,
introEligibility: .default(),
purchaseHandler: .default()
Expand Down
13 changes: 5 additions & 8 deletions RevenueCatUI/Templates/Template2View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ struct Template2View: TemplateViewType {
@State
private var displayingAllPlans: Bool

@State
private var containerHeight: CGFloat = 10

@EnvironmentObject
private var introEligibilityViewModel: IntroEligibilityViewModel
@EnvironmentObject
Expand All @@ -41,7 +38,7 @@ struct Template2View: TemplateViewType {
Spacer()

self.scrollableContent
.scrollableIfNecessary()
.scrollableIfNecessary(enabled: self.configuration.mode.shouldDisplayPackages)

if self.configuration.mode.shouldDisplayInlineOfferDetails {
self.offerDetails(package: self.selectedPackage, selected: false)
Expand Down Expand Up @@ -85,18 +82,16 @@ struct Template2View: TemplateViewType {

if self.configuration.mode.shouldDisplayPackages {
self.packages
Spacer()
} else {
self.packages
.onSizeChange(.vertical) { if $0 > 0 { self.containerHeight = $0 } }
.hideFooterContent(self.configuration,
hide: !self.displayingAllPlans,
offset: self.containerHeight)
hide: !self.displayingAllPlans)
}
}
.frame(maxHeight: .infinity)
}

@ViewBuilder
private var packages: some View {
VStack(spacing: 8) {
ForEach(self.configuration.packages.all, id: \.content.id) { package in
Expand All @@ -112,6 +107,8 @@ struct Template2View: TemplateViewType {
}
}
.padding(.horizontal)

Spacer()
}

@ViewBuilder
Expand Down
4 changes: 2 additions & 2 deletions RevenueCatUI/Templates/Template4View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ struct Template4View: TemplateViewType {
self.packagesScrollView
} else {
self.packagesScrollView
.padding(.vertical)
.hideFooterContent(self.configuration,
hide: !self.displayingAllPlans,
offset: self.packageContentHeight)
hide: !self.displayingAllPlans)
}

IntroEligibilityStateView(
Expand Down
113 changes: 113 additions & 0 deletions RevenueCatUI/View+PresentPaywallFooter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// View+PresentPaywallFooter.swift
//
//
// Created by Josh Holtz on 8/18/23.
//

import RevenueCat
import SwiftUI

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet")
extension View {

/// Presents a ``PaywallFooterView`` at the bottom of a view that loads the `Offerings.current`.
/// ```swift
/// var body: some View {
/// YourPaywall()
/// .paywallFooter()
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func paywallFooter(
condensed: Bool = false,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
purchaseCompleted: PurchaseCompletedHandler? = nil
) -> some View {
return self.paywallFooter(
offering: nil,
condensed: condensed,
fonts: fonts,
introEligibility: nil,
purchaseCompleted: purchaseCompleted
)
}

/// Presents a ``PaywallFooterView`` at the bottom of a view with the given offering.
/// ```swift
/// var body: some View {
/// YourPaywall()
/// .paywallFooter(offering: offering)
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func paywallFooter(
offering: Offering,
condensed: Bool = false,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
purchaseCompleted: PurchaseCompletedHandler? = nil
) -> some View {
return self.paywallFooter(
offering: offering,
condensed: condensed,
fonts: fonts,
introEligibility: nil,
purchaseCompleted: purchaseCompleted
)
}

func paywallFooter(
offering: Offering?,
condensed: Bool = false,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
introEligibility: TrialOrIntroEligibilityChecker? = nil,
purchaseHandler: PurchaseHandler? = nil,
purchaseCompleted: PurchaseCompletedHandler? = nil
) -> some View {
return self
.modifier(PresentingPaywallFooterModifier(
offering: offering,
condensed: condensed,
purchaseCompleted: purchaseCompleted,
fontProvider: fonts,
introEligibility: introEligibility,
purchaseHandler: purchaseHandler
))
}
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
private struct PresentingPaywallFooterModifier: ViewModifier {

let offering: Offering?
let condensed: Bool

let purchaseCompleted: PurchaseCompletedHandler?
let fontProvider: PaywallFontProvider
let introEligibility: TrialOrIntroEligibilityChecker?
let purchaseHandler: PurchaseHandler?

func body(content: Content) -> some View {
content
.safeAreaInset(edge: .bottom) {
PaywallFooterView(
offering: self.offering,
condensed: self.condensed,
fonts: self.fontProvider,
introEligibility: self.introEligibility ?? .default(),
purchaseHandler: self.purchaseHandler ?? .default()
)
.onPurchaseCompleted {
self.purchaseCompleted?($0)
}
}
}
}
Loading

0 comments on commit 42ce3fc

Please sign in to comment.