WWDC Quick Look 💓 By SwiftGGTeam
Meet StoreKit for SwiftUI

Meet StoreKit for SwiftUI

Watch original video

Highlight

Apple introduced a set of SwiftUI-native views in StoreKit (StoreView, ProductView, SubscriptionStoreView) that let developers display and sell in-app purchases and subscriptions in their apps with just a few lines of code, with Xcode Previews support for real-time preview without manually configuring screenshots in App Store Connect.


Core Content

Before iOS 17, building an in-app purchase store page in your app required significant work: loading product lists, monitoring purchase status, handling transaction callbacks, and writing your own UI layout. Supporting subscriptions meant additional logic for free trials, discount offers, upgrades, downgrades, and more. A complete store page could easily run to hundreds of lines of code, and every debug session required a real device.

StoreKit in iOS 17 provides three SwiftUI view groups that encapsulate product loading, display, and purchase flows. Developers only need to provide product IDs—the views automatically fetch metadata (name, price, description) from the App Store and render the complete UI.

The three view groups are:

  • StoreView — A collection view for displaying one-time purchase products (non-consumables, consumables)
  • ProductView — A view for displaying a single product with customizable layout
  • SubscriptionStoreView — A view for displaying subscription product groups, automatically handling free trials, discounts, upgrades, and related display logic

Let’s look at how to use each of these three view groups.


Detailed Content

StoreView: Build a product shelf in a few lines of code

StoreView is the simplest entry point. Give it a set of product IDs and it automatically fetches metadata and renders with the default style.

(03:35) Start with the basics. Create a BirdFoodShop view:

import SwiftUI
import StoreKit

struct BirdFoodShop: View {
    @Query var birdFood: [BirdFood]

    var body: some View {
        StoreView(ids: birdFood.productIDs)
    }
}

Key points:

  • import StoreKit brings in the StoreKit framework to use these new views
  • StoreView(ids:) accepts a set of product IDs ([String]) and automatically fetches corresponding product metadata from App Store Connect
  • You don’t need to manually call SKProductRequest or manage loading/error states
  • @Query is SwiftData’s property wrapper, used here to fetch the product ID list from the bird food data model

(04:51) If the default product icons aren’t distinctive enough, pass a closure to customize the decorative icon for each product:

StoreView(ids: birdFood.productIDs) { product in
    BirdFoodProductIcon(productID: product.id)
}

StoreView uses the .compact style by default. You can also specify the style explicitly:

StoreView(ids: birdFood.productIDs) { product in
    BirdFoodProductIcon(productID: product.id)
}
.productViewStyle(.compact)

ProductView: Custom layout and styling

When you want to design your own store layout, ProductView is the more flexible choice. It represents a single product view that you can place in any SwiftUI layout container.

(06:47) Place ProductView in a ScrollView + VStack for manual layout:

ScrollView {
    VStack(spacing: 10) {
        if let (birdFood, product) = birdFood.bestValue {
            ProductView(id: product.id) {
                BirdFoodProductIcon(
                    birdFood: birdFood,
                    quantity: product.quantity
                )
            }
            .padding()
            .background(.background.secondary, in: .rect(cornerRadius: 20))
            .padding()
            .productViewStyle(.large)
        }
    }
    .scrollClipDisabled()

    Text("Other Bird Food")
        .font(.title3.weight(.medium))
        .frame(maxWidth: .infinity, alignment: .leading)

    ForEach(birdFood.premiumBirdFood) { birdFood in
        BirdFoodShopShelf(title: birdFood.name) {
            ForEach(birdFood.orderedProducts) { product in
                ProductView(id: product.id) {
                    BirdFoodProductIcon(
                        birdFood: birdFood,
                        quantity: product.quantity
                    )
                }
            }
        }
    }
}
.contentMargins(.horizontal, 20, for: .scrollContent)

Key points:

  • ProductView(id:) accepts a single product ID and automatically loads and displays that product
  • The second parameter is a decorative icon closure, customizable like StoreView
  • .productViewStyle(.large) specifies the large style, suited for featured product display
  • Placing ProductView in ForEach + VStack manually implements StoreView’s layout capability
  • .productViewStyle(.compact) is the default style, suited for list display

(21:25) Use a promotional icon to make a product visually stand out:

ProductView(
    id: ids.nutritionPelletBox,
    prefersPromotionalIcon: true
) {
    BoxOfNutritionPelletsIcon()
}

prefersPromotionalIcon: true uses a larger icon size, suited for highlighting a recommended product in a product list.

