Hello everyone, my name is Vitaly, I am the founder of Adapty. We continue the series of articles devoted to in-app purchases in iOS applications. In the previous part, we covered the process of creating and configuring in-app purchases. In this article, we will analyze the creation of the simplest paywall (payment screen), as well as the initialization and processing of purchases that we configured at the first stage .
Create a subscription screen
Any app that uses in-app purchases has a paywall. There are requirements from Apple that define the minimum set of required elements and explanatory texts for such screens. At this stage, we will not carry out all of them as accurately as possible, but our version will be very close to the working version.
So, our screen will consist of the following functional elements:
- Title: explanatory / selling blocks.
- A set of buttons to initiate the purchase process. They will also show the main properties of the subscriptions: name and price in local currency (the currency of the store).
- Restore past purchases button. This element is required for all applications that use subscriptions or non-consumable purchases.
Interface Builder Storyboard. ViewController, UI (UIActivityIndicatorView) , .
ViewController. , .
import StoreKit
import UIKit
class ViewController: UIViewController {
// 1:
@IBOutlet private weak var purchaseButtonA: UIButton!
@IBOutlet private weak var purchaseButtonB: UIButton!
@IBOutlet private weak var activityIndicator: UIActivityIndicatorView!
override func viewDidLoad() {
super.viewDidLoad()
activityIndicator.hidesWhenStopped = true
// 2:
showSpinner()
Purchases.default.initialize { [weak self] result in
guard let self = self else { return }
self.hideSpinner()
switch result {
case let .success(products):
DispatchQueue.main.async {
self.updateInterface(products: products)
}
default:
break
}
}
}
// 3:
private func updateInterface(products: [SKProduct]) {
updateButton(purchaseButtonA, with: products[0])
updateButton(purchaseButtonB, with: products[1])
}
// 4:
@IBAction func purchaseAPressed(_ sender: UIButton) { }
@IBAction func purchaseBPressed(_ sender: UIButton) { }
@IBAction func restorePressed(_ sender: UIButton) { }
}
- - UI
- viewDidLoad . , , UI, . , — . -, .
- , , , .
- - .
:
extension ViewController {
// 1:
func updateButton(_ button: UIButton, with product: SKProduct) {
let title = "\(product.title ?? product.productIdentifier) for \(product.localizedPrice)"
button.setTitle(title, for: .normal)
}
func showSpinner() {
DispatchQueue.main.async {
self.activityIndicator.startAnimating()
self.activityIndicator.isHidden = false
}
}
func hideSpinner() {
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
}
}
}Spinner
, (1) SKProduct. , extension :
extension SKProduct {
var localizedPrice: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = priceLocale
return formatter.string(from: price)!
}
var title: String? {
switch productIdentifier {
case "barcode_month_subscription":
return "Monthly Subscription"
case "barcode_year_subscription":
return "Annual Subscription"
default:
return nil
}
}
}
Purchases
. Apple. Purchases , , SKProduct .
typealias RequestProductsResult = Result<[SKProduct], Error>
typealias PurchaseProductResult = Result<Bool, Error>
typealias RequestProductsCompletion = (RequestProductsResult) -> Void
typealias PurchaseProductCompletion = (PurchaseProductResult) -> Void
class Purchases: NSObject {
static let `default` = Purchases()
private let productIdentifiers = Set<String>(
arrayLiteral: "barcode_month_subscription", "barcode_year_subscription"
)
private var products: [String: SKProduct]?
private var productRequest: SKProductsRequest?
func initialize(completion: @escaping RequestProductsCompletion) {
requestProducts(completion: completion)
}
private var productsRequestCallbacks = [RequestProductsCompletion]()
private func requestProducts(completion: @escaping RequestProductsCompletion) {
guard productsRequestCallbacks.isEmpty else {
productsRequestCallbacks.append(completion)
return
}
productsRequestCallbacks.append(completion)
let productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest.delegate = self
productRequest.start()
self.productRequest = productRequest
}
}
Delegate:
extension Purchases: SKProductsRequestDelegate {
guard !response.products.isEmpty else {
print("Found 0 products")
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
return
}
var products = [String: SKProduct]()
for skProduct in response.products {
print("Found product: \(skProduct.productIdentifier)")
products[skProduct.productIdentifier] = skProduct
}
self.products = products
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Failed to load products with error:\n \(error)")
productsRequestCallbacks.forEach { $0(.failure(error)) }
productsRequestCallbacks.removeAll()
}
}
, , , enum PurchaseError, Error ( LocalizedError):
enum PurchasesError: Error {
case purchaseInProgress
case productNotFound
case unknown
}
purchaseProduct , restorePurchases — ( non-consumable ):
fileprivate var productPurchaseCallback: ((PurchaseProductResult) -> Void)?
func purchaseProduct(productId: String, completion: @escaping (PurchaseProductResult) -> Void) {
// 1:
guard productPurchaseCallback == nil else {
completion(.failure(PurchasesError.purchaseInProgress))
return
}
// 2:
guard let product = products?[productId] else {
completion(.failure(PurchasesError.productNotFound))
return
}
productPurchaseCallback = completion
// 3:
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
public func restorePurchases(completion: @escaping (PurchaseProductResult) -> Void) {
guard productPurchaseCallback == nil else {
completion(.failure(PurchasesError.purchaseInProgress))
return
}
productPurchaseCallback = completion
// 4:
SKPaymentQueue.default().restoreCompletedTransactions()
}
- , ( , , , , )
- peoductId,
- SKPaymentQueue
- , SKPaymentQueue
, , SKPaymentTransactionObserver:
extension Purchases: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
// 1:
for transaction in transactions {
switch transaction.transactionState {
// 2:
case .purchased, .restored:
if finishTransaction(transaction) {
SKPaymentQueue.default().finishTransaction(transaction)
productPurchaseCallback?(.success(true))
} else {
productPurchaseCallback?(.failure(PurchasesError.unknown))
}
// 3:
case .failed:
productPurchaseCallback?(.failure(transaction.error ?? PurchasesError.unknown))
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
productPurchaseCallback = nil
}
}
extension Purchases {
// 4:
func finishTransaction(_ transaction: SKPaymentTransaction) -> Bool {
let productId = transaction.payment.productIdentifier
print("Product \(productId) successfully purchased")
return true
}
}
- ,
- , purchased restored, , , /, , finishTransaction. : consumable , , , .
- , .
- , 2: (, , UI , )
. purchasing (, ) deferred — (, ). UI.
ViewController, , , .
@IBAction func purchaseAPressed(_ sender: UIButton) {
showSpinner()
Purchases.default.purchaseProduct(productId: "barcode_month_subscription") { [weak self] _ in
self?.hideSpinner()
// Handle result
}
}
@IBAction func purchaseBPressed(_ sender: Any) {
showSpinner()
Purchases.default.purchaseProduct(productId: "barcode_year_subscription") { [weak self] _ in
self?.hideSpinner()
// Handle result
}
}
@IBAction func restorePressed(_ sender: UIButton) {
showSpinner()
Purchases.default.restorePurchases { [weak self] _ in
self?.hideSpinner()
// Handle result
}
}
, . . x401om .