In-app subscriptions in a Twitter clone made easy: a RevenueCat + Stream tutorial
Learn how to build a fully functioning app using SwiftUI, with in-app subscriptions, in a weekend.
Peter Meinertzhagen
Last month, Stream launched their superb tutorial project, teaching you how to build a Twitter clone with Swift UI in just a weekend.
This resource teaches developers a bunch of things. Firstly, if you’ve ever wanted to learn more about iOS and SwiftUI development, then this is a quick self-contained project that will give you something tangible by the end of it. Secondly, it shows all developers the power of using third-party platforms to power discreet functionality in your app.
Stream for chat and activity feeds, Algolia for search, 100ms for audio rooms, Mux for video playback, and RevenueCat for subscriptions. Powering up your project with these platforms is the only way you’d get this done, with little sweat, in a weekend.
In our portion of this tutorial, we’re going to spotlight the in-app subscriptions step of the project using RevenueCat. To get started, download the source code and project demo from Github. If you haven’t already, you’ll want to check out parts 1-6 of the tutorial series first.
This tutorial is broken down into five parts:
1. Create a RevenueCat account.
2. Create a project on the dashboard and select the platform of your app.
3. Configure your product in App Store Connect.
4. Configure your product in RevenueCat.
5. Install and configure the RevenueCat SDK.
As we go, we’ll be highlighting the key things to know as we progress through configuring your in-app subscription — please refer to our SDK quick-start and SDK configuration guides for further resources.
1. Create a RevenueCat account
Sign up for a new RevenueCat account here.
It’s worth bearing in mind for the future that we recommend setting up a separate RevenueCat account for each unique project or app you work on. Should you wish to sell or transfer ownership down the line, this makes it easier.
2. Create a project on the dashboard and select the platform of your app
Navigate to the RevenueCat dashboard and add a new project from the dropdown in the top navigation menu called Projects.
Next you need to add an app. Choose ‘App Store’ as your platform and give your app a name. We’ll add the rest of the details later.
3. Configure your product in App Store Connect
Next we’ll set up our Twitter Blue subscription in App Store Connect.
Rather than copy and paste the same information, you can find full details on how to set your product up in App Store Connect in our iOS product setup guide.
Follow the instructions that guide. When you create your Subscription, make a note of your Product ID — we’ll be using that in the next step.
4. Configure your product in RevenueCat
Next up, we’ll return to the RevenueCat dashboard and configure the product you just set up in App Store Connect.
Once again, we have a very comprehensive tutorial that covers the ins and outs of product configuration in RevenueCat. We recommend that you give that a read. However, one thing to keep in mind, when you create your entitlement in RevenueCat, call it “blue” to match Stream’s SDK configuration guide.
Other than that, go ahead and match your App Store Connect product to a new one in RevenueCat.
5. Install and configure the RevenueCat SDK
We have guides outlining how to install and configure the RevenueCat SDK for iOS (and other platforms).
However, for the purposes of this tutorial, we’ll be using the guide and code that Stream has already created. This method of integration uses Tuist, which we don’t cover in our documentation.
First of all, add the RevenueCat dependencies as a Swift Package to your Tuist dependencies.swift file.
1var swiftPackageManagerDependencies = SwiftPackageManagerDependencies(
2 [
3 …
4 .remote(url: "https://github.com/RevenueCat/purchases-ios.git", requirement: .range(from: "4.0.0", to: "5.0.0")), …
5 ],
6 productTypes: [
7 …
8 "RevenueCat": .framework,
9 …
10 ]
11)
Then add the dependency in the Tuist project, in project.swift, to a target.
1let profileTarget =
2 Project.makeFrameworkTargets(name: profileName,
3 platform: .iOS,
4 dependencies: [
5 .target(name: authName),
6 .target(name: authUiName),
7 .target(name: messagesName),
8 .target(name: uiName),
9 .external(name: "RevenueCat")
10 ])
11
Now fetch the dependency by running:
1tuist fetch
And then generate the Xcode project again by running:
1tuist generate
From Stream’s codebase, you can find and explore how they implemented RevenueCat in the following Swift files in the folders Profile -> Sources from the Project Navigator.
PurchaseViewModel.swift
1//
2// PurchaseManager.swift
3// Profile
4//
5// Created by Jeroen Leenarts on 13/02/2023.
6// Copyright © 2023 Stream.io Inc. All rights reserved.
7//
8
9import SwiftUI
10import Foundation
11import RevenueCat
12import Auth
13
14public class PurchaseViewModel: ObservableObject {
15 @Published
16 var offerings: Offerings?
17
18 @Published
19 var isSubscriptionActive = false
20
21 public func configure(userId: String?) {
22 let configuration = Configuration.Builder(withAPIKey: "appl_ffoirKXwYnVnlhIlEaExRfMZxxf")
23 .with(appUserID: userId)
24 .build()
25 Purchases.configure(with: configuration)
26
27 Purchases.shared.getCustomerInfo { customerInfo, error in
28 self.isSubscriptionActive = customerInfo?.entitlements.all["blue"]?.isActive == true
29 if let error {
30 print(error)
31 }
32
33 }
34
35 Purchases.shared.getOfferings { offerings, error in
36 if let offerings {
37 self.offerings = offerings
38 }
39 if let error {
40 print(error)
41 }
42 }
43 }
44}
From the code above, the SDK is first initialized using the public API key obtained from your RevenueCat project settings and the shared instance of the RevenueCat SDK is configured during the app launch. For any subscription app, it is necessary to get the users’ subscription status. The RevenueCat SDK makes this easy using the customer information object returned from the Purchases.shared.getCustomerInfo method as shown in the code above.
Next, the code retrieves information about TwitterClone’s available in-app purchases from App Store Connect using the Purchases.shared.getOfferings method. You can then display those available products in your TwitterClone paywall.
SubscribeBlue.swift
In your SubscribeBlue.swift SwiftUI view, add a property and an init method that accepts a Package from the Offerings object in the previous step. Every Offering needs a Package, which is simply a group of equivalent products across iOS, Android, and web — however, as we’re only dealing with one platform here, our Package contains only iOS products.
1var package: Package
2 public init(package: Package) {
3 self.package = package
4 }
Then, implement the code sample below demonstrating purchasing a package using a subscribe button.
1Button {
2 Purchases.shared.purchase(package: package) { transaction, customerInfo, error, userCancelled in
3 if customerInfo?.entitlements.all["blue"]?.isActive == true {
4 print("Bought")
5 }
6 }
7
8 } label: {
9 Text("Subscribe for $2.99/month")
10 Image(systemName: "checkmark.seal.fill")
11 }
12 .buttonStyle(.bordered)
13 .padding(.top, 32)
14
See the full implementation in the code sample below.
SubscribeBlue.swift
1import SwiftUI
2import RevenueCat
3
4public struct SubscribeBlue: View {
5 var package: Package
6 public init(package: Package) {
7 self.package = package
8 }
9
10 @State private var isShowingConfirmSheet = false
11
12 public var body: some View {
13 NavigationStack {
14 VStack {
15 VStack(alignment: .leading, spacing: 16) {
16 Text("Get more out of Twitter Blue with exclusive features")
17 .font(.title)
18 .multilineTextAlignment(.center)
19 .padding(.bottom)
20
21 HStack(alignment: .top) {
22 Image(systemName: "circle.slash")
23 VStack(alignment: .leading) {
24 Text("Ad-free articles")
25 .font(.headline)
26 Text("Read ad-free articles from popular websites with no pay walls")
27 }
28 }
29
30 HStack(alignment: .top) {
31 Image(systemName: "flame")
32 VStack(alignment: .leading) {
33 Text("Top articles")
34 .font(.headline)
35 Text("Read ad-free articles from popular websites with no pay walls")
36 }
37 }
38
39 HStack(alignment: .top) {
40 Image(systemName: "pin.circle")
41 VStack(alignment: .leading) {
42 Text("Custom navigation")
43 .font(.headline)
44 Text("Read ad-free articles from popular websites with no pay walls")
45 }
46 }
47
48 HStack(alignment: .top) {
49 Image(systemName: "theatermask.and.paintbrush")
50 VStack(alignment: .leading) {
51 Text("Custom app icon and themes")
52 .font(.headline)
53 Text("Read ad-free articles from popular websites with no pay walls")
54 }
55 }
56 }
57 .toolbar {
58 ToolbarItem(placement: .principal) {
59 HStack {
60 Image(systemName: "checkmark.seal.fill")
61 Text("Blue")
62 }
63 .foregroundColor(.streamBlue)
64
65 }
66 }
67 Button { // Make a purchase
68 Purchases.shared.purchase(package: package) { transaction, customerInfo, error, userCancelled in
69 if customerInfo?.entitlements.all["blue"]?.isActive == true {
70 print("Bought")
71 }
72 }
73
74 } label: {
75 Text("Subscribe for $2.99/month")
76 Image(systemName: "checkmark.seal.fill")
77 }
78 .buttonStyle(.bordered)
79 .padding(.top, 32)
80 }
81 }
82 }
83}
84
When the subscription is successful, the user gets the confirmation message “You are subscribed.” Otherwise, we send the customer to the subscription screen (SubscribeBlue.swift).
1if purchaseViewModel.isSubscriptionActive {
2 Text("You are subscribed")
3 .padding(.top)
4 } else {
5 if let packages = purchaseViewModel.offerings?.current?.availablePackages {
6 ForEach(packages) { package in
7 SubscribeBlue(package: package)
8 }
9 }
10 }
11
See the full implementation in Stream’s SettingsView.swift file.
1import SwiftUI
2import TwitterCloneUI
3import AuthUI
4import Auth
5import Feeds
6import Chat
7import DirectMessages
8import RevenueCat
9
10public struct SettingsView: View {
11 @EnvironmentObject var feedsClient: FeedsClient
12 @EnvironmentObject var auth: TwitterCloneAuth
13 @EnvironmentObject var chatModel: ChatModel
14 @EnvironmentObject var purchaseViewModel: PurchaseViewModel
15 @Environment(\.presentationMode) var presentationMode
16
17 @StateObject var mediaPickerViewModel = MediaPickerViewModel()
18
19 @State private var isEditingName = "Amos Gyamfi"
20 @State private var isEditingUserName = false
21 @State private var isEditingPassword = false
22 @State private var isLoggedOut = false
23 public init () {}
24
25 public var body: some View {
26 NavigationStack {
27 List {
28 HStack {
29 Button {
30 print("Open the photo picker")
31 } label: {
32 HStack {
33 ZStack {
34 ProfileImage(imageUrl: "https://picsum.photos/id/64/200", action: {})
35 .opacity(0.6)
36 MediaPickerView(viewModel: mediaPickerViewModel)
37 }
38 Image(systemName: "pencil")
39 .fontWeight(.bold)
40 }
41 }
42
43 Spacer()
44 }
45
46 HStack {
47 Text("Change your Name")
48 TextField("Amos Gyamfi", text: $isEditingName)
49 .foregroundColor(.streamBlue)
50 .labelsHidden()
51 }
52
53 NavigationLink {
54 EditUserName()
55 } label: {
56 Button {
57 self.isEditingUserName.toggle()
58 } label: {
59 HStack {
60 Text("Change your username")
61 Spacer()
62 Text("@stefanjblos")
63 }
64 }
65 }
66
67 NavigationLink {
68 EditPassword()
69 } label: {
70 Button {
71 self.isEditingPassword.toggle()
72 } label: {
73 HStack {
74 Text("Change your password")
75 Spacer()
76 }
77 }
78 }
79
80 if purchaseViewModel.isSubscriptionActive {
81 Text("You are subscribed")
82 .padding(.top)
83 } else {
84 if let packages = purchaseViewModel.offerings?.current?.availablePackages {
85 ForEach(packages) { package in
86 SubscribeBlue(package: package)
87 }
88 }
89 }
90 }
91 .listStyle(.plain)
92 .navigationTitle("")
93 .navigationBarTitleDisplayMode(.inline)
94 .frame(maxHeight: 280)
95 .toolbar {
96 ToolbarItem(placement: .principal) {
97 Text("Your acount settings")
98 }
99 }
100
101 Button(role: .destructive) {
102 presentationMode.wrappedValue.dismiss()
103 auth.logout()
104// chatModel.logout()
105 } label: {
106 Image(systemName: "power.circle.fill")
107 Text("Log out")
108 }
109
110 Spacer()
111 }
112 }
113}
114
That’s it! Told you it would be quick
At this point, we’d recommend that you go and read Stream’s tutorial wrap-up.
And if you’re itching to complete more tutorials, then you might be interested in:
- our guide to building a SwiftUI app with in-app subscriptions with StoreKit 2 (and although you’ll quickly come to the conclusion that it was easier with RevenueCat all along, you’ll nevertheless pick up some useful knowledge)
- our guide to implementing in-app subscriptions in an Android app (using RevenueCat)
- and our guide to implementing in-app purchases in a React Native app with Expo
In-App Subscriptions Made Easy
See why thousands of the world's tops apps use RevenueCat to power in-app purchases, analyze subscription data, and grow revenue on iOS, Android, and the web.