gradient blur
Engineering

StoreKit paywall views in SwiftUI: The Complete Fieldguide

New in iOS 17, the StoreKit framework introduced several ways to display your products and subscriptions. From showing a store to using advanced techniques, this post covers it all.

StoreKitViews Header

Jordan Morgan

Developer Advocate

Published

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:

gradient blur
dashboard-header

All product views created using StoreKit SwiftUI views

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:

gradient blur
dashboard-header

Tapping the "Shop" button shows an empty store

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

ungroup Copy

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

ungroup Copy

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:

gradient blur
dashboard-header

The store view with Caffeine Pal's products

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

ungroup Copy

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

ungroup Copy

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

ungroup Copy

And with that, the store looks a bit nicer:

gradient blur
dashboard-header

"Before" on the left, and "after" on the right.

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

ungroup Copy

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

ungroup Copy

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 of PurchaseOperations.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 the Text 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

ungroup Copy

Now, the featured section shows a product and supports purchasing them:

gradient blur
dashboard-header

The finished ProductView on the right.

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

ungroup Copy

Replace the placeholder Text view with another ProductView:

ProductView(id: drink.skIdentifier)
    .bold()
    .productViewStyle(.compact)

swift

ungroup Copy

And now, the Recipe tab is looking much better:

gradient blur
dashboard-header

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

ungroup Copy

With that, there's a working view to start a subscription with Caffeine Pal:

gradient blur
dashboard-header

The basic subscription view design.

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

ungroup Copy

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:

gradient blur
dashboard-header

The subscription view in action, using existing paywall code.

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)

ungroup Copy

...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

ungroup Copy

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

ungroup Copy

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:

gradient blur
dashboard-header

Caffeine Pal completed with StoreKit views.

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

ungroup Copy

The result:

gradient blur
dashboard-header

Using a customized product view style

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

ungroup Copy

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

ungroup Copy

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

ungroup Copy

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.

gradient blur

Get a demo

We'd love to show you Superwall

Want to learn more?

  1. Fill out this tiny form →
  2. We'll prepare you a custom demo
  3. Walk you through Superwall
  4. Follow up and answer questions

Key features

  • Drag 'n Drop Paywalls
  • 200+ Custom Templates
  • Unlimited A/B tests
  • Surveys, Charts & More
Select...

By proceeding you consent to receiving emails and our terms.

gradient blur
shape-starshape-starshape-starshape-starshape-star

Customer Stories

Our customers refer to Superwall as their most impactful monetization tool. In their own words:

dashboard-header

Thanks to Superwall, we were able to 2x our iOS app profitability in just six months. It has greatly assisted our growth team in achieving exceptional results by facilitating high-frequency experimentation.

Mojo launch
Bernard Bontemps, Head of Growth
dashboard-header

Really excited about the progress we made recently on paywalls with Superwall. We got more than 50% increase in conversion for upsell screens. This is crazy.

Photoroom launch
Matthieu Rouif, CEO
dashboard-header

Superwall has completely changed the game for us. We’re able to run experiments 10x faster and unlock the ideal monetization model for our users.

RapChat launch
Seth Miller, CEO
dashboard-header

Superwall made testing paywalls so much faster. Instead of releasing a new version of the app each time, we were able to iterate on the winning paywalls much quicker. Thanks to that it increased our revenue per customer by 40%.

Teleprompter launch
Mate Kovacs, Indie Dev
dashboard-header

Superwall lets us move 10x faster on our monetization strategy. We can build and launch multiple paywall iterations without the need for client releases or complicated deploys. Our product iteration loop is days, rather than months because of Superwall.

Citizen launch
Jon Rhome, Head of Product
dashboard-header

Superwall enables Bickster’s marketing team to design and optimize app paywalls, freeing up engineering to concentrate on innovation. As a result, Superwall helped accelerate our install-to-subscription rates, lower engineering expenses, and cured our team’s frustration with the (once) time-consuming process of iterating on paywalls.

Bickster launch
Chris Bick, CEO
dashboard-header

Superwall has revolutionized our monetization strategy. It’s an essential tool that allows rapid paywall testing and optimization, leading to remarkable improvements in our subscription conversions and revenue generation. Can’t recommend Superwall enough for any app-based business.

Coinstats launch
Vahe Baghdasaryan, Sr. Growth
dashboard-header

Superwall has played an integral part of improving our subscription business. Compared to other providers, Superwall has proven superior in facilitating high-frequency experimentation allowing us to achieve an ideal monetization model, resulting in a significant increase in revenue.

Hornet launch
Nils Breitmar, Head of Growth
dashboard-header

Superwall is the single greatest tool we’ve used to help us increase our revenue. Our material wins from Superwall are greater than any tool we’ve worked with to date!

Pixite launch
Jordan Gaphni, Head of Growth
dashboard-header

Shout out to Superwall for helping us dial in our paywall — made a big impact on monetization, increasing revenue by more than 50% 💸

Polycam launch
Chris Heinrich, CEO
dashboard-header

Superwall increases revenue. Full stop. Being able to test paywalls on the fly and quickly analyze results has drastically increased our revenue and improved our monetization of users. Highly recommend this tool!

Hashtag Expert launch
Zach Shakked, Founder
Start for FREE

Simple win-win pricing

Interest aligned pricing. No recurring charges. No hidden fees.

dashboard-header

Pricing that scales ic_trending_up_24px

Superwall only makes money when a paywall converts, usually only costing ~1% of total revenue. In all cases, Superwall pays for itself... otherwise, why would anyone use it?

What's included

  • Drag 'n Drop Paywall Editor
  • 200+ Paywall Templates
  • Unlimited A/B tests
  • Charts & Analytics

$0.20 /

Paywall Conversion

Start for free

250 conv/mo, 100% FREE.
No credit card required.