Implementing a Tip Jar with Swift and SwiftUI

DevelopmentSwiftSwiftUI

Pressured by friends, I recently added a tip jar to Pendulum, the pen pal tracking app I develop with my friend Alex. It’s implemented (like the rest of the app) in pure SwiftUI, and uses the newer StoreKit 2 APIs to communicate with Apple to fetch the IAP information and make purchases. This is a write of how I muddled through the process, from start to finish.

Defining the tip IAPs

The first step is to head into App Store Connect and define the IAPs for each of the tips you want to offer. In my case, I knew what I wanted the tips to be called, in ascending order of price, but not exactly what price each would be. That doesn’t matter for now, though. I had the following in mind, amusingly named after fountain pen nib sizes:

To create these in App Store Connect, I headed to the In-App Purchases section under Features on the app’s App Store tab. There, I could create each tip using the plus button. The initial form has three fields:

Once created, to complete the IAP you need to define a few extra fields that aren’t present on the initial form, such as the Price Schedule (where you set the cost of the IAP, using Apple’s price tiers), and the App Store Localization, where you define how the tip appears (its name and description) in the App Store for each language. I defined only “English (U.K.)” which is all the app is offered in.

Defining the tips in Swift

StoreKit2 doesn’t have an API to fetch all the IAPs associated with an app; instead, you need to request specific Product IDs known by the app ahead of time. To this end, I decided it would be best to represent the available tips in the app with an enum, based off their unique IDs:

enum TipJar: String, CaseIterable {
    case extraFine = "uk.co.bencardy.Pendulum.ExtraFineTip"
    case fine = "uk.co.bencardy.Pendulum.FineTip"
    case medium = "uk.co.bencardy.Pendulum.MediumTip"
    case broad = "uk.co.bencardy.Pendulum.BroadTip"
    case stub = "uk.co.bencardy.Pendulum.StubTip"

    var name: String {
        switch self {
        case .extraFine:
            return "Extra Fine"
        case .fine:
            return "Fine"
        case .medium:
            return "Medium"
        case .broad:
            return "Broad"
        case .stub:
            return "Stub"
        }
    }
}

I made the enum conform to CaseIterable, meaning I can iterate over TipJar.allCases to display all available tips in the SwiftUI view. This I did inside my TipJarView, wrapping each tip in a button and displaying some placeholder information about each one:

struct TipJarView: View {
    var body: some View {
        List {
            ForEach(TipJar.allCases, id: \.self) { tip in
                Button(action: {}) {
                    HStack {
                        Text(tip.name)
                            .foregroundColor(.primary)
                        Spacer()
                        Text("£??")
                            .foregroundColor(.accentColor)
                    }
                }
            }
        }
        .navigationTitle("Support Pendulum")
    }
}

This presented a list of the available tips, with a place for me to put their prices (once known), and a button to purchase the tip (functionality yet to be completed):

Tip jar mockup

Fetching IAP information with StoreKit 2

The next step was to actually fetch the prices I had defined in App Store Connect within the app, and display them. For this, I needed to use Apple’s StoreKit 2 APIs. The particular one I’m interested in here is Product.products(for:), which returns an array of Product objects for each ID passed in. I decided to add a static method to the TipJar enum to call this with all my IAP IDs, and return a mapping of [TipJar: Product] that the view could use. The new StoreKit 2 APIs are all asyncronous, so my function needed to be to:

import StoreKit

extension TipJar {
    static func fetchProducts() async -> [Self: Product] {
        do {
            let products = try await Product.products(for: Self.allCases.map { $0.rawValue })
            var results: [Self: Product] = [:]
            for product in products {
                if let type = TipJar(rawValue: product.id) {
                    results[type] = product
                }
            }
            return results
        } catch {
            storeLogger.error("Could not fetch products: \(error.localizedDescription)")
            return [:]
        }
    }
}

(I am sure there is a more concise way to compile the dictionary, but those are the kinds of Swift tricks I am not yet proficient enough in the language to be able to come up with when I need them, so a simple for loop had to suffice here.)

With this extension in place, I can add a new State variable to my view, and fetch the product information when the view is loaded:

struct TipJarView: View {
    @State private var tipJarPrices: [TipJar: Product] = [:]

    var body: some View {
        List {
            ...
        }
        .task {
            let products = await TipJar.fetchProducts()
            DispatchQueue.main.async {
                withAnimation {
                    self.tipJarPrices = products
                }
            }
        }
    }
}

I can now use this information in the loop around the products. The Product object provides a displayPrice property, which handily returns the price of the tip in the user’s local currency, with the currency symbol:

ForEach(TipJar.allCases, id: \.self) { tip in
    Button(action: {}) {
        HStack {
            Text(tip.name)
                .foregroundColor(.primary)
            Spacer()
            if let product = tipJarPrices[tip] {
                Text(product.displayPrice)
                    .foregroundColor(.accentColor)
            }
        }
    }
}

After a brief moment with no prices available, they suddenly all fade in:

Tip jar with prices

We can do better than that, though. Using another state variable, we can notify the view when the product information has been loaded, and display a progress spinner until that point. We also need to handle the case that, for some reason, the products have been fetched but a particular tip isn’t present. I chose to do so with a simple warning triangle.

struct TipJarView: View {
    @State private var tipJarPrices: [TipJar: Product] = [:]
    @State private var productsFetched: Bool = false

