Categories
iOS Swift SwiftUI

SubscriptionStoreView for iOS apps

While building my indie iOS app, I decided to go for a subscription type of approach. At first, I built a fully custom upsell view which showed all the subscription options and handled the purchase actions (not as straight-forward as it sounds). Later, I realized that since iOS 17 there is a SubscriptionStoreView which does all of this and allows some customization as well. The aim of the blog post it to demonstrate how to configure the SubscriptionStoreView and therefore saving time by not building a fully custom one.

Configuring the testing environment for subscriptions

Before we start using the SubscriptionStoreView, we will need to configure subscriptions with Xcode’s StoreKit configuration file. This allows us to test subscriptions without needing to set everything up on the App Store. Open the new file panel and select StoreKit Configuration File. After that, create a subscription group and some auto-renewable subscriptions. In the example app, I just created a “Premium” subscription group and added “Monthly” and “Yearly” auto-renewable subscriptions.

The last thing to do is setting this configuration file as the StoreKit configuration file to the current scheme.

Using the SubscriptionStoreView for managing subscriptions

Now we are ready to go and display the SubscriptionStoreView. Since we are going to configure it a bit by inserting custom content into it, we’ll create a wrapping SubscriptionsView and use the StoreKit provided view from there. Let’s see an example first.

struct SubscriptionsView: View {
var body: some View {
SubscriptionStoreView(productIDs: Subscriptions.subscriptionIDs) {
VStack {
VStack(spacing: 8) {
Image(systemName: "graduationcap.fill")
.resizable()
.scaledToFill()
.frame(width: 96, height: 96)
.foregroundStyle(Color.brown)
Text("Premium Access")
.font(.largeTitle)
Text("Unlock premium access for enabling **X** and **Y**.")
.multilineTextAlignment(.center)
}
.padding()
}
}
.subscriptionStorePolicyDestination(url: AppConstants.URLs.privacyPolicy, for: .privacyPolicy)
.subscriptionStorePolicyDestination(url: AppConstants.URLs.termsOfUse, for: .termsOfService)
.subscriptionStoreButtonLabel(.multiline)
.storeButton(.visible, for: .restorePurchases)
}
}

The view above is pretty much the minimal we need to get going. App review requires having terms of service and privacy policy visible and also the restore subscription button as well.

There is quite a bit of more customization of what we can do. Adding these view modifiers will give us a slightly different view.

.subscriptionStorePolicyForegroundStyle(.teal)
.subscriptionStoreControlStyle(.prominentPicker)
.subscriptionStoreControlIcon { product, info in
switch product.id {
case "premium_yearly": Image(systemName: "star.fill").foregroundStyle(.yellow)
default: EmptyView()
}
}
.background {
Color(red: 1.0, green: 1.0, blue: 0.95)
.ignoresSafeArea()
}

Note: While playing around with subscriptions, an essential tool is in Xcode’s Debug > StoreKit > Manage Transactions menu.

Observing subscription changes

Our app also needs to react to subscription changes. StoreKit provides Transaction.currentEntitlements and Transaction.updates for figuring out the current state and receiving updates. A simple way for setting this up in SwiftUI is to create a class and inserting it into SwiftUI environment. On app launch, we can read the current entitlements and set up the observation for updates.

@Observable final class Subscriptions {
static let subscriptionIDs = ["premium_monthly", "premium_yearly"]
// MARK: Starting the Subscription Observing
@ObservationIgnored private var observerTask: Task<Void, Never>?
func prepare() async {
guard observerTask == nil else { return }
observerTask = Task(priority: .background) {
for await verificationResult in Transaction.updates {
consumeVerificationResult(verificationResult)
}
}
for await verificationResult in Transaction.currentEntitlements {
consumeVerificationResult(verificationResult)
}
}
// MARK: Validating Purchased Subscription Status
private var verifiedActiveSubscriptionIDs = Set<String>()
private func consumeVerificationResult(_ result: VerificationResult<Transaction>) {
guard case .verified(let transaction) = result else {
return
}
if transaction.revocationDate != nil {
verifiedActiveSubscriptionIDs.remove(transaction.productID)
}
else if let expirationDate = transaction.expirationDate, expirationDate < Date.now {
verifiedActiveSubscriptionIDs.remove(transaction.productID)
}
else if transaction.isUpgraded {
verifiedActiveSubscriptionIDs.remove(transaction.productID)
}
else {
verifiedActiveSubscriptionIDs.insert(transaction.productID)
}
}
var hasPremium: Bool {
!verifiedActiveSubscriptionIDs.isEmpty
}
}

Next, let’s insert it into the SwiftUI environment and update the current state. Wherever we need to read the state, we can access the Subscriptions class and read the hasPremium property. Moreover, thanks to the observation framework, the SwiftUI view will automatically update when the state changes.

@main
struct SwiftUISubscriptionStoreViewExampleApp: App {
@State private var subscriptions = Subscriptions()
var body: some Scene {
WindowGroup {
ContentView()
.environment(subscriptions)
.task {
await subscriptions.prepare()
}
}
}
}
struct ContentView: View {
@Environment(Subscriptions.self) var subscriptions
@State private var isPresentingSubscriptions = false
var body: some View {
VStack {
Text(subscriptions.hasPremium ? "Subscribed!" : "Not subscribed")
Button("Show Subscriptions") {
isPresentingSubscriptions = true
}
}
.sheet(isPresented: $isPresentingSubscriptions, content: {
SubscriptionsView()
})
.padding()
}
}
view raw App.swift hosted with ❤ by GitHub

SwiftUISubscriptionStoreViewExample (GitHub, Xcode 15.2)

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.