(21:56) You can also add a border to the icon:

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
        .productIconBorder()
}

(20:52) Show a placeholder icon while loading:

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
} placeholderIcon: {
    Circle()
}

placeholderIcon displays while product metadata hasn’t finished loading, avoiding blank UI.

Custom ProductViewStyle

(23:02) The ProductViewStyle protocol lets you fully control the appearance and behavior of product views. Built-in styles are .compact and .large, but you can write your own:

struct SpinnerWhenLoadingStyle: ProductViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        switch configuration.state {
        case .loading:
            ProgressView()
                .progressViewStyle(.circular)
        default:
            ProductView(configuration)
        }
    }
}

Usage:

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
}
.productViewStyle(SpinnerWhenLoadingStyle())

Key points:

  • The ProductViewStyle protocol has only one method: makeBody(configuration:)
  • configuration.state has four states: .loading, .failure(let error), .unavailable, .success(let product)
  • Show a spinner during loading; fall back to the default ProductView(configuration) in other cases

(23:58) A more complete custom style example with full control over each state:

struct BackyardBirdsStyle: ProductViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        switch configuration.state {
        case .loading:
            ProgressView()
        case .failure(let error):
            Text("Loading failed")
        case .unavailable:
            Text("Unavailable")
        case .success(let product):
            HStack(spacing: 12) {
                configuration.icon
                VStack(alignment: .leading, spacing: 10) {
                    Text(product.displayName)
                    Button(product.displayPrice) {
                        configuration.purchase()
                    }
                    .bold()
                }
            }
            .backyardBirdsProductBackground()
        }
    }
}

Key points:

  • configuration.icon is the decorative icon closure you passed earlier
  • configuration.purchase() triggers the purchase flow
  • product.displayName and product.displayPrice are fields from App Store metadata, automatically localized

SubscriptionStoreView: One-tap subscription store generation

SubscriptionStoreView is designed specifically for subscriptions. It automatically handles subscription-specific display needs like free trials, discounts, and multi-tier comparisons.

(09:57) Basic usage:

import SwiftUI
import StoreKit

struct BackyardBirdsPassShop: View {
    @Environment(\.shopIDs.pass) var passGroupID

    var body: some View {
        SubscriptionStoreView(groupID: passGroupID)
    }
}

groupID is the subscription group ID configured in App Store Connect. SubscriptionStoreView automatically loads and displays all subscription tiers in that group.

(10:38) Add marketing content at the top of the subscription page:

SubscriptionStoreView(groupID: passGroupID) {
    PassMarketingContent()
}

The passed closure is the marketing area content—you can place a logo, promotional image, feature descriptions, and more.

(10:57) Set full-height background and styling:

SubscriptionStoreView(groupID: passGroupID) {
    PassMarketingContent()
        .lightMarketingContentStyle()
        .containerBackground(for: .subscriptionStoreFullHeight) {
            SkyBackground()
        }
}
.backgroundStyle(.clear)

.containerBackground(for: .subscriptionStoreFullHeight) sets the background for the entire subscription page, extending from the top marketing area through the subscription options area.

(11:21) Control subscription button label styling:

.subscriptionStoreButtonLabel(.multiline)

.multiline lets button labels support multiple lines, suited for displaying longer subscription descriptions.

(11:44) Set picker item background material:

.subscriptionStorePickerItemBackground(.thinMaterial)

(12:20) Show the redeem code button:

.storeButton(.visible, for: .redeemCode)

(31:22) Switch control style to buttons (default is picker style):

.subscriptionStoreControlStyle(.buttons)

Button style displays each subscription tier as an independent button rather than a Picker-style single-select list.

(33:44) Customize icons for each subscription tier:

.subscriptionStoreControlIcon { subscription, info in
    Group {
        let status = PassStatus(
            levelOfService: info.groupLevel
        )
        switch status {
        case .premium:
            Image(systemName: "bird")
        case .family:
            Image(systemName: "person.3.sequence")
        default:
            Image(systemName: "wallet.pass")
        }
    }
    .foregroundStyle(.tint)
    .symbolVariant(.fill)
}

(35:30) Show only upgrade options (hide current subscription tier):

SubscriptionStoreView(
    groupID: passGroupID,
    visibleRelationships: .upgrade
) {
    PremiumMarketingContent()
        .containerBackground(for: .subscriptionStoreFullHeight) {
            SkyBackground()
        }
}

visibleRelationships: .upgrade shows only subscription options higher than the current tier, suited for users who already subscribe and want to upgrade.

(30:32) Show App Store privacy policy and terms links:

