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

image

Привет! На связи Денис из Apphud — сервиса по аналитике возобновляемых подписок для iOS-приложений.

Как вы знаете, на WWDC 2019 Apple анонсировали свой новый декларативный фреймворк SwiftUI. В этой статье я попробую рассказать как с помощью SwiftUI сделать экраны оплаты и реализовать функционал авто-возобновляемых подписок.


Если вы еще не знакомы со SwiftUI, то можете прочитать небольшую вводную статью. А если вы хотите побольше узнать о подписках и как их правильно реализовать, то прочитайте эту статью.

Для работы вам понадобится Xcode 11. Создайте новый проект и убедитесь, что стоит галочка рядом с «Use SwiftUI».

SwiftUI — фреймворк для написания интерфейса, и поэтому мы не можем с помощью него создать менеджер покупок. Но мы и не будем писать свой менеджер, а используем готовое решение, которое дополним своим кодом. Вы можете использовать, например, SwiftyStoreKit. В нашем примере мы будем использовать класс из нашей предыдущей статьи.

Инициализация продуктов будет происходить на главном экране, там же будет отображаться дата истечения наших подписок и кнопка перехода на экран покупки.

ProductsStore.shared.initializeProducts()
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: ContentView(productsStore: ProductsStore.shared))
self.window = window
window.makeKeyAndVisible()

Рассмотрим класс SceneDelegate. В нем мы создаем singleton-класс ProductsStore, в котором происходит инициализация продуктов. После этого создаем наш рутовый ContentView и указываем singleton в качестве входного параметра:

class ProductsStore : BindableObject {
    static let shared = ProductsStore()

    var products: [SKProduct] = [] {
        didSet {
            handleUpdateStore()
        }
    }

    func initializeProducts(){
        IAPManager.shared.startWith(arrayOfIds: [subscription_1, subscription_2], sharedSecret: shared_secret) { products in
            self.products = products
        }
    }
}

Рассмотрим класс ProductsStore. Это небольшой класс, эдакая «надстройка» над IAPManager, служит, чтобы обновлять ContentView при обновлении списка продуктов. Класс ProductsStore поддерживает протокол BindableObject.

Что такое BindableObject и @ObjectBinding?

BindableObject — это особый протокол для биндинга (binding) объектов и отслеживания их изменений. Единственным условием протокола является наличие переменной didChange, непосредственно отправляющей уведомления. В примере уведомление отправляется при изменении массива Products, но вы можете добавить это уведомление для любых методов и свойств объекта.

Сама загрузка продуктов может осуществляться любым способом, но при завершении данного запроса вы должны присвоить массив продуктов переменной products. Функция didChange.send() отправит уведомление.

var didChange = PassthroughSubject()
var products: [SKProduct] = [] {
    didSet {
        didChange.send()
    }
}

Проще говоря, это нечто похожее на Notification Center. А чтобы ваши View принимали эти уведомления, вы должны иметь переменную данного объекта с атрибутом @ObjectBinding.

Вернемся к логике класса ProductsStore. Его основное назначение — это загружать и хранить список продуктов. Но массив продуктов уже хранится в IAPManager, происходит дублирование. Это нехорошо, но, во-первых, в данной статье я хотел показать вам, как реализован биндинг объектов, а, во-вторых, не всегда получается изменять готовый класс менеджера покупок. Например, если вы используете сторонние библиотеки, то не сможете добавить протокол BindableObject и отправлять уведомления.

Стоит отметить, что кроме атрибута @ObjectBinding есть еще и атрибут @State, помогающий отслеживать изменение простых переменных (например, String или Int) и более глобальный @EnvironmentObject, который может обновлять сразу все View в приложении без необходимости передавать переменную между объектами.

Перейдем к стартовому экрану ContentView:

struct ContentView : View {
    @ObjectBinding var productsStore : ProductsStore

    var body: some View {
        VStack() {
            ForEach (productsStore.products.identified(by: \.self)) { prod in
                Text(prod.subscriptionStatus()).lineLimit(nil).frame(height: 80)
            }
            PresentationButton(destination: PurchaseView(), label: {
                Text("Present")
            })
        }
    }
}

Давайте разберемся с кодом. С помощью 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 (alwaysBounceVertical: true, showsVerticalIndicator: 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.identified(by: \.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).border(Color.blue, width: 1, cornerRadius: 10).scaledToFill()
    }
}

Это обычная кнопка, которая хранит и вызывает блок переданный при создании объекта. К нему применяется обводка с закруглением. В качестве текста отображаем цену продукта и длительность периода подписки в методе 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 — декларативный фреймворк, то скрытие модального окна реализуется не так, как обычно. Мы лишь изменяем переменную isPresented, объявляя ее с атрибутом @Environment:

struct PurchaseView : View {
    @State private var isDisabled : Bool = false
    @Environment(\.isPresented) var isPresented: Binding?

    private func dismiss() {
        self.isPresented?.value = false
    }
...

Переменная isPresented является частью Environment Values — специальных наборов глобальных методов и свойств. Вас может удивить, как изменение переменной может скрывать модальное окно. Однако в SwiftUI почти все действия происходят при изменении значений переменных, сделать что-либо в runtime в прямом смысле слова нельзя — все забиндено заранее.


Экран покупки подписок
Экран покупки подписок


Заключение

Надеюсь, данная статья будет вам полезна. Apple любит, когда разработчики используют ее новейшие технологии. Если вы выпустите приложение под iOS 13 с использованием SwiftUI, есть потенциальная вероятность быть зафичеринным Apple. Так что не бойтесь новых технологий — используйте их. Полный код проекта вы можете скачать здесь.


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


© Habrahabr.ru