├── PurchaseModel.swift ├── PurchaseView.swift └── README.md /PurchaseModel.swift: -------------------------------------------------------------------------------- 1 | // PurchaseModel SwiftUI 2 | // Created by Adam Lyttle on 7/18/2024 3 | 4 | // Make cool stuff and share your build with me: 5 | 6 | // --> x.com/adamlyttleapps 7 | // --> github.com/adamlyttleapps 8 | 9 | import Foundation 10 | import StoreKit 11 | 12 | class PurchaseModel: ObservableObject { 13 | 14 | @Published var productIds: [String] 15 | @Published var productDetails: [PurchaseProductDetails] = [] 16 | 17 | @Published var isSubscribed: Bool = false 18 | @Published var isPurchasing: Bool = false 19 | @Published var isFetchingProducts: Bool = false 20 | 21 | init() { 22 | 23 | //initialise your productids and product details 24 | self.productIds = ["demo_y", "demo_w"] 25 | self.productDetails = [ 26 | PurchaseProductDetails(price: "$25.99", productId: "demo_y", duration: "year", durationPlanName: "Yearly Plan", hasTrial: false), 27 | PurchaseProductDetails(price: "$4.99", productId: "demo_w", duration: "week", durationPlanName: "3-Day Trial", hasTrial: true) 28 | ] 29 | 30 | } 31 | 32 | func purchaseSubscription(productId: String) { 33 | //trigger purchase process 34 | } 35 | 36 | func restorePurchases() { 37 | //trigger restore purchases 38 | } 39 | 40 | } 41 | 42 | class PurchaseProductDetails: ObservableObject, Identifiable { 43 | let id: UUID 44 | 45 | @Published var price: String 46 | @Published var productId: String 47 | @Published var duration: String 48 | @Published var durationPlanName: String 49 | @Published var hasTrial: Bool 50 | 51 | init(price: String = "", productId: String = "", duration: String = "", durationPlanName: String = "", hasTrial: Bool = false) { 52 | self.id = UUID() 53 | self.price = price 54 | self.productId = productId 55 | self.duration = duration 56 | self.durationPlanName = durationPlanName 57 | self.hasTrial = hasTrial 58 | } 59 | 60 | } 61 | 62 | -------------------------------------------------------------------------------- /PurchaseView.swift: -------------------------------------------------------------------------------- 1 | // PurchaseView SwiftUI 2 | // Created by Adam Lyttle on 7/18/2024 3 | 4 | // Make cool stuff and share your build with me: 5 | 6 | // --> x.com/adamlyttleapps 7 | // --> github.com/adamlyttleapps 8 | 9 | // Special thanks: 10 | 11 | // --> Mario (https://x.com/marioapps_com) for recommending changes to fix 12 | // an issue Apple had rejecting the paywall due to excessive use of 13 | // the word "FREE" 14 | 15 | import SwiftUI 16 | 17 | struct PurchaseView: View { 18 | 19 | @StateObject var purchaseModel: PurchaseModel = PurchaseModel() 20 | 21 | @State private var shakeDegrees = 0.0 22 | @State private var shakeZoom = 0.9 23 | @State private var showCloseButton = false 24 | @State private var progress: CGFloat = 0.0 25 | 26 | @Binding var isPresented: Bool 27 | 28 | @State var showNoneRestoredAlert: Bool = false 29 | @State private var showTermsActionSheet: Bool = false 30 | 31 | @State private var freeTrial: Bool = true 32 | @State private var selectedProductId: String = "" 33 | 34 | let color: Color = Color.blue 35 | 36 | private let allowCloseAfter: CGFloat = 5.0 //time in seconds until close is allows 37 | 38 | var hasCooldown: Bool = true 39 | 40 | let placeholderProductDetails: [PurchaseProductDetails] = [ 41 | PurchaseProductDetails(price: "-", productId: "demo", duration: "week", durationPlanName: "week", hasTrial: false), 42 | PurchaseProductDetails(price: "-", productId: "demo", duration: "week", durationPlanName: "week", hasTrial: false) 43 | ] 44 | 45 | var callToActionText: String { 46 | if let selectedProductTrial = purchaseModel.productDetails.first(where: {$0.productId == selectedProductId})?.hasTrial { 47 | if selectedProductTrial { 48 | return "Start Free Trial" 49 | } 50 | else { 51 | return "Unlock Now" 52 | } 53 | } 54 | else { 55 | return "Unlock Now" 56 | } 57 | } 58 | 59 | var calculateFullPrice: Double? { 60 | if let weeklyPriceString = purchaseModel.productDetails.first(where: {$0.duration == "week"})?.price { 61 | 62 | let formatter = NumberFormatter() 63 | formatter.numberStyle = .currency 64 | 65 | if let number = formatter.number(from: weeklyPriceString) { 66 | let weeklyPriceDouble = number.doubleValue 67 | return weeklyPriceDouble * 52 68 | } 69 | 70 | 71 | } 72 | 73 | return nil 74 | } 75 | 76 | var calculatePercentageSaved: Int { 77 | if let calculateFullPrice = calculateFullPrice, let yearlyPriceString = purchaseModel.productDetails.first(where: {$0.duration == "year"})?.price { 78 | 79 | let formatter = NumberFormatter() 80 | formatter.numberStyle = .currency 81 | 82 | if let number = formatter.number(from: yearlyPriceString) { 83 | let yearlyPriceDouble = number.doubleValue 84 | 85 | let saved = Int(100 - ((yearlyPriceDouble / calculateFullPrice) * 100)) 86 | 87 | if saved > 0 { 88 | return saved 89 | } 90 | 91 | } 92 | 93 | } 94 | return 90 95 | } 96 | 97 | var body: some View { 98 | ZStack (alignment: .top) { 99 | 100 | HStack { 101 | Spacer() 102 | 103 | if hasCooldown && !showCloseButton { 104 | Circle() 105 | .trim(from: 0.0, to: progress) 106 | .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) 107 | .opacity(0.1 + 0.1 * self.progress) 108 | .rotationEffect(Angle(degrees: -90)) 109 | .frame(width: 20, height: 20) 110 | } 111 | else { 112 | Image(systemName: "multiply") 113 | .resizable() 114 | .aspectRatio(contentMode: .fit) 115 | .frame(width: 20, alignment: .center) 116 | .clipped() 117 | .onTapGesture { 118 | isPresented = false 119 | } 120 | .opacity(0.2) 121 | } 122 | } 123 | .padding(.top) 124 | 125 | VStack (spacing: 20) { 126 | 127 | ZStack { 128 | Image("purchaseview-hero") 129 | .resizable() 130 | .aspectRatio(contentMode: .fit) 131 | .frame(height: 150, alignment: .center) 132 | .scaleEffect(shakeZoom) 133 | .rotationEffect(.degrees(shakeDegrees)) 134 | .onAppear { 135 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 136 | startShaking() 137 | } 138 | } 139 | } 140 | 141 | VStack (spacing: 10) { 142 | Text("Unlock Premium Access") 143 | .font(.system(size: 30, weight: .semibold)) 144 | .multilineTextAlignment(.center) 145 | VStack (alignment: .leading) { 146 | PurchaseFeatureView(title: "Add first feature here", icon: "star", color: color) 147 | PurchaseFeatureView(title: "Then add second feature", icon: "star", color: color) 148 | PurchaseFeatureView(title: "Put final feature here", icon: "star", color: color) 149 | PurchaseFeatureView(title: "Remove annoying paywalls", icon: "lock.square.stack", color: color) 150 | } 151 | .font(.system(size: 19)) 152 | .padding(.top) 153 | } 154 | 155 | Spacer() 156 | 157 | VStack (spacing: 20) { 158 | VStack (spacing: 10) { 159 | 160 | let productDetails = purchaseModel.isFetchingProducts ? placeholderProductDetails : purchaseModel.productDetails 161 | 162 | ForEach(productDetails) { productDetails in 163 | 164 | Button(action: { 165 | withAnimation { 166 | selectedProductId = productDetails.productId 167 | } 168 | self.freeTrial = productDetails.hasTrial 169 | }) { 170 | VStack { 171 | HStack { 172 | VStack(alignment: .leading) { 173 | Text(productDetails.durationPlanName) 174 | .font(.headline.bold()) 175 | if productDetails.hasTrial { 176 | Text("then "+productDetails.price+" per "+productDetails.duration) 177 | .opacity(0.8) 178 | } 179 | else { 180 | HStack (spacing: 0) { 181 | if let calculateFullPrice = calculateFullPrice, //round down 182 | let calculateFullPriceLocalCurrency = toLocalCurrencyString(calculateFullPrice), 183 | calculateFullPrice > 0 184 | { 185 | //shows the full price based on weekly calculaation 186 | Text("\(calculateFullPriceLocalCurrency) ") 187 | .strikethrough() 188 | .opacity(0.4) 189 | 190 | } 191 | Text(" " + productDetails.price + " per " + productDetails.duration) 192 | } 193 | .opacity(0.8) 194 | } 195 | } 196 | Spacer() 197 | if productDetails.hasTrial { 198 | //removed: Some apps were being rejected with this caption present: 199 | /*Text("FREE") 200 | .font(.title2.bold())*/ 201 | } 202 | else { 203 | VStack { 204 | Text("SAVE \(calculatePercentageSaved)%") 205 | .font(.caption.bold()) 206 | .foregroundColor(.white) 207 | .padding(8) 208 | } 209 | .background(Color.red) 210 | .cornerRadius(6) 211 | } 212 | 213 | ZStack { 214 | Image(systemName: (selectedProductId == productDetails.productId) ? "circle.fill" : "circle") 215 | .foregroundColor((selectedProductId == productDetails.productId) ? color : Color.primary.opacity(0.15)) 216 | 217 | if selectedProductId == productDetails.productId { 218 | Image(systemName: "checkmark") 219 | .foregroundColor(Color.white) 220 | .scaleEffect(0.7) 221 | } 222 | } 223 | .font(.title3.bold()) 224 | 225 | } 226 | .padding(.horizontal) 227 | .padding(.vertical, 10) 228 | } 229 | //.background(Color(.systemGray4)) 230 | .cornerRadius(6) 231 | .overlay( 232 | ZStack { 233 | RoundedRectangle(cornerRadius: 6) 234 | .stroke((selectedProductId == productDetails.productId) ? color : Color.primary.opacity(0.15), lineWidth: 1) // Border color and width 235 | RoundedRectangle(cornerRadius: 6) 236 | .foregroundColor((selectedProductId == productDetails.productId) ? color.opacity(0.05) : Color.primary.opacity(0.001)) 237 | } 238 | ) 239 | } 240 | .accentColor(Color.primary) 241 | 242 | } 243 | 244 | HStack { 245 | Toggle(isOn: $freeTrial) { 246 | Text("Free Trial Enabled") 247 | .font(.headline.bold()) 248 | } 249 | .padding(.horizontal) 250 | .padding(.vertical, 10) 251 | .onChange(of: freeTrial) { freeTrial in 252 | if !freeTrial, let firstProductId = self.purchaseModel.productIds.first { 253 | withAnimation { 254 | self.selectedProductId = String(firstProductId) 255 | } 256 | } 257 | else if freeTrial, let lastProductId = self.purchaseModel.productIds.last { 258 | withAnimation { 259 | self.selectedProductId = lastProductId 260 | } 261 | } 262 | } 263 | } 264 | .background(Color.primary.opacity(0.05)) 265 | .cornerRadius(6) 266 | 267 | } 268 | .opacity(purchaseModel.isFetchingProducts ? 0 : 1) 269 | 270 | VStack (spacing: 25) { 271 | 272 | ZStack (alignment: .center) { 273 | 274 | //if purchasedModel.isPurchasing { 275 | ProgressView() 276 | .opacity(purchaseModel.isPurchasing ? 1 : 0) 277 | 278 | Button(action: { 279 | //productManager.purchaseProduct() 280 | if !purchaseModel.isPurchasing { 281 | purchaseModel.purchaseSubscription(productId: self.selectedProductId) 282 | } 283 | }) { 284 | HStack { 285 | Spacer() 286 | HStack { 287 | Text(callToActionText) 288 | Image(systemName: "chevron.right") 289 | } 290 | Spacer() 291 | } 292 | .padding() 293 | .foregroundColor(.white) 294 | .font(.title3.bold()) 295 | } 296 | .background(color) 297 | .cornerRadius(6) 298 | .opacity(purchaseModel.isPurchasing ? 0 : 1) 299 | .padding(.top) 300 | .padding(.bottom, 4) 301 | 302 | 303 | } 304 | 305 | } 306 | .opacity(purchaseModel.isFetchingProducts ? 0 : 1) 307 | } 308 | .id("view-\(purchaseModel.isFetchingProducts)") 309 | .background { 310 | if purchaseModel.isFetchingProducts { 311 | ProgressView() 312 | } 313 | } 314 | 315 | VStack (spacing: 5) { 316 | 317 | /*HStack (spacing: 4) { 318 | Image(systemName: "figure.2.and.child.holdinghands") 319 | .foregroundColor(Color.red) 320 | Text("Family Sharing enabled") 321 | .foregroundColor(.white) 322 | } 323 | .font(.footnote)*/ 324 | 325 | HStack (spacing: 10) { 326 | 327 | Button("Restore") { 328 | purchaseModel.restorePurchases() 329 | DispatchQueue.main.asyncAfter(deadline: .now() + 7) { 330 | if !purchaseModel.isSubscribed { 331 | showNoneRestoredAlert = true 332 | } 333 | } 334 | } 335 | .alert(isPresented: $showNoneRestoredAlert) { 336 | Alert(title: Text("Restore Purchases"), message: Text("No purchases restored"), dismissButton: .default(Text("OK"))) 337 | } 338 | .overlay( 339 | Rectangle() 340 | .frame(height: 1) 341 | .foregroundColor(.gray), alignment: .bottom 342 | ) 343 | .font(.footnote) 344 | 345 | 346 | Button("Terms of Use & Privacy Policy") { 347 | showTermsActionSheet = true 348 | } 349 | .overlay( 350 | Rectangle() 351 | .frame(height: 1) 352 | .foregroundColor(.gray), alignment: .bottom 353 | ) 354 | .actionSheet(isPresented: $showTermsActionSheet) { 355 | ActionSheet(title: Text("View Terms & Conditions"), message: nil, 356 | buttons: [ 357 | .default(Text("Terms of Use"), action: { 358 | if let url = URL(string: "https://example.com") { 359 | UIApplication.shared.open(url) 360 | } 361 | }), 362 | .default(Text("Privacy Policy"), action: { 363 | if let url = URL(string: "https://example.com") { 364 | UIApplication.shared.open(url) 365 | } 366 | }), 367 | .cancel() 368 | ]) 369 | } 370 | .font(.footnote) 371 | 372 | 373 | } 374 | //.font(.headline) 375 | .foregroundColor(.gray) 376 | .font(.system(size: 15)) 377 | 378 | 379 | 380 | 381 | } 382 | 383 | 384 | } 385 | } 386 | .padding(.horizontal) 387 | .onAppear { 388 | selectedProductId = purchaseModel.productIds.last ?? "" 389 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 390 | withAnimation(.easeIn(duration: allowCloseAfter)) { 391 | self.progress = 1.0 392 | } 393 | DispatchQueue.main.asyncAfter(deadline: .now() + allowCloseAfter) { 394 | withAnimation { 395 | showCloseButton = true 396 | } 397 | } 398 | } 399 | } 400 | .onChange(of: purchaseModel.isSubscribed) { isSubscribed in 401 | if(isSubscribed) { 402 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 403 | isPresented = false 404 | } 405 | } 406 | } 407 | .onAppear { 408 | if(purchaseModel.isSubscribed) { 409 | isPresented = false 410 | } 411 | } 412 | 413 | 414 | } 415 | 416 | private func startShaking() { 417 | let totalDuration = 0.7 // Total duration of the shake animation 418 | let numberOfShakes = 3 // Total number of shakes 419 | let initialAngle: Double = 10 // Initial rotation angle 420 | 421 | withAnimation(.easeInOut(duration: totalDuration / 2)) { 422 | self.shakeZoom = 0.95 423 | DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration / 2) { 424 | withAnimation(.easeInOut(duration: totalDuration / 2)) { 425 | self.shakeZoom = 0.9 426 | } 427 | } 428 | } 429 | 430 | for i in 0.. String? { 475 | let formatter = NumberFormatter() 476 | formatter.numberStyle = .currency 477 | //formatter.locale = locale 478 | return formatter.string(from: NSNumber(value: value)) 479 | } 480 | 481 | } 482 | 483 | #Preview { 484 | PurchaseView(isPresented: .constant(true)) 485 | } 486 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paywall-PurchaseView-SwiftUI 2 | 3 | PurchaseView is a SwiftUI view for implementing a premium access purchase screen in your iOS app. This screen offers a user-friendly way to present subscription options, handle purchase transactions, and restore purchases. 4 | 5 | ## Features 6 | 7 | * Customizable UI: Easily modify the view to fit your app’s design. 8 | * Animated Hero Image: Includes a shaking effect for the hero image. 9 | * Dynamic Content: Displays product details dynamically based on the available purchase options. 10 | * Subscription Management: Handles purchasing and restoring subscriptions. 11 | * Trial Support: Displays trial information and adjusts the call to action accordingly. 12 | * Cooldown Timer: Prevents immediate closing of the purchase view, encouraging interaction. 13 | 14 | ## How to Use 15 | 16 | The best way to present `PurchaseView` is using a full-screen cover. Here’s an example: 17 | 18 | ``` 19 | .fullScreenCover(isPresented: $showPurchaseSheet) { 20 | PurchaseView(isPresented: $showPurchaseSheet) 21 | } 22 | ``` 23 | 24 | # Customization 25 | 26 | ## Customizing Features 27 | 28 | The features displayed in the PurchaseView can be customized by modifying the PurchaseFeatureView instances within the body of PurchaseView. Each feature is defined by a title, an icon, and a color. 29 | 30 | ``` 31 | VStack (alignment: .leading) { 32 | PurchaseFeatureView(title: "Add first feature here", icon: "star", color: color) 33 | PurchaseFeatureView(title: "Then add second feature", icon: "star", color: color) 34 | PurchaseFeatureView(title: "Put final feature here", icon: "star", color: color) 35 | PurchaseFeatureView(title: "Remove annoying paywalls", icon: "lock.square.stack", color: color) 36 | } 37 | .font(.system(size: 19)) 38 | .padding(.top) 39 | ``` 40 | 41 | To customize, simply change the title, icon, and color parameters: 42 | 43 | ``` 44 | PurchaseFeatureView(title: "New Feature", icon: "new.icon", color: .green) 45 | ``` 46 | 47 | ## Customizing the Title 48 | 49 | The main title displayed in the PurchaseView can be customized by modifying the Text view in the body: 50 | 51 | ``` 52 | Text("Unlock Premium Access") 53 | .font(.system(size: 30, weight: .semibold)) 54 | .multilineTextAlignment(.center) 55 | ``` 56 | 57 | To customize, change the string “Unlock Premium Access” to your desired title: 58 | 59 | ``` 60 | Text("Your Custom Title") 61 | ``` 62 | 63 | # Customizing Purchase Functionality 64 | 65 | ## PurchaseModel.swift 66 | 67 | The PurchaseModel class manages the purchase data and state. You can customize the purchase functionality by modifying methods in this class. 68 | 69 | ## Initializing Products 70 | 71 | You can set up your product IDs and details in the init() method: 72 | 73 | ``` 74 | init() { 75 | self.productIds = ["your_product_id_1", "your_product_id_2"] 76 | self.productDetails = [ 77 | PurchaseProductDetails(price: "$9.99", productId: "your_product_id_1", duration: "month", durationPlanName: "Monthly Plan", hasTrial: true), 78 | PurchaseProductDetails(price: "$99.99", productId: "your_product_id_2", duration: "year", durationPlanName: "Yearly Plan", hasTrial: false) 79 | ] 80 | } 81 | ``` 82 | 83 | Replace "your_product_id_1", "your_product_id_2", and the corresponding details with your actual product information. 84 | 85 | ## Handling Purchases 86 | 87 | To customize the purchase process, modify the purchaseSubscription method: 88 | 89 | ``` 90 | func purchaseSubscription(productId: String) { 91 | // Trigger purchase process 92 | // Implement your purchase logic here 93 | } 94 | ``` 95 | 96 | Insert your in-app purchase logic within this method to handle the subscription process. 97 | 98 | ## Restoring Purchases 99 | 100 | To customize the restore purchases functionality, modify the restorePurchases method: 101 | 102 | ``` 103 | func restorePurchases() { 104 | // Trigger restore purchases 105 | // Implement your restore logic here 106 | } 107 | ``` 108 | 109 | Insert your restore purchases logic within this method to handle restoring previous purchases. 110 | 111 | # About 112 | 113 | Created by Adam Lyttle 114 | 115 | Twitter: [https://x.com/adamlyttleapps](adamlyttleapps) 116 | 117 | ## Contributions 118 | 119 | Contributions are welcome! Feel free to open an issue or submit a pull request on the [GitHub repository](https://github.com/adamlyttleapps/Paywall-PurchaseView-SwiftUI). 120 | 121 | ## MIT License 122 | 123 | This project is licensed under the MIT License. See the LICENSE file for more details. 124 | 125 | This README provides a clear overview of the project, detailed usage instructions, and additional sections like examples, contributions, and licensing, making it more comprehensive and user-friendly. 126 | --------------------------------------------------------------------------------