.subscriptionStorePolicyForegroundStyle(.white)
.storeButton(.visible, for: .policies)

(29:56) Customize sign-in behavior:

@State var presentingSignInSheet = false

var body: some View {
    SubscriptionStoreView(groupID: passGroupID) {
        PassMarketingContent()
            .containerBackground(for: .subscriptionStoreFullHeight) {
                SkyBackground()
            }
    }
    .subscriptionStoreSignInAction {
        presentingSignInSheet = true
    }
    .sheet(isPresented: $presentingSignInSheet) {
        SignInToBirdAccountView()
    }
}

If users need to sign in to your app account before purchasing (for example, to link subscription status to an existing account), you can intercept the default sign-in flow via .subscriptionStoreSignInAction and present your own sign-in page.

Handling purchase results

(14:10) Listen for purchase completion on the view:

BirdFoodShop()
    .onInAppPurchaseCompletion { (product: Product, result: Result<Product.PurchaseResult, Error>) in
        if case .success(.success(let transaction)) = result {
            await BirdBrain.shared.process(transaction: transaction)
            dismiss()
        }
    }

(15:43) Listen for purchase start events (suited for showing loading state):

BirdFoodShop()
    .onInAppPurchaseStart { (product: Product) in
        self.isPurchasing = true
    }

Querying subscription status

(16:57) Use subscriptionStatusTask to query the current user’s subscription status:

subscriptionStatusTask(for: passGroupID) { taskState in
    if let statuses = taskState.value {
        passStatus = await BirdBrain.shared.status(for: statuses)
    }
}

(19:37) For non-consumables, use currentEntitlementTask to check purchase eligibility:

currentEntitlementTask(for: "com.example.id") { state in
    self.isPurchased = BirdBrain.shared.isPurchased(
        for: state.transaction
    )
}

Handling product loading state

(26:44) When you need finer control over the loading flow, use storeProductsTask:

@State var productsState: Product.CollectionTaskState = .loading

var body: some View {
    ZStack {
        switch productsState {
        case .loading:
            BirdFoodShopLoadingView()
        case .failed(let error):
            ContentUnavailableView(/* ... */)
        case .success(let products, let unavailableIDs):
            if products.isEmpty {
                ContentUnavailableView(/* ... */)
            } else {
                BirdFoodShop(products: products)
            }
        }
    }
    .storeProductsTask(for: productIDs) { state in
        self.productsState = state
    }
}

Key points:

  • storeProductsTask(for:) loads products asynchronously and updates state via callback
  • Product.CollectionTaskState has three states: .loading, .failed(let error), .success(let products, let unavailableIDs)
  • Display different UI based on state: loading, load failure, empty result, or normal content

Core Takeaways

1. Quickly add an in-app purchase store to an existing app

If your app currently displays product pages in a WebView or has no in-app purchase store at all, you can now create a new page, drop in a StoreView or SubscriptionStoreView, pass product IDs, and have a complete native store page. Product metadata, price localization, and purchase buttons are all handled automatically.

Entry API: StoreView(ids:) or SubscriptionStoreView(groupID:)

2. Build a product detail page with a “hero” item

Use ProductView + .productViewStyle(.large) to enlarge and showcase the best-value product at the top, with other products listed below in .compact style. prefersPromotionalIcon: true makes the hero product’s icon more prominent.

Entry API: ProductView(id:prefersPromotionalIcon:content:) + .productViewStyle(.large)

3. Build a subscription tier comparison page

SubscriptionStoreView’s default Picker style already provides tier comparison. But if you want a more custom comparison, switch to button layout with .subscriptionStoreControlStyle(.buttons), then add custom icons and descriptions for each tier. Use visibleRelationships: .upgrade for a dedicated upgrade page.

Entry API: SubscriptionStoreView(groupID:visibleRelationships:content:)

4. Combine with SwiftData for a dynamic product shelf

The example uses @Query var birdFood: [BirdFood] to fetch data from SwiftData, then passes productIDs from the data model to StoreView. You can maintain a local product catalog in your app (categories, sorting, recommendation logic) and hand the corresponding product ID list to StoreKit views for rendering.

Entry API: @Query + StoreView(ids:)

5. Write a custom ProductViewStyle for a branded store

If your app has a distinctive design style, the built-in .compact and .large styles may not be enough. By implementing the ProductViewStyle protocol, you can fully control how each product is displayed—from loading state animations to purchase button styling to the entire card layout.

Entry API: ProductViewStyle protocol, makeBody(configuration:) method


Comments

GitHub Issues · utterances