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.

In-app subscriptions in a Twitter clone made easy: a RevenueCat + Stream tutorial
Peter Meinertzhagen

Peter Meinertzhagen

PublishedLast updated

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. 

Figure 1. Create your RevenueCat account

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.

Figure 2. Create your project within the RevenueCat dashboard

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. 

Figure 3. Add your app within your RevenueCat project

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.

Figure 4. Create your Subscription in App Store Connect

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		[
34		 .remote(url: "https://github.com/RevenueCat/purchases-ios.git", requirement: .range(from: "4.0.0", to: "5.0.0")),5    ],
6    productTypes: [
78        "RevenueCat": .framework,
910    ]
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//
89import SwiftUI
10import Foundation
11import RevenueCat
12import Auth
1314public class PurchaseViewModel: ObservableObject {
15    @Published
16    var offerings: Offerings?
17    
18    @Published
19    var isSubscriptionActive = false
2021    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            }
3233        }
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:

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.

Related posts

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
Engineering

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake

Challenges, solutions, and insights from optimizing our data ingestion pipeline.

Jesús Sánchez

Jesús Sánchez

April 15, 2024

How RevenueCat handles errors in Google Play’s Billing Library
How RevenueCat handles errors in Google Play’s Billing Library  
Engineering

How RevenueCat handles errors in Google Play’s Billing Library  

Lessons on Billing Library error handling from RevenueCat's engineering team

Cesar de la Vega

Cesar de la Vega

April 5, 2024

Use cases for RevenueCat Billing
Engineering

Use cases for RevenueCat Billing

3 ways you can use the new RevenueCat Billing beta today.

Charlie Chapman

Charlie Chapman

March 21, 2024

Want to see how RevenueCat can help?

RevenueCat enables us to have one single source of truth for subscriptions and revenue data.

Olivier Lemarié, PhotoroomOlivier Lemarié, Photoroom
Read Case Study