Meet StoreKit SubscriptionStoreView in iOS 17

Explore how the new StoreKit views help you build in-app subscriptions with just a few lines of code

joker hook
Better Programming

--

All images by author

The emergence of SubscriptionStoreView frees developers from tedious and complicated code. WWDC23 brought a declarative in-app purchase UI, which allows developers to use Apple’s predesigned app purchase interface. Only a few lines of code need to be inserted into the code to realize functions that previously required a lot of design and code.

In the last few days, I have been researching how to use the in-app subscription function on iOS 17 with clean code and handle a series of operations, including user cancellation and refund, in a smart way. This article mainly introduces how the “SubscriptionStoreView” brought by WWDC 23 can help developers realize the in-app subscription interface with only a few lines of code. A practical example is used to illustrate the specific implementation method and clarify how to support auto-renewable subscriptions in your application.

If you are looking for the way to support non-consumable in-app purchases in your application, please check this article: Meet StoreKit for SwiftUI in iOS 17.

Example

The following picture describes the demo app in this article. This app is similar to the other video apps. People need to subscribe to Flower Movie+ to access all the content of movies. At first, there are View Options button appears on the screen. When users tap the button, the subscription view will show up, and if the user chooses one of the plans, the view will update the plan and show the details according to the selection. The Handle Subscription button aims to help users change their subscription plan whenever they want.

Now let’s analyze in detail how to implement this simple program.

Create Auto-Renewable Subscription

After creating a new project, we need to add in-app purchase items. In this example, you should consider setting the in-app purchase item as an auto-renewable subscription attribute when setting. In your project, create a new StoreKit Configuration File. We will need to test everything through this file under the XCode environment.

Before StoreKit2, if you need to test in-app purchases, you must configure your product on App Store Connect. You can use XCode to test whether the in-app purchase function is perfect.

Name your configuration file. Here I named it Store. After creating your StoreKit configuration file, you should give your subscriptions' information. Tap the plus button and then choose Add Auto-Renewable Subscription.

Name your subscription group, and select the group you created before when creating a new auto-renewable subscription. Here I named my group Flower Movie+. There are three ways to subscribe to Flower Movie+, monthly payment, quarterly payment, and annual payment. So you need to continue to create three auto-renewable subscriptions. Here I set their Reference Names to Monthly, Quarterly, and Yearly, respectively. Prices are set according to your preferences. Whether users can try it for free before paying for the subscription depends on your idea. I prefer to let users experience it for a period of time before paying. At least one has localization because the detail information is needed for SubscriptionStoreView. The following is a detailed configuration list of my Store.storekit.

Change StoreKit Configuration Scheme

We will use the configuration file we just created when testing, so we need to go to Scheme and change the StoreKit Configuration to the Store.storekitwe created.

Create Subscription Status

Note that there are four types of subscription states:

  1. The user does not subscribe to the product
  2. The user subscribes monthly
  3. The user subscribes quarterly
  4. The user subscribes annually

This is an enumeration problem, so we consider trying to use enum to list all subscription states. Create a new file named PassStatus.swift and add the following code:

import StoreKit

enum PassStatus: Comparable, Hashable {
case notSubscribed
case monthly
case quarterly
case yearly

init?(productID: Product.ID, ids: PassIdentifiers) {
switch productID {
case ids.monthly: self = .monthly
case ids.quarterly: self = .quarterly
case ids.yearly: self = .yearly
default: return nil
}
}

var description: String {
switch self {
case .notSubscribed:
"Not Subscribed"
case .monthly:
"Monthly"
case .quarterly:
"Quarterly"
case .yearly:
"Yearly"
}
}
}

The PassIdentifiers contains all the subscriptions' product identifiers:

struct PassIdentifiers {
var group: String

var monthly: String
var quarterly: String
var yearly: String
}

extension EnvironmentValues {
private enum PassIDsKey: EnvironmentKey {
static var defaultValue = PassIdentifiers(
group: "506F71A6",
monthly: "com.pass.monthly",
quarterly: "com.pass.quarterly",
yearly: "com.pass.yearly"
)
}

var passIDs: PassIdentifiers {
get { self[PassIDsKey.self] }
set { self[PassIDsKey.self] = newValue }
}
}

In this article, we will use @Environment(\.passIDs) to get all the identifiers of products. You can get the group ID in your Store.storekit.

Store Model

To display the status of the subscription on the interface, we use passStatus to help us monitor the status.

passStatus is a member variable of the class PassStatusModel, and the @Observable macro is added in front of the class PassStatusModel, so that if the passStatus changes inside the program, the view will change accordingly.

For @Observable macro content, please refer to the article First glance at @Observable macro.

