FreemiumKit

0.1.2

Lightweight layer on top of StoreKit 2 + built-in permission engine & built-in UI components for SwiftUI paywalls.
FlineDev/FreemiumKit

What's New

2023-09-10T11:23:32Z

⚠️ While I use this framework in production for my app Twoot it!, the paywall part got kinda sherlocked by Apple and soon also RevenueCat will ship a paywall framework, so I'm planning to give these a try and might not continue working on this. Apple's solution lacks customizability though. For example, they don't allow hiding the description (FB12261973), showing the monthly price for a yearly subscription (FB12261899), placing the price horizontally (FB12262030), or showing badges on top of products (FB12262171). Also, they're only available in iOS 17+, while FreemiumKit supports iOS 15+ (like StoreKit 2).

FreemiumKit Logo

FreemiumKit

Lightweight layer on top of StoreKit 2 + built-in permission engine & built-in UI components for SwiftUI paywalls.

Read this introductory article to learn about my motivation to create this framework and how I came up with the "Paywall Blueprint" designs this library ships with.

Getting Started

Here are the minimum steps you need to take to make use of FreemiumKit (obviously, you first need to add it as a package dependency):

Step 0: Obviously, you need to add FreemiumKit to your app as a package dependency first. See Apples official guide.

Step 1: Define a type that conforms to RawRepresentableProductID

This is required so FreemiumKit knows what products you want to present to the users. Make sure to use the correct identifiers of the products you created on App Store Connect as the raw String values:

enum ProductID: String, CaseIterable, RawRepresentableProductID {
   case proMonthly = "dev.fline.TwootIt.Pro.Monthly"
   case proYearly = "dev.fline.TwootIt.Pro.Monthly"
   case proLifetime = "dev.fline.TwootIt.Pro.Lifetime"
   
   case liteMonthly = "dev.fline.TwootIt.Lite.Monthly"
   case liteYearly = "dev.fline.TwootIt.Lite.Yearly"
   case liteLifetime = "dev.fline.TwootIt.Lite.Lifetime"
}

Note that it is totally possible to provide more cases here than you present to users. For example, if you have a product that you only want to offer to customers that unsubscribed to win them back (re-engagement offers), add it here.

Step 2: Define a type that conforms to Unlockable

This is part of the built-in permissions system that will help you decide which features your user has access to. Specify the different kinds of features you lock or limit for lower/free tiers here. Note that you decide if you prefer a more fine-grained control or if you want to group features into broader topics and just list those:

enum LockedFeature: Unlockable {
   case twitterPostsPerDay
   case extendedAttachments
   case scheduledPosts

   func permission(purchasedProductIDs: Set<ProductID>) -> Permission {
      switch self {
      case .twitterPostsPerDay:
         return purchasedProductIDs.contains(where: \.rawValue, prefixedBy: "dev.fline.TwootIt.Pro") ? .limited(3) : .locked 
      case .extendedAttachments:
         return purchasedProductIDs.isEmpty ? .locked : .unlimited
      case .scheduledPosts:
         return purchasedProductIDs.isEmpty ? .limited(1) : .unlimited
      }
   }
}

Note that you have to implement the permission(purchasedProductIDs:) function yourself. In it, you get passed a set of ProductIDs (the type you defined in step 1) and you have to return a Permission, one of .locked, .limited(Int), or .unlimited. You can make use of the contains(where:...:) convenience functions FreemiumKit ships with.

Step 3: Initialize an instance of InAppPurchase on app start

In a SwiftUI app, using a simple global instance, this could look something like this:

import SwiftUI
import FreemiumKit

final class AppDelegate: NSObject, UIApplicationDelegate {
   static let inAppPurchase = InAppPurchase<ProductID>()
   
   func application(
      _ application: UIApplication,
      willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
   ) -> Bool {
      Self.inAppPurchase.appLaunched()
      return true
   }
}

@main
struct FreemiumKitDemoApp: App {
   @UIApplicationDelegateAdaptor(AppDelegate.self)
   var appDelegate

   var body: some Scene {
      WindowGroup {
         ContentView()
      }
   }
}

Note that you need to pass your custom ProductID type as the generic type to InAppPurchase to let it know about your apps product IDs. The sheer initialization of InAppPurchase will activate your apps integration with StoreKit and load the users purchased products on app start. It will also take care of observing any changes while the app is running, so your users purchases are always correct.

Step 4: Lock your features if user doesn't have permission

Now, anywhere in your app where you have features that are potentially locked, you can ask InAppPurchase what the current permissions for your custom LockedFeature type are at the moment:

let permission = AppDelegate.inAppPurchase.permission(for: LockedFeature.scheduledPosts)

The Permission type which you receive as a response to permission(for:) is an enum with the cases locked, limited(Int), and unlimited that you can switch over. But it also comes with a bunch of convenience APIs so no switch-case is ever needed:

  • permission.isAlwaysGranted returns true if the permission is set to unlimited
  • permission.isAlwaysDenied returns true if the permission is set to locked
  • permission.limit returns an Int that represents the allowed count (0 if locked, Int.max if unlimited)
  • permission.isGranted(current: Int) returns true if the current "usage count" doesn't exceed the allowed limit
  • permission.isDenied(current: Int) returns true if the current "usage count" equals or exceeds the allowed limit

So, you might do something like this:

Button("Schedule Post") { ... }
   .disabled(
      AppDelegate.inAppPurchase
         .permission(for: LockedFeature.schedulesPosts)
         .isDenied(current: scheduledPosts.count)
   )

Note that FreemiumKit does not help persisting your current usage count, you need to handle that yourself, e.g. using UserDefaults or requesting your server API.

Step 5: Show your products & handle purchase completion in your paywall

Lastly, whenever you present your paywall, you can use one of the provided UI components so you don't have to fetch your products from App Store Connect and present them in a nice way yourself. The UI part is what really saves a lot of time when integrating in-app purchases, and thanks to the open AsyncProductsStyle protocol, the community can add new UI styles over time so you can quickly switch between different styles, following current trends or doing A/B testing easily.

For a full list of all available UI components, see the next section. But after some research I created the VerticalPickerProductsStyle which is a good one to start with as it's clean, flexible, and proven to be succesful in many high-grossing apps:

// in your paywall SwiftUI screen, place this view where you need it (for iOS, bottom half of the screen is recommended)
AsyncProducts(
   style: VerticalPickerProductsStyle(preselectedProductID: ProductID.proYearly), 
   productIDs: ProductID.allCases, 
   inAppPurchase: AppDelegate.inAppPurchase
)
.padding()

Note that instead of VerticalPickerProductsStyle you can pass any other community-provided or even your custom style, or pass some of the optional parameters to VerticalPickerProductsStyle. Also, instead of ProductID.allCases, you can pass an array with only select cases if you don't want to show all available options at once (like excluding re-engagement offers).

The resulting screen should look something like this (the AsyncProducts view is highlighted):

Note that the AsyncProducts initializer takes several optional arguments, one of them is onPurchase which you can use to close your paywall or do whatever the next step is after a successful purchase. For example:

@Environment(\.dismiss)
var dismiss
// ...
AsyncProducts(style: ..., productIDs: ..., inAppPurchase: ..., onPurchase: { _ in self.dismiss() })
Read this if any of your products is a Consumable

Typically, you need to execute some code to provide the purchased consumable thing to your user, and often this code involves sending requests to a server. To ensure a user actually gets the purchased consumable, StoreKit requires you to call the `finish()` method on the purchased `Transaction`. FreemiumKit defaults to automatically calling `finish()` right after a transaction was successfully made, but for consumables, it's better you handle this manually. To do that, make sure to set the optional parameter `autoFinishPurchases` to `false` on the `AsyncProducts` initializer. Then, use the `transaction` parameter passed to the optional `onPurchase` closure of the same initializer to call `finish()` once you provided your user with the purchased consumable item(s). Any consumable items you neve called `finish()` on will be delivered to the app on each app start and can be handled by using the `onPurchase` closure of the `InAppPurchase` initializer.

Step 6: Provide a 'Restore Purchases' button to pass App Store Review

While FreemiumKit implements the latest proactive in-app purchase restore best practice, Apple still recommends adding a 'Restore Purchases' button to your app. It's also explicitly mentioned in the App Store Review guidelines (see section 3.1.1 In-App Purchase). To give you full flexibility of deciding where to put your 'Restore Purchases' button and to allow you placing it among other buttons like 'Terms of Service' or 'Privacy Policy', FreemiumKit does not place a 'Restore Purchases' button into the AsyncProducts view.

Instead, a separate RestorePurchasesButton view is provided that encapsulates a button with loading logic & even a loading state which you can place anywhere you see fit and it will just work. Note that the view isn't styled in any way though, so you need to style it the way you want, allowing you to provide a custom .buttonStyle(), for example. Most apps probably want to keep the style to the default (.plain) though, but "down-pop" the button to make it less prominent like so:

RestorePurchasesButton(inAppPurchase: AppDelegate.inAppPurchase)
   .font(.footnote)
   .foregroundColor(.secondary)