    var body: some View {
        List {
            ForEach(TipJar.allCases, id: \.self) { tip in
                Button(action: {}) {
                    HStack {
                        Text(tip.name)
                            .foregroundColor(.primary)
                        Spacer()
                        if let product = tipJarPrices[tip] {
                            Text(product.displayPrice)
                                .foregroundColor(.accentColor)
                        } else {
                            if productsFetched {
                                Image(systemName: "exclamationmark.triangle")
                            } else {
                                ProgressView()
                            }
                        }
                    }
                }
            }
        }
        .task {
            let products = await TipJar.fetchProducts()
            DispatchQueue.main.async {
                withAnimation {
                    self.tipJarPrices = products
                    self.productsFetched = true
                }
            }
        }
    }
}

Tip jar loading

Making a purchase

To initiate the actual purchase of the IAP, we need to call the Product‘s .purchase() method. This async method returns a result indicating whether the purchase was successful or not, and a few other bits of information. As is my way, I chose to wrap this up in a method on the TipJar enum:

extension TipJar {
    func purchase(_ product: Product) async -> Bool {
        storeLogger.debug("Attempting to purchase \(self.rawValue)")
        do {
            let purchaseResult = try await product.purchase()
            switch purchaseResult {
            case .success(let verificationResult):
                storeLogger.debug("Purchase result: success")
                switch verificationResult {
                case .verified(let transaction):
                    storeLogger.debug("Purchase success result: verified")
                    await transaction.finish()
                    return true
                default:
                    storeLogger.debug("Purchase success result: unverified")
                    return false
                }
            default:
                return false
            }
        } catch {
            storeLogger.error("Could not purchase \(self.rawValue): \(error.localizedDescription)")
            return false
        }
    }
}

For ease of use in the view, I convert the result into a simple true or false for whether the purchase went through successfully. In the view, I can fire this off inside a Task in the button’s action method, and handle the response appropriately. In this case, I want to display an alert saying thank you on a successful purchase, and do nothing if it was cancelled:

struct TipJarView: View {
    ...
    @State private var showingSuccessAlert: Bool = false
    var body: some View {
        List {
            ForEach(TipJar.allCases, id: \.self) { tip in
                Button(action: {
                    storeLogger.debug("\(tip.rawValue) tapped")
                    if let product = tipJarPrices[tip] {
                        Task {
                            if await tip.purchase(product) {
                                DispatchQueue.main.async {
                                    withAnimation {
                                        showingSuccessAlert = successful
                                    }
                                }
                            }
                        }
                    }
                }) {
                    ...
                }
            }
        }
        .alert(isPresented: $showingSuccessAlert) {
            Alert(title: Text("Purchase Successful"), message: Text("Thank you for supporting Pendulum!"), dismissButton: .default(Text("🧡")))
        }
        ...
    }
}

Tip jar success alert

We now have a fully-functional tip jar! Users can view the list of tips, complete with pricing information in their own local currency, and tap on a tip to purchase it.

Preventing user interaction

The final niggle I wanted to fix was preventing user interaction with the tip buttons in three situations:

The first two situations can be handled together: in both cases, the tipJarPrices dict has no entry for the given tip. A simple disabled modifier on the Button will prevent the user from tapping it:

struct TipJarView: View {
    ...
    var body: some View {
        List {
            ForEach(TipJar.allCases, id: \.self) { tip in
                Button(action: { ... }) {
                    ...
                }
                .disabled(tipJarPrices[tip] == nil)
            }
        }
        ...
    }
}

The latter requires us to store some information about whether a purchase is in progress or not. Again, a State variable is perfect here. We can set it when the tip is tapped, and check it later:

struct TipJarView: View {
    ...
    @State private var pendingPurchase: TipJar? = nil
    var body: some View {
        List {
            ForEach(TipJar.allCases, id: \.self) { tip in
                Button(action: {
                    guard pendingPurchase == nil else { return }
                    storeLogger.debug("\(tip.rawValue) tapped")
                    if let product = tipJarPrices[tip] {
                        withAnimation {
                            pendingPurchase = tip
                        }
                        Task {
                            let successful = await tip.purchase(product)
                            storeLogger.debug("Successful? \(successful)")
                            DispatchQueue.main.async {
                                withAnimation {
                                    showingSuccessAlert = successful
                                    pendingPurchase = nil
                                }
                            }
                        }
                    }
                }) {
                    GroupBox {
                        HStack {
                            Text("\(tip.name) Tip")
                                .fullWidth()
                            if let product = tipJarPrices[tip] {
                                if pendingPurchase == tip {
                                    ProgressView()
                                } else {
                                    Text(product.displayPrice)
                                        .foregroundColor(.accentColor)
                                }
                            } else {
                                if productsFetched {
                                    Image(systemName: "exclamationmark.triangle")
                                } else {
                                    ProgressView()
                                }
                            }
                        }
                    }
                    .foregroundColor(.primary)
                }
                .disabled(tipJarPrices[tip] == nil || pendingPurchase != nil)
            }
        }
        ...
    }
}

There’s a few parts to this code, so I’ll highlight them here:

Tip jar Apple confirmation

What I learned

In putting this tip jar together, I learned a number of things, not least:

There are a number of ways we could improve upon this basic tip jar, but it’s a pretty decent start. In my own implementation in the app, I also added support for storing a history of how many of each tip the user has purchased, in order to show them the size of their “tip collection” for a little bit of whimsy (and to hopefully encourage more tips!). That’s left as an exercise for the reader; but I’m storing the information in NSUbiquitousKeyValueStore, a useful little class that automatically syncs its using iCloud.

Hopefully you’ve found some useful information in this post to inspire you to add a tip jar to your own indie apps! I’d love to hear about them: let me know on Mastodon.