What’s new in Apphud: Free Trials Performance Report, Paddle Integration, SDK Updates, and moreLet’s see
Apphud
Why Apphud?PricingContact
Ren
Ren
July 04, 2019
10 min read

SwiftUI & auto-renewable subscriptions

In this article we will show how to implement purchase screens using SwiftUI and handle auto-renewable subscriptions status.

SwiftUI & auto-renewable subscriptions

Before We Begin

If you are unfamiliar with SwiftUI, you can read this good article.

To start we will need the latest Xcode. Create a new project and make sure you have checked "Use SwiftUI".

SwiftUI  is a framework for writing user interfaces. We can’t actually create a StoreKit wrapper or something that is not a part of UI. So we need a StoreKit helper to use in our project. You can use any solution you want, for example, SwiftyStoreKit. But we will use this class from our previous article.

Coding Part

On our main screen we will show the list of all our subscriptions, with expiration dates for purchased ones. Let’s see how we create a main view from SceneDelegate:

ProductsStore.shared.initializeProducts()
if let windowScene = scene as? UIWindowScene {
	let window = UIWindow(windowScene: windowScene)
	window.rootViewController = UIHostingController(rootView: ContentView(productsStore: ProductsStore.shared))
	self.window = window
	window.makeKeyAndVisible()
}

Here we create a singleton class ProductsStore, which initializes our products. Then a ContentView is being created and shared instance is passed as a parameter.

class ProductsStore : ObservableObject {
    static let shared = ProductsStore()
    @Published var products: \[SKProduct\] = \[\]
    @Published var anyString = "123" // little trick to force reload ContentView from PurchaseView by just changing any Published value
    func handleUpdateStore(){
        anyString = UUID().uuidString
    }

    func initializeProducts(){
        IAPManager.shared.startWith(arrayOfIds: \[subscription\_1, subscription\_2\], sharedSecret: shared\_secret) { products in    
            self.products = products   
        }
    }
}

Let’s dive into ProductsStore class. It’s a small class, which has one function: loads SKProduct objects and passes them to SwiftUI views using object observing. You can see that ProductsStore conforms toObservableObject.

What is ObservableObject and @Published keyword?

ObservableObject  is a special protocol to track changes of it's properties (marked with @Published keyword) and reflect those changes in SwiftUI views. The only requirement of ObservableObject protocol  is to have a property with @Published keyword which will change in some time. You can treat it like a Notification Center for SwiftUI views. In our example, when products array changes, a notification will be sent to all views that listen for changes. To listen for changes you should add a property to your view with a special @ObservedObject keyword.

@ObservedObject var productsStore : ProductsStore

This line of code should be added to our ContentView in order to listen for ProductsStore changes. ContentView will be automatically reloaded when we received products from StoreKit.

In our example, we store products in ProductsStore. But we already have products in IAPManager. Although duplicating same array is not good approach, in this article I wanted to show how object observing works. Furthermore, it’s not always possible to change existing StoreKit libraries, if you use Cocoapods, for example.

It’s worth noting, that besides object observing technique, there are also a simpler @State keyword for observing basic types (Int, String) and more global @EnvironmentObject, which lets you to update all views at once without the need of storing a property.

Let's take a look at the ContentView code:

struct ContentView : View {
    @ObservedObject var productsStore : ProductsStore
	@State var show\_modal = false
    var body: some View {      
        VStack() {
            ForEach (productsStore.products, id: \\.self) { prod in
                Text(prod.subscriptionStatus()).lineLimit(nil).frame(height: 80)
            }
            Button(action: {
                print("Button Pushed")
                self.show\_modal = true
            }) {
                Text("Present")
            }.sheet(isPresented: self.$show\_modal) {
                 PurchaseView()
            }
        }
    }
}

Here we display subscriptions statuses for each product. Using ForEach we create Text views and assign a string from an extension. Since we observe productsStore property, our view will reload each time the products array changes.

subscriptionStatus  is a simple extension function which returns a state of a subscription:

func subscriptionStatus() -> String {
    if let expDate = IAPManager.shared.expirationDateFor(productIdentifier) {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .medium
        let dateString = formatter.string(from: expDate)
        if Date() > expDate {
            return "Subscription expired: \\(localizedTitle) at: \\(dateString)"
        } else {
           return "Subscription active: \\(localizedTitle) until:\\(dateString)"
        }
    } else {
        return "Subscription not purchased: \\(localizedTitle)"
    }
}
This is what our home screen looks likeThis is what our home screen looks like

