What’s new in Apphud: Refund Requests Win Back Solution, Web-to-App Match Quality, Integration Improvements, and SDK UpdatesLet’s see
Apphud
Why Apphud?PricingContact
Ren
Ren
July 04, 2019
3 min read

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

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

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

Перед тем, как начнем

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

Для работы вам понадобится Xcode. Создайте новый проект и убедитесь, что стоит галочка рядом с “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 в качестве входного параметра:

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   

        }

    }

}

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

Что такое Observable и @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. Так что не бойтесь новых технологий – используйте их. Полный код проекта вы можете скачать здесь.

Ren
Ren
Co-founder at Apphud
Ex iOS app and game developer. 11 years in the industry since iOS 3. More than 50 apps are in the background with 4 exits. Entrepreneur and traveler.

Related Posts