Apphud corporate blog
Development for iOS
Development of mobile applications
Swift
28 June

SwiftUI и авто-возобновляемые подписки

Tutorial

image


Привет! На связи Денис из 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)
}

В это примере мы создали две текстовые вью с разным шрифтом. Далее все остальные вью выделены в собственные методы. Это сделано по трем причинам:


  1. Код становится более читабельным и понятным для изучения.


  2. На момент написания статьи Xcode 11 Beta часто зависает и не может скомпилировать код, а вынесение частей кода по функциям помогает компилятору.


  3. Показать, что вью можно выносить в отдельные функции, облегчая 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 в приложениях и возвращайте отписавшихся пользователей.


Что почитать?



+13
2.1k 35
Leave a comment
Top of the day