
Привет! На связи Денис из Apphud – сервиса по аналитике возобновляемых подписок для iOS-приложений.
Как вы знаете, на WWDC 2019 Apple анонсировали свой новый декларативный фреймворк SwiftUI. В этой статье я попробую рассказать как с помощью SwiftUI сделать экраны оплаты и реализовать функционал авто-возобновляемых подписок.
Если вы еще не знакомы со SwiftUI, то можете прочитать небольшую вводную статью. А если вы хотите побольше узнать о подписках и как их правильно реализовать, то прочитайте эту статью.
Для работы вам понадобится Xcode 11. Создайте новый проект и убедитесь, что стоит галочка рядом с “Use SwiftUI”.
SwiftUI – фреймворк для написания интерфейса, и поэтому мы не можем с помощью него создать менеджер покупок. Но мы и не будем писать свой менеджер, а используем готовое решение, которое дополним своим кодом. Вы можете использовать, например, SwiftyStoreKit. В нашем примере мы будем использовать класс из нашей предыдущей статьи.
Инициализация продуктов будет происходить на главном экране, там же будет отображаться дата истечения наших подписок и кнопка перехода на экран покупки.
ProductsStore.shared.initializeProducts() if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: ContentView(productsStore: ProductsStore.shared)) self.window = window window.makeKeyAndVisible() }
Рассмотрим класс SceneDelegate. В нем мы создаем singleton-класс ProductsStore, в котором происходит инициализация продуктов. После этого создаем наш рутовый ContentView и указываем singleton в качестве входного параметра.
Рассмотрим класс ProductsStore:
class ProductsStore : ObservableObject { static let shared = ProductsStore() @Published var products: [SKProduct] = [] @Published var anyString = "123" // little trick to force reload ContentView from PurchaseView by just changing any Published value func handleUpdateStore(){ anyString = UUID().uuidString } func initializeProducts(){ IAPManager.shared.startWith(arrayOfIds: [subscription_1, subscription_2], sharedSecret: shared_secret) { products in self.products = products } } }
Это небольшой класс, эдакая “надстройка” над IAPManager, служит, чтобы обновлять ContentView при обновлении списка продуктов. Класс ProductsStore поддерживает протокол ObservableObject.
Что такое ObservableObject и @Published?
ObservableObject – это особый протокол для наблюдения объектов и отслеживания изменений его свойств. Свойства должны быть помечены атрибутом @Published. В примере уведомление отправляется при изменении массива products, но вы можете добавить это уведомление для любых методов и свойств объекта.
Сама загрузка продуктов может осуществляться любым способом, но при завершении данного запроса вы должны присвоить массив продуктов переменной products. Как слушать изменения? Делается это с помощью ключевого параметра @ObservedObject:
@ObservedObject var productsStore : ProductsStore
Проще говоря, это нечто похожее на Notification Center. А чтобы ваши View принимали эти уведомления, вы должны иметь переменную данного объекта с атрибутом @ObservedObject.
Вернемся к логике класса ProductsStore. Его основное назначение – это загружать и хранить список продуктов. Но массив продуктов уже хранится в IAPManager, происходит дублирование. Это нехорошо, но, во-первых, в данной статье я хотел показать вам, как реализован биндинг объектов, а, во-вторых, не всегда получается изменять готовый класс менеджера покупок. Например, если вы используете сторонние библиотеки, то не сможете добавить протокол ObservableObject и отправлять уведомления.
Стоит отметить, что кроме атрибута @ObservedObject есть еще и атрибут @State, помогающий отслеживать изменение простых переменных (например, String или Int) и более глобальный @EnvironmentObject, который может обновлять сразу все View в приложении без необходимости передавать переменную между объектами.
Перейдем к стартовому экрану ContentView:
struct ContentView : View { @ObservedObject var productsStore : ProductsStore @State var show_modal = false var body: some View { VStack() { ForEach (productsStore.products, id: \.self) { prod in Text(prod.subscriptionStatus()).lineLimit(nil).frame(height: 80) } Button(action: { print("Button Pushed") self.show_modal = true }) { Text("Present") }.sheet(isPresented: self.$show_modal) { PurchaseView() } } } }
Давайте разберемся с кодом. С помощью ForEach мы создаем текстовые View, количество которых равно количеству продуктов. Так как мы забиндили переменную productsStore, то View будет обновляться всякий раз, когда изменится массив продуктов в классе ProductsStore.
Метод subscriptionStatus входит в расширение класса SKProduct и возвращает нужный текст в зависимости от даты истечения подписки:
func subscriptionStatus() -> String { if let expDate = IAPManager.shared.expirationDateFor(productIdentifier) { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .medium let dateString = formatter.string(from: expDate) if Date() > expDate { return "Subscription expired: \(localizedTitle) at: \(dateString)" } else { return "Subscription active: \(localizedTitle) until:\(dateString)" } } else { return "Subscription not purchased: \(localizedTitle)" } }

Так выглядит наш стартовый экран
Теперь перейдем к экрану подписки. Так как по правилам Apple экран оплаты должен иметь длинный текст условий покупки, то разумно будет использовать ScrollView.
var body: some View { ScrollView (showsIndicators: false) { VStack { Text("Get Premium Membership").font(.title) Text("Choose one of the packages above").font(.subheadline) self.purchaseButtons() self.aboutText() self.helperButtons() self.termsText().frame(width: UIScreen.main.bounds.size.width) self.dismissButton() }.frame(width : UIScreen.main.bounds.size.width) }.disabled(self.isDisabled) }
В это примере мы создали две текстовые вью с разным шрифтом. Далее все остальные вью выделены в собственные методы. Это сделано по трем причинам:
Код становится более читабельным и понятным для изучения.
На момент написания статьи Xcode 11 Beta часто зависает и не может скомпилировать код, а вынесение частей кода по функциям помогает компилятору.
Показать, что вью можно выносить в отдельные функции, облегчая
body.
Рассмотрим метод purchaseButtons():
func purchaseButtons() -> some View { // remake to ScrollView if has more than 2 products because they won't fit on screen. HStack { Spacer() ForEach(ProductsStore.shared.products, id: \.self) { prod in PurchaseButton(block: { self.purchaseProduct(skproduct: prod) }, product: prod).disabled(IAPManager.shared.isActive(product: prod)) } Spacer() } }
Здесь мы создаем горизонтальный стек и в цикле ForEach создаем кастомный PurchaseButton, в который передаем продукт и callback-блок.
Класс PurchaseButton:
struct PurchaseButton : View { var block : SuccessBlock! var product : SKProduct! var body: some View { Button(action: { self.block() }) { Text(product.localizedPrice()).lineLimit(nil).multilineTextAlignment(.center).font(.subheadline) }.padding().frame(height: 50).scaledToFill().border(Color.blue, width: 1) } }
Это обычная кнопка, которая хранит и вызывает блок переданный при создании объекта. К нему применяется обводка с закруглением. В качестве текста отображаем цену продукта и длительность периода подписки в методе localizedPrice().
Покупка подписки реализована так:
func purchaseProduct(skproduct : SKProduct){ print("did tap purchase product: \(skproduct.productIdentifier)") isDisabled = true IAPManager.shared.purchaseProduct(product: skproduct, success: { self.isDisabled = false ProductsStore.shared.handleUpdateStore() self.dismiss() }) { (error) in self.isDisabled = false ProductsStore.shared.handleUpdateStore() } }
Как видите, после завершения покупки вызывается метод handleUpdateStore, с помощью которого отправляется уведомление на обновление ContentView. Это сделано для того, чтобы в ContentView обновился статус подписок при скрытии модального экрана. Метод dismiss скрывает модальное окно.
Так как SwiftUI – декларативный фреймворк, то скрытие модального окна реализуется не так, как обычно. Мы должны вызвать метод dismiss() у обертки переменной presentationMode, объявляя ее с атрибутом @Environment:
struct PurchaseView : View { @State private var isDisabled : Bool = false @Environment(\.presentationMode) var presentationMode private func dismiss() { self.presentationMode.wrappedValue.dismiss() } func dismissButton() -> some View { Button(action: { self.dismiss() }) { Text("Not now").font(.footnote) }.padding() } ...
Переменная presentationMode является частью Environment Values – специальных наборов глобальных методов и свойств. В SwiftUI почти все действия происходят при изменении значений переменных, сделать что-либо в runtime в прямом смысле слова нельзя — все забиндено заранее. А для того, чтобы что-то сделать в runtime, нужно использовать обертки.

Экран покупки подписок
Заключение
Надеюсь, данная статья будет вам полезна. Apple любит, когда разработчики используют ее новейшие технологии. Если вы выпустите приложение под iOS 13 с использованием SwiftUI, есть потенциальная вероятность быть зафичеринным Apple. Так что не бойтесь новых технологий – используйте их. Полный код проекта вы можете скачать здесь.
Хотите внедрить подписки в iOS-приложение за 10 минут? Интегрируйте Apphud и:
- оформляйте покупки с помощью лишь одного метода;
- автоматически отслеживайте состояние подписки каждого пользователя;
- легко интегрируйте Subscription Offers;
- отправляйте события о подписках в Amplitude, Mixpanel, Slack и Telegram с учетом локальной валюты пользователя;
- уменьшайте Churn rate в приложениях и возвращайте отписавшихся пользователей.