And that's it! You've added support for in-app purchases to your app. 🎉

Provided UI Components

To get you up and running fast, FreemiumKit ships with a set of community-provided UI components that you can use in your SwiftUI paywall:

TODO: table with a preview image for each and which features are supported (like showing subscriptions, consumables, etc.) + original author

Implementing a Custom UI

While FreemiumKit ships with the above UI components that you can use out of the box, you can provide your entirely custom UI:

TODO: explain how to conform to AsyncProductsStyle and that it's recommended to copy & adjust PlainAsyncProdcutsStyle which is its whole purpose

Note: If you implemented a somewhat different UI and have the chance to share it with the community, I'm happy to review your PR!

Project Scope

The purpose of this project is to make common In-App Purchase scenarios as easy as possible. But it's not the goal to cover every single feature of StoreKit 2. For example, FreemiumKit automatically handles expired & revoked purchases without passing on details like revocation/expiration date to the app. Instead, it defaults to what most developers probably want: Ignoring them.

So, if you're missing a feature in FreemiumKit, you are free to request the feature in the Issues tab. But please provide a reason why you think that the feature is needed by many developers. Except for the UI components, this library is really lightweight and the core logic is unlikely to get many changes. So forking the library is a viable option.

TODOs

While basic In-App Purchases are already covered, there are several extra features I'd like to add over time. These include:

  • Automatically calculate long-term subcription savings over short-term ones & show "Save 20%" badge
  • Support for specifying a "Popular" or "Best Value" badge to selected plans
  • Implement HorizontalPickerProductsStyle
  • Implement an Apple-style HorizontalButtonsProductsStyle (like in the Final Cut Pro for iPad paywall)
  • Support for other kinds of Introductory Offers than freeTrial
  • Support for Promotional Offers

Contributing

If you find any issues with this project, make sure to report them in the Issues tab. Any questions should be asked in the Discussions tab.

If you want to share your custom UI component with the community, that's also highly encouraged! It can even be a cool SwiftUI programming challenge to find a paywall design you like (e.g. from here) and trying to implement it & sharing with the community. Corrections to the machine-translated localized texts are welcome, too. Send in a pull request with your corrections.

Note that in order to provide localized texts to over 150 languages in fast & simple way, this library is makes use of RemafoX. If you want to use it for your custom UI component, download it there – it's fully featured without limits for for open-source projects like this. To set it up quickly for this project, watch this short onboarding video.

FAQ

Does FreemiumKit do receipt validation?

Yes: FreemiumKit is built on top of StoreKit 2 which automatically verifies any transaction is "signed by the App Store for my app for this device" (quote from WWDC22 session "Meet StoreKit 2") before passing them on. It leaves developers the choice to accept even unverified purchases or to ignore them, depending on their business needs. But FreemiumKit doesn't do that, it simply ALWAYS ignores them. When FreemiumKit passes a transaction to the UI component, it has already successfully passed validation. 💯

I can't decide: Should I use a service like RevenueCat or StoreKit directly with this library?

The purpose of this library is to make integrating with StoreKit 2 as easy as possible. It does this job much better than the SDKs of RevenueCat and the like which don't help with permission checking and don't provide UI components.

The purpose of those services was also to make integrating with StoreKit easier, but that was because StoreKit 1 was much harder to work with and quite limited, too. Apple improved that situation vastly with StoreKit 2 in iOS 15+ so that advantage no longer holds true. But these services not only make things easier for StoreKit 1, they also add a lot of other value, like providing live purchase stats on their site (Connect data is delayed), providing an overview of your total income if you also support other platforms like Android, and much more. If you need any of these things, you might want to use those services. But if the data on App Store Connect is enough for you and all you want is to provide In-App Purchases on Apple platforms in the simplest way possible and you are on iOS 15+ (that's when StoreKit 2 arrived), I recommend FreemiumKit.

Can I use FreemiumKit and services like RevenueCat side-by-side?

Maybe. It was never the goal of FreemiumKit to be used in combination with those services, but some may want to use the permissions & UI capabilities of FreemiumKit while also profiting from the extra features of such a service. I'm not one of those people though, so I can't provide any support here. It's best you contact those services directly. The license of FreemiumKit allows for them to fork it or copy any code they like into their own SDKs.

License

This library is released under the MIT License. See LICENSE for details.

Description

  • Swift Tools 5.8.0
View More Packages from this Author

Dependencies

Last updated: Tue Apr 30 2024 14:40:29 GMT-0900 (Hawaii-Aleutian Daylight Time)