import Observation
@Observable class PassStatusModel {
var passStatus: PassStatus = .notSubscribed
}

View to Show Subscription Status

As mentioned before, when the passStatus is equal to .notSubscribed, we need to display the view which enables users to subscribe to the Flower Movie+. When the passStatus is not .notSubscribed, a view that indicates the subscription status should show up.

import StoreKit

struct ContentView: View {

@State private var showScriptionView: Bool = false

@Environment(PassStatusModel.self) var passStatusModel: PassStatusModel
@Environment(\.passIDs) private var passIDs


var body: some View {
NavigationView {
List {
Section {
planView
// Show the option button if user does not have a plan.
if passStatusModel.passStatus == .notSubscribed {
Button {
self.showScriptionView = true
} label: {
Text("View Options")
}
}
} header: {
Text("SUBSCRIPTION")
} footer: {
if passStatusModel.passStatus != .notSubscribed {
Text("Flower Movie+ Plan: \(String(describing: passStatusModel.passStatus.description))")
}
}
}
.navigationTitle("Account")
.sheet(isPresented: $showScriptionView, content: {
SubscriptionShopView()
})
}
}
}

extension ContentView {
@ViewBuilder
var planView: some View {
VStack(alignment: .leading, spacing: 3) {
Text(passStatusModel.passStatus == .notSubscribed ? "Flower Movie+": "Flower Movie+ Plan: \(passStatusModel.passStatus.description)")
.font(.system(size: 17))
Text(passStatusModel.passStatus == .notSubscribed ? "Subscription to unlock all streaming videos, enjoy Blu-ray 4K quality, and watch offline.": "Enjoy all streaming Blu-ray 4K quality videos, and watch offline.")
.font(.system(size: 15))
.foregroundStyle(.gray)
if passStatusModel.passStatus != .notSubscribed {
Button("Handle Subscription \(Image(systemName: "chevron.forward"))") {
self.presentingSubscriptionSheet = true
}
}
}
}
}

The implementation of SubscriptionShopView will be described in detail in the next section.

Subscription Shop View

The system will display all available products when the user taps the View Options button. I will use a SubscriptionStoreView(groupID:) to the app because it's the quickest way to get the merchandising view up and running. We need to provide it a group identifier from our StoreKit configuration file, which we can get by using @Environment(\.passIDs.group).

Create a new SwiftUI file and name it SubscriptionShopView.swift. First, declare the group ID.

import StoreKit
struct SubscriptionShopView: View {
@Environment(\.passIDs.group) private var passGroupID
}

Now, if we use SubscriptionStoreView(groupID:), we will have a functioning merchandising view.

import StoreKit
struct SubscriptionShopView: View {
...
var body: some View {
SubscriptionStoreView(groupID: passGroupID)
.backgroundStyle(.clear)
.subscriptionStoreButtonLabel(.multiline)
.subscriptionStorePickerItemBackground(.thinMaterial)
.storeButton(.visible, for: .restorePurchases)
}
}

Just like the StoreView and the ProductView, the SubscriptionStoreView manages the data flow for us and lays out a view with the different plan options. It also checks for existing subscriber status and whether the customer is eligible for an introductory offer. Here I use the background style modifier to clarify the background behind the subscription controls. Then the subscriptionStoreButtonLabel is used to choose a multi-line layout for our subscribe button. Notice how the subscribe button contains both the price and "Try it Free." The subscriptionStorePickerItemBackground aims to declare a material effect for our subscription options. Finally, I use the new storeButton modifier to declare the Restore Button as visible.

While this automatic look is great, I want to replace the marketing content in the header with my SwiftUI view.

import StoreKit
struct SubscriptionShopView: View {
...
var body: some View {
SubscriptionStoreView(groupID: passGroupID) {
SubscriptionShopContent()
}
...
}
}

The SubscriptionShopContent is defined below:

struct SubscriptionShopContent: View {
var body: some View {
VStack {
image
VStack(spacing: 3) {
title
desctiption
}
}
.padding(.vertical)
.padding(.top, 40)
}
}

extension SubscriptionShopContent {
@ViewBuilder
var image: some View {
Image("movie")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100)

}
@ViewBuilder
var title: some View {
Text("Flower Movie+")
.font(.largeTitle.bold())
}
@ViewBuilder
var desctiption: some View {
Text("Subscription to unlock all streaming videos, enjoy Blu-ray 4K quality, and watch offline.")
.fixedSize(horizontal: false, vertical: true)
.font(.title3.weight(.medium))
.padding([.bottom, .horizontal])
.foregroundStyle(.gray)
.multilineTextAlignment(.center)
}
}