That’s how ContentView looks like.

Now let’s go to purchase screen. As we know, in a purchase screen we must include a long terms text about subscription. So better to use ScrollView here.

var body: some View {
    ScrollView (showsIndicators: false) {
        VStack {
            Text("Get Premium Membership").font(.title)
            Text("Choose one of the packages above").font(.subheadline)
            self.purchaseButtons()
            self.aboutText()
            self.helperButtons()
            self.termsText().frame(width: UIScreen.main.bounds.size.width)
            self.dismissButton()
            }.frame(width : UIScreen.main.bounds.size.width)
        }.disabled(self.isDisabled)
}

This is the code for PurchaseView. Inside ScrollView you can see that two TextViews are created. Then there are a number of functions. What is happening? Well, here we separated each view to a single function. That is made for 3 reasons:

  • To show you that it is possible to split the code into functions inside ViewBuilder block.
  • To make a code more readable and understandable.
  • At the time of writing, Xcode 11 Beta 2 was hanging and couldn’t compile that complex ViewBuilder block. Splitting the code helped the compiler.

Now, let’s see some functions code:

func purchaseButtons() -> some View {
    // remake to ScrollView if has more than 2 products because they won't fit on screen.
    HStack {
        Spacer()
        ForEach(ProductsStore.shared.products, id: \\.self) { prod in
            PurchaseButton(block: {
                self.purchaseProduct(skproduct: prod)
            }, product: prod).disabled(IAPManager.shared.isActive(product: prod))
        }
        Spacer()
    }
}

Here we just create an HStack view and using ForEach loop add special PurchaseButton views in it. Here’s PurchaseButton implementation:

struct PurchaseButton : View {
    var block : SuccessBlock!
    var product : SKProduct!
    var body: some View {
        Button(action: {
            self.block()
        }) {
        Text(product.localizedPrice()).lineLimit(nil).multilineTextAlignment(.center).font(.subheadline)
            }.padding().frame(height: 50).scaledToFill().border(Color.blue, width: 1)
    }
}

As you see, it’s a simple button which stores a completion block and calls it in an action block. And some style is being applied. The title of the button is returned from an SKProduct’s extension function.

The payment process is implemented like this:

func purchaseProduct(skproduct : SKProduct){
    print("did tap purchase product: \\(skproduct.productIdentifier)")
    isDisabled = true
    IAPManager.shared.purchaseProduct(product: skproduct, success: {
        self.isDisabled = false
        ProductsStore.shared.handleUpdateStore()
        self.dismiss()
    }) { (error) in
        self.isDisabled = false
        ProductsStore.shared.handleUpdateStore()
    }
}

After payment successful block is called, a handleUpdateStore method of ProductsStore is called. This allows our root ContentView to force reload. When PurchaseView dismisses, root view updates their subscriptions statuses.

How dismiss modal is implemented? SwiftUI  is a declarative framework, and you can’t just dismiss a view whenever you want. But there is a way of dismissing by calling a dismiss() method of a wrapper of Environment's presentationMode property. Here’s how we do it:

struct PurchaseView : View {
    @State private var isDisabled : Bool = false
    @Environment(\\.presentationMode) var presentationMode
    private func dismiss() {
        self.presentationMode.wrappedValue.dismiss()
    }
  	func dismissButton() -> some View {
        Button(action: { 
            self.dismiss()
        }) {
            Text("Not now").font(.footnote)
            }.padding()
    }

The syntax is quite complicated, but it means that we bind one of the Environment values with our property. presentationMode property  is a part of Environment values, a special set of global functions and properties. You may be confused, how changing a value can dismiss a modal. But this is the way SwiftUI works. You can’t perform any actions at runtime:   everything is binded at the beginning.

Subscription purchase screenSubscription purchase screen

That’s how PurchaseView looks like.

Conclusion

I hope, this article will be helpful for you. Apple likes when developers use their latest technologies. If you make an app using just iOS 13 SDK and SwiftUI, there is a chance to be featured by Apple. So don’t be afraid of new technologies — use them. The full source code can be downloaded here.

To get more insights regarding subscription app revenue growth read the Apphud Blog.

Ren
Ren
Co-founder at Apphud
Ex iOS app and game developer. 11 years in the industry since iOS 3. More than 50 apps are in the background with 4 exits. Entrepreneur and traveler.