Custom Paywall
Build custom paywalls matching the app's design system. DO NOT use RevenueCat's built-in PaywallView — always build custom UI.
BANNED Patterns
Do NOT create:
SubscriptionPlan/SubscriptionTier/Planenum or struct with hardcoded prices- Models with
var price: Stringreturning "$X.XX" - ViewModels holding
[SubscriptionPlan]— hold[Package]from RevenueCat instead - Any fallback or sample data with price strings
PaywallViewModelor any ViewModel that callsPurchases.shared— PaywallView usesSubscriptionManager.shareddirectly.sheetfor paywall presentation — MUST use.fullScreenCover- Hardcoded savings percentages ("Save ~17%") — must be calculated from StoreKit prices
Data Source: RevenueCat Package Objects
The PaywallView gets its data from SubscriptionManager.packages which holds RevenueCat Package objects. ALL pricing comes from package.storeProduct.localizedPriceString. ALL plan names come from package.storeProduct.localizedTitle.
PaywallView Pattern (REQUIRED)
struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
@State private var manager = SubscriptionManager.shared
var body: some View {
ZStack {
ScrollView {
VStack(spacing: AppTheme.Spacing.lg) {
closeButton
heroSection
planCards
ctaButton
footer
}
.padding(AppTheme.Spacing.md)
}
if manager.purchaseSuccess {
purchaseSuccessOverlay
}
}
.task { await manager.loadOfferings() }
.onChange(of: manager.purchaseSuccess) { _, success in
if success {
Task {
try? await Task.sleep(for: .seconds(1.5))
manager.resetPurchaseSuccess()
dismiss()
}
}
}
}
private var planCards: some View {
VStack(spacing: AppTheme.Spacing.sm) {
ForEach(manager.packages, id: \.identifier) { package in
PaywallPlanCard(
package: package,
isSelected: manager.selectedPackage?.identifier == package.identifier,
onTap: { manager.selectedPackage = package }
)
}
}
}
private var ctaButton: some View {
Button {
guard let pkg = manager.selectedPackage else { return }
Task { await manager.purchase(pkg) }
} label: {
Text("Subscribe")
.font(AppTheme.Fonts.headline)
}
.buttonStyle(.borderedProminent)
.disabled(manager.selectedPackage == nil || manager.isLoading)
}
}
Purchase Success Overlay (REQUIRED)
After a successful purchase, the paywall MUST show a success overlay before auto-dismissing. This gives the user clear confirmation that their purchase went through.
private var purchaseSuccessOverlay: some View {
ZStack {
Color.black.opacity(0.6)
.ignoresSafeArea()
VStack(spacing: AppTheme.Spacing.md) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(AppTheme.Colors.success)
.symbolEffect(.bounce, value: manager.purchaseSuccess)
Text("You're all set!")
.font(AppTheme.Fonts.title2)
.foregroundStyle(.white)
Text("Your premium access is now active")
.font(AppTheme.Fonts.body)
.foregroundStyle(.white.opacity(0.8))
}
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.3), value: manager.purchaseSuccess)
}
Key points:
- The overlay appears immediately when
purchaseSuccessbecomestrue - After 1.5 seconds, the paywall resets the flag and auto-dismisses
- The underlying views don't need to do anything —
isPremiumis already updated, so feature gates unlock automatically - Use
AppTheme.Colors.successif defined, otherwise use.green
Plan Card Pattern
struct PaywallPlanCard: View {
let package: Package // RevenueCat Package — NOT a custom model
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: AppTheme.Spacing.sm) {
Text(package.storeProduct.localizedTitle) // from store
.font(AppTheme.Fonts.headline)
Text(package.storeProduct.localizedPriceString) // from store
.font(AppTheme.Fonts.title2)
Text(package.storeProduct.localizedDescription) // from store
.font(AppTheme.Fonts.subheadline)
}
.padding(AppTheme.Spacing.md)
.background(isSelected ? AppTheme.Colors.primary.opacity(0.1) : AppTheme.Colors.surface)
.cornerRadius(AppTheme.Style.cornerRadius)
.overlay(
RoundedRectangle(cornerRadius: AppTheme.Style.cornerRadius)
.stroke(isSelected ? AppTheme.Colors.primary : .clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
Savings Badge (REQUIRED for multi-duration plans)
When showing annual alongside monthly plans, calculate savings dynamically from StoreKit prices. NEVER hardcode savings percentages.
// REQUIRED calculation — in the PlanCard or a helper
private var savingsText: String? {
guard let monthlyPackage = manager.packages.first(where: { $0.packageType == .monthly }),
let annualPackage = manager.packages.first(where: { $0.packageType == .annual }) else {
return nil
}
let monthlyAnnualized = monthlyPackage.storeProduct.price * 12
let annualPrice = annualPackage.storeProduct.price
guard monthlyAnnualized > annualPrice else { return nil }
let savings = ((monthlyAnnualized - annualPrice) / monthlyAnnualized * 100)
.formatted(.number.precision(.fractionLength(0)))
return "Save \(savings)%"
}
BANNED:
- Hardcoded savings strings like "Save ~17%", "Save 50%"
- Savings percentages that don't come from a calculation of actual StoreKit prices
Apple Compliance (mandatory, post-Jan 2026)
Every paywall MUST follow these rules:
- Close button immediately visible — no cooldown timer
- Full billed amount most prominent — minimum 16pt font
- No toggles — use tappable cards
- No fake urgency — no countdown timers
- Schedule 2, Section 3.8(b) disclosure in footer
- Terms of Service link — tappable in-app link
- Privacy Policy link — tappable in-app link
- Restore Purchases button — visible without scrolling
- Dynamic pricing — from
package.storeProduct.localizedPriceString, never hardcoded - Trial timeline — show exact dates if offering trial
Presentation — MUST use .fullScreenCover
Present paywalls as .fullScreenCover, NEVER as .sheet:
// REQUIRED
.fullScreenCover(isPresented: $showPaywall) {
PaywallView()
}
// BANNED — never use .sheet for paywalls
.sheet(isPresented: $showPaywall) { // WRONG
PaywallView()
}
Why: .sheet allows swipe-to-dismiss which bypasses mandatory disclosures. Apple requires the close button to be the only dismissal mechanism so users see compliance text.
See Compliance Checklist, Subscription Paywall, Credit Paywall, and Disclosure Text for templates.
Scan to join WeChat group