The basic subscription view is finished, and you can subscribe to Flower Movie+ by tapping the button. However, the view must be updated to show the plan when the purchase is completed. So, we need to tackle this problem.

Handle Subscriptions

Handling subscriptions and transactions has never been easier with StoreKit2. Create a new file called ProductSubscription.swift and add the following code:

import StoreKit
actor ProductSubscription {
private init() {}
private(set) static var shared: ProductSubscription!
static func createSharedInstance() {
shared = ProductSubscription()
}
}

To protect the stored properties when accessed asynchronously, make ProductSubscription to an actor type. By using ProductSubscription.share syntax, you can easily call internal member functions inside ProductSubscription.

Now, we need to handle the results of user subscriptions. When the user taps the button, we must verify the final purchase result.

actor ProductSubscription {
...
func status(for statuses: [Product.SubscriptionInfo.Status], ids: PassIdentifiers) -> PassStatus {
let effectiveStatus = statuses.max { lhs, rhs in
let lhsStatus = PassStatus(
productID: lhs.transaction.unsafePayloadValue.productID,
ids: ids
) ?? .notSubscribed
let rhsStatus = PassStatus(
productID: rhs.transaction.unsafePayloadValue.productID,
ids: ids
) ?? .notSubscribed
return lhsStatus < rhsStatus
}
guard let effectiveStatus else {
return .notSubscribed
}

let transaction: Transaction
switch effectiveStatus.transaction {
case .verified(let t):
transaction = t
case .unverified(_, let error):
print("Error occured in status(for:ids:): \(error)")
return .notSubscribed
}

if case .autoRenewable = transaction.productType {
if !(transaction.revocationDate == nil && transaction.revocationReason == nil) {
return .notSubscribed
}
if let subscriptionExpirationDate = transaction.expirationDate {
if subscriptionExpirationDate.timeIntervalSince1970 < Date().timeIntervalSince1970 {
return .notSubscribed
}
}
}
return PassStatus(productID: transaction.productID, ids: ids) ?? .notSubscribed
}
}

In the above code, we first check the transaction status. Then we first confirmed whether the transaction values were verified or unverified. If verified, pass the transaction to Transaction. If not, stop processing and return .notSubscribed. Then we need to check the product type. Here I check the .autoRenewable case in the code. If the subscription is revocated, return .notSubscribed. I also check whether the subscription is expired or not. If the subscription is expired, return .notSubscribed. Finally, return the actual result from the App Store.

Let’s go back to ContentView . We haven't handled the subscription in our view. Handling subscriptions that come from any of the StoreKit views is simple. You just modify a view with subscriptionStatusTask and it will load the subscription status and then call the function we provide once the task completes.

struct ContentView: View {
...
@State private var status: EntitlementTaskState<PassStatus> = .loading
var body: some View {
NavigationView {...}
.onAppear(perform: {
ProductSubscription.createSharedInstance()
})
.subscriptionStatusTask(for: passIDs.group) { taskStatus in
self.status = await taskStatus.map { statuses in
await ProductSubscription.shared.status(
for: statuses,
ids: passIDs
)
}
switch self.status {
case .failure(let error):
passStatusModel.passStatus = .notSubscribed
print("Failed to check subscription status: \(error)")
case .success(let status):
passStatusModel.passStatus = status
case .loading: break
@unknown default: break
}
}
}
}

Handle Subscription Button

It is good to hide the subscription interface when the user has subscribed and allow the user to change the subscription plan in your app. .manageSubscriptionsSheet is a good way to show the edit subscription view. But before we add it to our view, we must first add the Handle Subscription button.

struct ContentView: View {
...
@State private var presentingSubscriptionSheet = false
var body: some View {
...
}
}

Then use the .manageSubscriptionsSheet modifier to show the subscription sheet.

struct ContentView: View {
...
@State private var status: EntitlementTaskState<PassStatus> = .loading
var body: some View {
NavigationView {...}
.manageSubscriptionsSheet(
isPresented: $presentingSubscriptionSheet,
subscriptionGroupID: passIDs.group
)
...
}
}

All done. Try to make a subscription and enjoy coding.

Source Code

You can find the source code on GitHub.

If you think this article is helpful, you can support me by downloading my first iOS App, WeTally, on the iOS App Store. WeTally is a paramount, exquisite and practical app, allowing you to grasp all financial information in an instant, with a soothing and pleasant use experience, and easily accomplish every accounting.. It is free and useful for many people. You can ask me for free one-month access to advanced features. Hope you like it.

--

--

👨‍🎓/study communication engineering🛠/love iOS development💻/🐶🌤🍽🏸🏫