Apple's StoreKit framework can produce a paywall in just a few lines of SwiftUI code. But how do they work, and when would they make sense to use?
Getting paywalls "right" isn't easy. There are several aspects to consider. As a developer advocate here at Superwall, I understand the pain points more than most. Superwall's core product revolves around getting these views done correctly, making them easy to test and ultimately determining the best ways to convert.
With iOS 17, Apple threw their own hat into the "paywall" ring. Now, with StoreKit 2, developers can present paywalls with just a few lines of code that can produce views like this:
This post demonstrates how to get these up and running. It covers the basics, looks at some advanced techniques and then finishes off by reviewing any trade offs.
To start, this tutorial will be using the Caffeine Pal demo app which is part of the StoreKit 2 tutorial. This app has full support for in-app purchases and subscriptions, all built with StoreKit 2. That will be used as a starting point, but all of the paywall views will be removed and rebuilt with the StoreKit views.
To follow along, either clone or download Caffeine Pal:
StoreKit view types
It's probably no surprise that these views are made available by the StoreKitframework. There are three primary views available to use:
Store view: An all-in-one view that displays products you specify. The most "out-of-the-box" solution of the views available.
Subscription store view: Similar to the above, except this view focuses solely on subscriptions you offer.
Product view: A single view that maps directly to a product. This makes them composable, and the previous two views use them.
The best part about all of these views? StoreKit will handle the grunt work of fetching products, displaying them correctly, localizing prices and more. This means developers can worry less about the inner workings of StoreKit, and simply focus on other parts of their apps.
Getting started
Now is a good time to build and run Caffeine Pal. Tapping on any button that presents a product to purchase presents views with "TODO" inside Text
views. This is where StoreKit views will be used to display products.
To begin, the simplest way to get a feel for how StoreKit views work is to display the StoreView
. In Caffeine Pal, under the Settings tab, there is button that reads "Shop" at the bottom:
This is the perfect place to display each product available using StoreView
.
Displaying all products
Open up AllProductsView.swift
, and notice that there's an import for StoreKit already at the top ( import StoreKit
). Since, again, these views are found in the StoreKit framework, that import will be required to use them. The same goes for SwiftUI.
The StoreView
works by simply passing it an array of product identifiers that it'll use to fetch the relevant products and then display them. In PurchaseOperations.swift
, there's already a property that aggregates every product type identifier into an array:
static var allProductIdentifiers: [String] {
get {
let tipIdentifiers: [String] = PurchaseOperations.tipProductIdentifiers
let recipeIdentifiers: [String] = PurchaseOperations.recipeProductIdentifiers
let subIdentifiers: [String] = PurchaseOperations.subProductIdentifiers
let allIdentifiers: [String] = tipIdentifiers + recipeIdentifiers + subIdentifiers
return allIdentifiers
}
}
swift
That property, allProductIdentifiers
, combines all product identifiers that Caffeine Pal offers. Thus, every available thing to purchase in the app will be shown in the StoreView
. Replace the code in AllProductsView.swift
with this:
struct AllProductsView: View {
@Environment(PurchaseOperations.self) private var storefront: PurchaseOperations
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
StoreView(ids: PurchaseOperations.allProductIdentifiers)
.navigationTitle("All Products")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark.circle.fill") {
dismiss()
}
}
}
}
}
}
swift
With that one line, StoreView(ids: PurchaseOperations.allProductIdentifiers)
, StoreKit will show a ready-to-use storefront that supports:
Localized prices
The ability to purchase goods
Display of product names and descriptions
Pre-styled components
And, it works on all of Apple's platforms
Build and run the app (or use Xcode Previews), and this is what it should present:
While that's a wonderful start, there's a few things to clean up. There are two close buttons and the products are all vertically presented, which doesn't make good use of the space that's available. All of these things can be quickly fixed using modifiers built for StoreKit views.
For the close button...
It's important to realize that StoreKit views will sometimes include buttons on their own, but their visibility can always be changed. That's done via the store button modifier:
SomeStoreKitView()
.storeButton(.hidden, for: .cancellation)
swift
The last parameter is variadic, so it's possible to pass any button that should be hidden or shown.
For the products being vertically presented...
Remember the mention of a ProductView
? That's what's shown here, and those can be styled from their own modifiers, too. Further, there's a protocol that can be adopted, ProductViewStyle
, to customize it altogether. For this scenario, a more compact style looks a little nicer:
SomeStoreKitView()
.productViewStyle(.compact)
swift
So, applying those modifiers back to AllProductsView.swift
, here's what the code should look like now:
var body: some View {
NavigationStack {
StoreView(ids: PurchaseOperations.allProductIdentifiers)
.storeButton(.hidden, for: .cancellation) // Added
.productViewStyle(.compact) // Added
.bold() // Added
.navigationTitle("All Products")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark.circle.fill") {
dismiss()
}
}
}
}
}
swift
And with that, the store looks a bit nicer:
Again, it's impressive to take stock of what's ready to go at this point. With one view added from StoreKit in Caffeine Pal, users could purchase anything in the app, browse products and more.
Next, the Recipe tab could use some work.
Showing singular products
The Recipes tab shows off individual espresso-based drink recipes and the steps to make them that users can buy. There's a "Featured" drinks section at the top, and a list of all of the drinks below it. Each one of these U.I. components represents a one-to-one mapping of a product we offer, so this is the ideal spot to use ProductView
.
Build and run the app now to get a feel for the interface. To begin, open up RecipesView.swift
. The "Featured" section is a logical first step to fix, and those are all shown here in FeaturedEspressoDrinksView
:
struct FeaturedEspressoDrinksView: View {
@Environment(PurchaseOperations.self) private var storefront: PurchaseOperations
private let featured: [EspressoDrink] = [.affogato, .ristretto, .flatWhite]
let onTap: (EspressoDrink) -> ()
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 10.0) {
ForEach(featured) { drink in
ZStack {
switch storefront.hasPurchased(drink) {
case true:
PurchasedFeatureDrinkView(drink: drink) { _ in
onTap(drink)
}
case false:
// TODO: Insert StoreKit view
Text("TODO - Product view")
}
}
.padding()
.aspectRatio(3.0 / 2.0, contentMode: .fit)
.containerRelativeFrame(.horizontal, count: 1, spacing: 0)
.background(.background.secondary, in: .rect(cornerRadius: 16))
}
}
}
.scrollTargetBehavior(.paging)
.safeAreaPadding(.horizontal, 16.0)
.scrollIndicators(.hidden)
}
}
swift
The // TODO: Insert StoreKit view
is where ProductView
will be used, but it's worth pointing out something first. In particular, this code:
ZStack {
switch storefront.hasPurchased(drink) {
case true:
PurchasedFeatureDrinkView(drink: drink) { _ in
onTap(drink)
}
case false:
Text("TODO - Product view")
}
}
swift
This is an approach that can be used to toggle between some U.I. based on whether or not someone already has purchased a product. If you need a refresher on how storefront.hasPurchased(drink)
is working, either check out the code or read the StoreKit 2 tutorial for more of a walk through. In short, it'll work like this:
When a user buys something, it'll be picked up by the
transactionListener
inside ofPurchaseOperations.swift
.Then, the view to show when a user owns the product will be presented instead of the
ProductView
(which we're going to add in place of theText
view next).
Replace the Text("TODO - Product view")
with this code:
ProductView(id: drink.skIdentifier) {
Image(drink.imageFile())
.resizable()
.scaledToFill()
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.productViewStyle(.large)
swift
Now, the featured section shows a product and supports purchasing them:
The ProductView
follows the same pattern that StoreView
does (i.e. pass it an identifier to a product, and it'll take care of the rest) but it does have on added capability. The closure of ProductView
supports an "icon" that can be used to display more about the product. It'll shift its location and presentation based off of the .productViewStyle()
, among other things.
Here, an image of the drink was used. To see how the image's presentation can change, simply use a different product style in the .productViewStyle(.large)
modifier.
Next, there's the list of drinks to address. Those are found within RecipeView
's main body
:
GroupBox {
ForEach(EspressoDrink.all()) { drink in
ZStack {
switch storefront.hasPurchased(drink) {
case true:
PurchasedReceipeView(drink: drink) {
handleSelectionFor(drink)
}
case false:
Text("TODO - Product view")
}
}
.animation(.smooth, value: storefront.hasPurchased(drink))
.padding(6)
Divider()
}
}
swift
Replace the placeholder Text
view with another ProductView
:
ProductView(id: drink.skIdentifier)
.bold()
.productViewStyle(.compact)
swift
And now, the Recipe tab is looking much better:
The ProductView
has a lot of API available to tweak its appearance. At its core, though, it works right away without much need for customization. With that in place, there's one more spot to fix in Caffeine Pal — its subscription view.
Subscriptions
Open up SubscriptionView.swift
to get started. This is an ideal place to use StoreKit's last "primary" view type, the SubscriptionStoreView
. This view has a bit more available to customize than the previous two views, but it's not intimidating. It makes sense, too, because this represents the traditional "paywall" — a place where the design, copy, fonts, colors and everything else can have a substantial impact on revenue.
To begin, initializing a subscription view works the same way the other two views do. Pass in the relevant subscription product identifiers:
var body: some View {
SubscriptionStoreView(productIDs: PurchaseOperations.subProductIdentifiers)
}
swift
With that, there's a working view to start a subscription with Caffeine Pal:
The subscription view takes care of some details that have, historically speaking, been non-trivial to get correct. For example, the renewal string below the button, along with its duration and localized price. Here, StoreKit takes care of all of that on developer's behalf.
If this is all an app's design requires, then it's ready to use. But, as previously mentioned, there's a lot of customization that can be performed. At first glace, it may be a little overwhelming browsing the documentation and seeing all of the different ways this view can be used.
However, the easiest way to think about the API is like this:
There's a closure that be used to pass any view or design that can cover the whole view. The "footer" at the bottom will always be included.
Then, simply use modifiers from there to hide buttons, change text and more.
So, that means if there's already a view in an app built for subscriptions, it can still be used. That's the case with Caffeine Pal, so change the body
code to use it:
SubscriptionStoreView(productIDs: PurchaseOperations.subProductIdentifiers) {
ScrollView {
VStack {
Text("Join Caffeine Pal Pro Today!")
.font(.largeTitle.weight(.black))
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 16)
ForEach(ProFeatures.allCases) { feature in
ExpandedFeatureView(feature: feature)
}
}
.padding()
}
.containerBackground(Color(uiColor: .systemBackground).gradient,
for: .subscriptionStoreFullHeight)
}
swift
Now, the existing Caffeine Pal "paywall" view is used — but StoreKit takes care of all of the dirty work in the footer to purchase the subscription, support accessibility APIs, show the correct pricing and more:
The API is flexible. For example, if a "header" based design is all that's required then in this code...
.containerBackground(Color(uiColor: .systemBackground).gradient,
for: .subscriptionStoreFullHeight)
...simply switch out .subscriptionStoreFullHeight
for .subscriptionStoreHeader
to customize the background. Here, though, there are still some tweaks that will help. For those, the modifiers built for subscription views will get the job done.
On this paywall, a "Restore Purchases" button will be needed. Plus, the button text could do with a change-up as well. Add these modifiers to the bottom of SubscriptionStoreView
to get those tweaks added in:
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreButtonLabel(.action)
.backgroundStyle(.thinMaterial)
swift
The store button modifier was used early on in this tutorial to hide the pre-built close button on the store view. Here, it'll be used to include a restore purchases button. Even better, it's placed in the view automatically. Further, the subscriptionStoreButtonLabel
modifier provides several different ways to customize the button to purchase the subscription.
After all of that, there is still one more thing to consider. If a user purchases a subscription, the view here should be dismissed. That's where StoreKit's modifiers to track transactions comes into play. Add this code below the modifiers that were just added above:
.onInAppPurchaseCompletion { (product: Product,
result: Result<Product.PurchaseResult, Error>) in
if case .success(.success(_)) = result {
// StoreFront already processes this...
// Simply dismiss
dismiss()
}
}
swift
This modifier's closure is called when a product is purchased. And, if the purchased product is verified (a topic covered in the StoreKit 2 tutorial), then the view is now dismissed.
Now, everything is in place. Run Caffeine Pal and everything should be working. Purchasing a subscription, the views update when things are bought, every type of product can be sold, etc:
Advanced techniques
Every app has different needs, and thankfully StoreKit 2, and the views shown here, can adapt to just about all of them. Here are some advanced techniques which can be used from the framework.
Custom product views
By using the ProductViewStyle
protocol, product views can be customized much the same way buttons are in SwiftUI. For example, if a design called for an activity indicator while the product loads along with a custom buy button and labels — that could be achieved:
struct MonospaceProductStyle: ProductViewStyle {
func makeBody(configuration: Configuration) -> some View {
switch configuration.state {
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let product):
HStack {
VStack(alignment: .leading) {
Text(product.displayName)
.font(.headline.monospaced())
.padding(.bottom, 2)
Text(product.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: {
configuration.purchase()
}, label: {
Image(systemName: "cart.badge.plus")
.symbolRenderingMode(.multicolor)
.foregroundStyle(.white)
.padding(8)
.background(.blue.gradient, in: .rect(cornerRadius: 4))
})
.buttonStyle(.plain)
}
.padding()
.background(.thinMaterial, in: .rect(cornerRadius: 16))
default:
ProductView(configuration)
}
}
}
swift
The result:
The configuration
supplies anything required for the view, and it can also purchase the product it represents. Simply calling its configuration.purchase()
kicks off a purchase, and it even has access to the underlying Product
struct as well.
There's API to control the iconography as well. For example, changing the placeholder icon, using the App Store promotional badge, or reusing the promotional badge icon are all supported:
// Button iconography on the subscription view
SubscriptionStoreView(productIDs: PurchaseOperations.subProductIdentifiers) {
ScrollView {
VStack {
Text("Join Caffeine Pal Pro Today!")
.font(.largeTitle.weight(.black))
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 16)
ForEach(ProFeatures.allCases) { feature in
ExpandedFeatureView(feature: feature)
}
}
.padding()
}
.containerBackground(Color(uiColor: .systemBackground).gradient,
for: .subscriptionStoreFullHeight)
}
.subscriptionStoreControlIcon { subscription, info in
// Shown in the subscription button
Image(systemName: "use.sfSymbol.based.on.plan")
.symbolRenderingMode(.hierarchical)
}
// Promotional badge
ProductView(id: drink.skIdentifier, prefersPromotionalIcon: true)
// Your own image, with promotional badge border
ProductView(id: drink.skIdentifier) {
Image(drink.imageFile())
.resizable()
.scaledToFill()
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.productIconBorder()
swift
Customized loading screens
Achieving a custom loading experience is also possible without making a custom product style. Look no further than the .storeProductTask(for:)
modifier.
@State private var fetchState: Product.TaskState = .loading
var body: some View {
HStack {
switch fetchState {
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let product):
Text(product.displayName)
case .unavailable:
Text("Product unavailable.")
case .failure(let error):
Text(error.localizedDescription)
@unknown default:
EmptyView()
}
}
.storeProductTask(for: productIdentifiers) { state in
self.fetchState = state
}
}
swift
Or, in the StoreView
, it's even easier:
StoreView(ids: productIdentifiers) { product, phase in
switch phase {
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let productIcon):
productIcon
case .unavailable:
Text("Product unavailable.")
case .failure(let error):
Text(error.localizedDescription)
@unknown default:
EmptyView()
}
} placeholderIcon: {
ProgressView()
.progressViewStyle(.circular)
}
swift
Responding to events
If an app needs to respond to any arbitrary event, StoreKit probably has a modifier for it. Use-cases for this could be situations where the subscription view was dismissed in Caffeine Pal once a subscription was purchased. There are several events, here are a few modifiers that would be common to use:
onInAppPurchaseStart
onInApppurchaseComplete
subscriptionStatusTask
currentEntitlementTask
Keep in mind, though, that if any object in the app already listens to the Transaction.updates
async stream, there might not be too many reasons to reach for these.
Exploring trade offs
As with any engineering project, there are pros and cons to using any API. StoreKit views do make a strong case for themselves, though. For example, some obvious benefits are:
They're multiplatform.
They take care of fetching products.
They handle localizations, accessibility and more.
They are customizable.
They provide sensible, understandable designs.
However, there are some rigid points, too.
Like any framework, there are things it doesn't excel at. One of those? There doesn't appear to be a way to change how the subscription product view works in terms of the footer view. The StoreKit buttons, like restoring purchases and more, will also be placed in an opinionated way. Other flows, like toggling different plans from a footer view, also don't appear to be possible yet.
The same is true of the product views themselves. They follow an "icon - name - description - buy button" pattern. Though, they can be customized if the ProductViewStyle
protocol is implemented.
Wrapping up
StoreKit views are wonderfully simplistic from an API standpoint — which is their primary strength. Getting started with them is refreshing, since supporting these types of views hasn't always been easy.
Whether or not they should be used, though, all depends on the app and business that's being built. No doubt, Apple will continue to give these StoreKit views even more functionality, which is great for developers. The more options, the better.
If quickness is paramount, along with testing paywalls, running experiments and understanding results — then Superwall can help. With hundreds of paywall templates ready to be used (and customized) — an app can be App Store ready in minutes from a paywall standpoint. Get started below, or check out this tutorial to integrate Superwall's SDK into an iOS app.