Files
purchases-ios/Examples/LegacySwiftExample/SwiftExample/SwiftPaywall.swift
T
aboedo e4cf7261ec Update imports (#815)
* import Purchases -> import RevenueCat

* updated Pod name to Purchases

* updated more Pod names
2021-09-03 14:17:37 -03:00

1033 lines
43 KiB
Swift

//
// SwiftPaywall.swift
// SwiftExample
//
// Created by Ryan Kotzebue on 9/13/19.
// Copyright © 2019 RevenueCat. All rights reserved.
//
import UIKit
import RevenueCat
enum PayWallEdgeStyle : String {
case square
case soft
case round
}
@objc protocol SwiftPaywallDelegate {
func purchaseCompleted(paywall: SwiftPaywall, transaction: SKPaymentTransaction, purchaserInfo: Purchases.PurchaserInfo)
@objc optional func purchaseFailed(paywall: SwiftPaywall, purchaserInfo: Purchases.PurchaserInfo?, error: Error, userCancelled: Bool)
@objc optional func purchaseRestored(paywall: SwiftPaywall, purchaserInfo: Purchases.PurchaserInfo?, error: Error?)
}
class SwiftPaywall: UIViewController {
var delegate : SwiftPaywallDelegate?
// Properties to initialize with
private var textColor : UIColor
private var productSelectedColor : UIColor
private var productDeselectedColor : UIColor
private var showDiscountPercentage : Bool
private var edgeStyle : PayWallEdgeStyle
private var offeringId : String?
private var termsOfServiceURL : URL?
private var privacyPolicyURL : URL?
private var allowRestore : Bool
// Views to optionally customize
public var headerView : UIView!
public var titleLabel : UILabel!
public var subtitleLabel : UILabel!
public var buyButton : UIButton!
public var restoreButton : UIButton!
public var freeTrialLabel : UILabel!
public var termsAndConditionsLabel : UILabel!
// Internal variables
private var scrollView : UIScrollView!
private var offering : Purchases.Offering?
private var offeringCollectionView : UICollectionView!
private let maxItemsPerRow : CGFloat = 3
private let aspectRatio : CGFloat = 1.3
private let sectionInsets = UIEdgeInsets(top: 12.0,
left: 12.0,
bottom: 12.0,
right: 12.0)
// This determines the cell size
private var widthPerPackage : CGFloat {
let paddingSpace = sectionInsets.left * (maxItemsPerRow + 1)
let availableWidth = view.frame.width - paddingSpace
let maxWidth:CGFloat = 300.0
return min(availableWidth / maxItemsPerRow, maxWidth)
}
private var offeringLoadingIndicator : UIActivityIndicatorView!
private var buyButtonLoadingIndicator : UIActivityIndicatorView!
private var closeButton : CloseButton!
private var defaultBuyButtonText : String?
private var defaultRestoreButtonText : String?
private var didChangePackage = false
init(termsOfServiceUrlString: String, // Required terms of service url string
privacyPolicyUrlString: String, // Required privacy policy url string
allowRestore: Bool = true, // Whether your app allows restoring purchases, default is true for most apps
offeringId: String? = nil, // Offering ID, defaults to the current offering in RevenueCat
edgeStyle: PayWallEdgeStyle = .round, // Corner radius style, defaults to round
showDiscountPercentage: Bool = true, // Whether or not to show the discount badge on the products, default to true
backgroundColor: UIColor = UIColor(red: 0.937, green: 0.286, blue: 0.314, alpha: 1.00), // Background color, defults to RevenueCat red
textColor: UIColor = UIColor.white, // Text color, defaults to white
productSelectedColor: UIColor = UIColor.white, // Selected product cell color, defaults to white
productDeselectedColor: UIColor = UIColor.black) { // Deselected product cell color, defaults to black
self.termsOfServiceURL = URL(string: termsOfServiceUrlString)
self.privacyPolicyURL = URL(string: privacyPolicyUrlString)
self.allowRestore = allowRestore
self.offeringId = offeringId
self.edgeStyle = edgeStyle
self.showDiscountPercentage = showDiscountPercentage
self.textColor = textColor
self.productSelectedColor = productSelectedColor
self.productDeselectedColor = productDeselectedColor
super.init(nibName: nil, bundle: nil)
view.backgroundColor = backgroundColor
buildSubviews()
loadOfferings()
}
override var supportedInterfaceOrientations : UIInterfaceOrientationMask {
return .portrait
}
override var shouldAutorotate: Bool {
return false
}
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
return .portrait
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func loadOfferings() {
offeringLoadingIndicator.startAnimating()
buyButton.isEnabled = false
Purchases.shared.offerings { (offerings, error) in
if error != nil {
self.showAlert(title: "Error", message: "Unable to fetch offerings.") { (action) in
self.close()
}
}
if let offeringId = self.offeringId {
self.offering = offerings?.offering(identifier: offeringId)
} else {
self.offering = offerings?.current
}
if self.offering == nil {
self.showAlert(title: "Error", message: "No offerings found.") { (action) in
self.close()
}
}
self.offeringLoadingIndicator.stopAnimating()
self.offeringCollectionView.reloadData()
self.buyButton.isEnabled = true
}
}
@objc private func purchaseSelectedPackage() {
guard let indexPath = offeringCollectionView.indexPathsForSelectedItems?.first else {
print("No package selected")
return
}
guard let package = offering?.availablePackages[indexPath.row] else {
print("No available package")
return
}
setState(loading: true)
Purchases.shared.purchasePackage(package) { (trans, info, error, cancelled) in
self.setState(loading: false)
if let error = error {
if let purchaseFailedHandler = self.delegate?.purchaseFailed {
purchaseFailedHandler(self, info, error, cancelled)
} else {
if !cancelled {
self.showAlert(title: "Error", message: error.localizedDescription)
}
}
} else {
if let purchaseCompletedHandler = self.delegate?.purchaseCompleted {
purchaseCompletedHandler(self, trans!, info!)
} else {
self.close()
}
}
}
}
@objc private func restorePurchases() {
setState(loading: true)
Purchases.shared.restoreTransactions { (info, error) in
self.setState(loading: false)
if let purchaseRestoredHandler = self.delegate?.purchaseRestored {
purchaseRestoredHandler(self, info, error)
} else {
if let error = error {
self.showAlert(title: "Error", message: error.localizedDescription)
} else {
if let purchaserInfo = info {
if purchaserInfo.entitlements.active.isEmpty {
self.showAlert(title: "Restore Unsuccessful", message: "No prior purchases found for your account.")
} else {
self.close()
}
}
}
}
}
}
@objc private func close() {
dismiss(animated: true, completion: nil)
}
@objc private func tapToCs(tap: UITapGestureRecognizer) {
guard let text = termsAndConditionsLabel.text else {
return
}
guard let tocRange = termsAndConditionsLabel.text?.range(of: "Terms of Service") else {
return
}
guard let privacyRange = termsAndConditionsLabel.text?.range(of: "Privacy Policy") else {
return
}
if tap.didTapAttributedTextInLabel(label: termsAndConditionsLabel, inRange: NSRange(tocRange, in: text)) {
if let url = termsOfServiceURL {
let nav = UINavigationController(rootViewController: WebViewController(url: url, title: "Terms of Service", textColor: view.backgroundColor, barColor: textColor))
present(nav, animated: true, completion: nil)
}
} else if tap.didTapAttributedTextInLabel(label: termsAndConditionsLabel, inRange: NSRange(privacyRange, in: text)) {
if let url = privacyPolicyURL {
let nav = UINavigationController(rootViewController: WebViewController(url: url, title: "Privacy Policy", textColor: view.backgroundColor, barColor: textColor))
present(nav, animated: true, completion: nil)
}
}
}
// Only call this right before purchasing or restoring
private func setState(loading: Bool) {
if loading {
// This is to preserve the current button text after loading is shown
defaultBuyButtonText = buyButton.titleLabel?.text
defaultRestoreButtonText = restoreButton.titleLabel?.text
buyButton.isEnabled = false
buyButton.setTitle("", for: .normal)
buyButtonLoadingIndicator.startAnimating()
restoreButton.isEnabled = false
restoreButton.setTitle("LOADING...", for: .normal)
offeringCollectionView.isUserInteractionEnabled = false
closeButton.isHidden = true
} else {
buyButton.isEnabled = true
buyButton.setTitle(defaultBuyButtonText, for: .normal)
buyButtonLoadingIndicator.stopAnimating()
restoreButton.isEnabled = true
restoreButton.setTitle(defaultRestoreButtonText, for: .normal)
offeringCollectionView.isUserInteractionEnabled = true
closeButton.isHidden = false
}
}
private func shouldShowDiscount(package: Purchases.Package?) -> (Bool, Purchases.Package?) {
return (showDiscountPercentage == true
&& mostAffordablePackages.count > 1
&& mostAffordablePackages.first?.product.productIdentifier == package?.product.productIdentifier, mostAffordablePackages.last)
}
private var mostAffordablePackages : [Purchases.Package] {
guard let sorted = offering?.availablePackages
.filter({$0.packageType != .lifetime && $0.packageType != .custom})
.sorted(by: { $1.annualCost() > $0.annualCost() }) else {
return []
}
return sorted
}
private func showAlert(title: String?, message: String?, handler: ((UIAlertAction) -> Void)? = nil) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: handler))
self.present(alert, animated: true, completion: nil)
}
override func viewDidLayoutSubviews() {
// this computes the size of the scrollview to the bottom of the lowest subview + buffer
scrollView.contentSize = CGSize(width: view.frame.width, height: restoreButton.frame.maxY+30)
}
private func buildSubviews() {
// The scrollView
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.showsVerticalScrollIndicator = false
if #available(iOS 11.0, *) {
scrollView.contentInsetAdjustmentBehavior = .never
} else {
automaticallyAdjustsScrollViewInsets = false
}
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.rightAnchor.constraint(equalTo: view.rightAnchor),
scrollView.leftAnchor.constraint(equalTo: view.leftAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// The header is a UIView for customization
headerView = UIView()
headerView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(headerView)
NSLayoutConstraint.activate([
headerView.rightAnchor.constraint(equalTo: scrollView.rightAnchor),
headerView.leftAnchor.constraint(equalTo: scrollView.leftAnchor),
headerView.topAnchor.constraint(equalTo: scrollView.topAnchor),
headerView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.25),
headerView.widthAnchor.constraint(equalTo: view.widthAnchor)
])
// The title label
titleLabel = UILabel()
titleLabel.numberOfLines = 0
titleLabel.minimumScaleFactor = 0.01
titleLabel.textAlignment = .center
titleLabel.font = UIFont.boldSystemFont(ofSize: 26)
titleLabel.textColor = textColor
titleLabel.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -16.0),
titleLabel.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 16.0),
titleLabel.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
titleLabel.heightAnchor.constraint(equalToConstant: 44.0)
])
// The offering container
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
offeringCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
offeringCollectionView.delegate = self
offeringCollectionView.dataSource = self
offeringCollectionView.showsHorizontalScrollIndicator = false
offeringCollectionView.register(PackageCell.self, forCellWithReuseIdentifier: "cell")
offeringCollectionView.translatesAutoresizingMaskIntoConstraints = false
offeringCollectionView.backgroundColor = .clear
scrollView.addSubview(offeringCollectionView)
NSLayoutConstraint.activate([
offeringCollectionView.leftAnchor.constraint(equalTo: scrollView.leftAnchor),
offeringCollectionView.rightAnchor.constraint(equalTo: scrollView.rightAnchor),
offeringCollectionView.heightAnchor.constraint(equalToConstant: view.frame.width/maxItemsPerRow*aspectRatio + sectionInsets.top*2),
offeringCollectionView.centerYAnchor.constraint(equalTo: scrollView.topAnchor, constant: view.frame.height/2)
])
// The offerings loading indicator
offeringLoadingIndicator = UIActivityIndicatorView(style: .gray)
offeringLoadingIndicator.hidesWhenStopped = true
offeringLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
offeringCollectionView.addSubview(offeringLoadingIndicator)
NSLayoutConstraint.activate([
offeringLoadingIndicator.centerXAnchor.constraint(equalTo: offeringCollectionView.centerXAnchor),
offeringLoadingIndicator.centerYAnchor.constraint(equalTo: offeringCollectionView.centerYAnchor)
])
// The subtitle label
subtitleLabel = UILabel()
subtitleLabel.numberOfLines = 2
subtitleLabel.minimumScaleFactor = 0.01
subtitleLabel.textAlignment = .center
subtitleLabel.font = UIFont.boldSystemFont(ofSize: 14)
subtitleLabel.textColor = textColor
subtitleLabel.alpha = 0.90
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(subtitleLabel)
NSLayoutConstraint.activate([
subtitleLabel.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -16.0),
subtitleLabel.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 16.0),
subtitleLabel.bottomAnchor.constraint(equalTo: offeringCollectionView.topAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6.0)
])
// The buy button
buyButton = UIButton()
buyButton.addTarget(self, action: #selector(purchaseSelectedPackage), for: .touchUpInside)
buyButton.translatesAutoresizingMaskIntoConstraints = false
buyButton.backgroundColor = textColor
buyButton.setTitle("Continue", for: .normal)
buyButton.setTitleColor(view.backgroundColor, for: .normal)
buyButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 20)
switch edgeStyle {
case .round:
buyButton.layer.cornerRadius = 25
case .soft:
buyButton.layer.cornerRadius = 8
case .square:
break
}
scrollView.addSubview(buyButton)
NSLayoutConstraint.activate([
buyButton.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -20.0),
buyButton.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 20.0),
buyButton.bottomAnchor.constraint(equalTo: scrollView.topAnchor, constant: view.frame.height-64),
buyButton.heightAnchor.constraint(equalToConstant: 50.0)
])
// The buy button loading indicator
buyButtonLoadingIndicator = UIActivityIndicatorView(style: .gray)
buyButtonLoadingIndicator.hidesWhenStopped = true
buyButtonLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
buyButton.addSubview(buyButtonLoadingIndicator)
NSLayoutConstraint.activate([
buyButtonLoadingIndicator.centerXAnchor.constraint(equalTo: buyButton.centerXAnchor),
buyButtonLoadingIndicator.centerYAnchor.constraint(equalTo: buyButton.centerYAnchor)
])
// The free trial text label
freeTrialLabel = UILabel()
freeTrialLabel.numberOfLines = 2
freeTrialLabel.minimumScaleFactor = 0.01
freeTrialLabel.textAlignment = .center
freeTrialLabel.font = UIFont.systemFont(ofSize: 14)
freeTrialLabel.textColor = textColor
freeTrialLabel.alpha = 0.90
freeTrialLabel.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(freeTrialLabel)
NSLayoutConstraint.activate([
freeTrialLabel.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -16.0),
freeTrialLabel.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 16.0),
freeTrialLabel.bottomAnchor.constraint(equalTo: buyButton.topAnchor, constant: -4.0),
freeTrialLabel.topAnchor.constraint(equalTo: offeringCollectionView.bottomAnchor)
])
// The Terms & Conditions Label
termsAndConditionsLabel = UILabel()
let tap = UITapGestureRecognizer(target: self, action: #selector(tapToCs(tap:)))
termsAndConditionsLabel.addGestureRecognizer(tap)
termsAndConditionsLabel.isUserInteractionEnabled = true
termsAndConditionsLabel.numberOfLines = 2
termsAndConditionsLabel.minimumScaleFactor = 0.01
termsAndConditionsLabel.textAlignment = .center
termsAndConditionsLabel.font = UIFont.systemFont(ofSize: 12)
termsAndConditionsLabel.textColor = textColor
termsAndConditionsLabel.alpha = 0.90
let linkAttributes = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 12)]
let firstLine = NSMutableAttributedString(string: "By continuing, you agree to our\n")
let termsText = NSAttributedString(string: "Terms of Service", attributes: linkAttributes)
let and = NSAttributedString(string: " and ")
let privacyText = NSAttributedString(string: "Privacy Policy", attributes: linkAttributes)
firstLine.append(termsText)
firstLine.append(and)
firstLine.append(privacyText)
termsAndConditionsLabel.attributedText = firstLine
termsAndConditionsLabel.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(termsAndConditionsLabel)
NSLayoutConstraint.activate([
termsAndConditionsLabel.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -16.0),
termsAndConditionsLabel.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 16.0),
termsAndConditionsLabel.topAnchor.constraint(equalTo: buyButton.bottomAnchor, constant: 42.0),
termsAndConditionsLabel.heightAnchor.constraint(equalToConstant: 30.0)
])
// The restore button
restoreButton = UIButton()
restoreButton.addTarget(self, action: #selector(restorePurchases), for: .touchUpInside)
restoreButton.translatesAutoresizingMaskIntoConstraints = false
restoreButton.setTitle("RESTORE PURCHASES", for: .normal)
restoreButton.setTitleColor(textColor.withAlphaComponent(0.6), for: .normal)
restoreButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16.0)
restoreButton.isHidden = !allowRestore
scrollView.addSubview(restoreButton)
NSLayoutConstraint.activate([
restoreButton.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -20.0),
restoreButton.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 20.0),
restoreButton.topAnchor.constraint(equalTo: termsAndConditionsLabel.bottomAnchor, constant: 35.0),
restoreButton.heightAnchor.constraint(equalToConstant: 50.0)
])
// The close button
closeButton = CloseButton(backgroundColor: productDeselectedColor, textColor: textColor)
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
scrollView.addSubview(closeButton)
NSLayoutConstraint.activate([
closeButton.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 20.0),
closeButton.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 30.0),
closeButton.widthAnchor.constraint(equalToConstant: 30.0),
closeButton.heightAnchor.constraint(equalToConstant: 30.0)
])
}
}
extension SwiftPaywall: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return offering?.availablePackages.count ?? 0
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let package = offering?.availablePackages[indexPath.row]
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! PackageCell
cell.setupWith(
package: package,
discount: shouldShowDiscount(package: package),
edgeStyle: edgeStyle,
productSelectedColor: productSelectedColor,
productDeselectedColor: productDeselectedColor)
// Should this package be selected
if !didChangePackage && mostAffordablePackages.first?.product.productIdentifier == package?.product.productIdentifier {
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
cell.isSelected = true
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
didChangePackage = true
if #available(iOS 11.2, *) {
if let introPrice = offering?.availablePackages[indexPath.row].product.introductoryPrice, introPrice.price == 0 {
var trialLength = ""
var cancelDate : Date?
var cancelString = "end of trial"
let numUnits = introPrice.subscriptionPeriod.numberOfUnits
switch introPrice.subscriptionPeriod.unit {
case .day:
trialLength = "\(numUnits)-day"
cancelDate = Calendar.current.date(byAdding: .day, value: numUnits-1, to: Date())
case .week:
trialLength = "\(numUnits*7)-day"
cancelDate = Calendar.current.date(byAdding: .day, value: 7*numUnits-1, to: Date())
case .month:
trialLength = "\(numUnits)-month"
cancelDate = Calendar.current.date(byAdding: .month, value: numUnits, to: Date())
cancelDate = Calendar.current.date(byAdding: .day, value: -1, to: cancelDate ?? Date())
case .year:
trialLength = "\(numUnits)-year"
cancelDate = Calendar.current.date(byAdding: .year, value: numUnits, to: Date())
cancelDate = Calendar.current.date(byAdding: .day, value: -1, to: cancelDate ?? Date())
}
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMMM d"
if let cancelDate = cancelDate {
cancelString = dateFormatter.string(from: cancelDate)
}
let dateAttributes = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14)]
let baseText = NSMutableAttributedString(string: "Includes \(trialLength) free trial. Cancel before ")
let cancelAttributedText = NSAttributedString(string: cancelString, attributes: dateAttributes)
let and = NSAttributedString(string: " and nothing will be billed.")
baseText.append(cancelAttributedText)
baseText.append(and)
freeTrialLabel.attributedText = baseText
} else {
freeTrialLabel.text = nil
}
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: widthPerPackage, height: widthPerPackage*aspectRatio)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
let packagesCount = offering?.availablePackages.count ?? 0
if CGFloat(packagesCount) < maxItemsPerRow {
let totalCellWidth = widthPerPackage * CGFloat(packagesCount)
let totalSpacingWidth = sectionInsets.left * CGFloat(packagesCount - 1)
let leftInset = (collectionView.frame.width - (totalCellWidth + totalSpacingWidth)) / 2
return UIEdgeInsets(
top: sectionInsets.top,
left: leftInset,
bottom: sectionInsets.bottom,
right: leftInset)
} else {
return sectionInsets
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return sectionInsets.left
}
}
private class PackageCell : UICollectionViewCell {
let containerView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
view.layer.masksToBounds = true
view.clipsToBounds = true
return view
}()
let discountLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.backgroundColor = UIColor(red: 0.165, green: 0.663, blue: 0.545, alpha: 1.00)
label.font = UIFont.boldSystemFont(ofSize: 12)
label.numberOfLines = 1
label.minimumScaleFactor = 0.1
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let durationLabel: UILabel = {
let label = UILabel()
label.font = UIFont.boldSystemFont(ofSize: 16)
label.numberOfLines = 2
label.textAlignment = .center
label.minimumScaleFactor = 0.1
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let priceLabel: UILabel = {
let label = UILabel()
label.font = UIFont.boldSystemFont(ofSize: 18)
label.numberOfLines = 1
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let monthlyPriceLabel: UILabel = {
let label = UILabel()
label.font = UIFont.boldSystemFont(ofSize: 14)
label.numberOfLines = 1
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let discountFormatter = NumberFormatter()
let priceFormatter = NumberFormatter()
var highlightColor : UIColor?
var secondaryColor : UIColor?
override init(frame: CGRect) {
super.init(frame: frame)
buildSubviews()
}
override var isSelected: Bool {
didSet {
setHighlighted(self.isSelected)
}
}
func setHighlighted( _ highlighted: Bool) {
if highlighted {
containerView.backgroundColor = highlightColor
durationLabel.textColor = secondaryColor?.withAlphaComponent(0.6)
priceLabel.textColor = secondaryColor?.withAlphaComponent(0.9)
monthlyPriceLabel.textColor = secondaryColor?.withAlphaComponent(0.7)
} else {
containerView.backgroundColor = secondaryColor?.withAlphaComponent(0.1)
durationLabel.textColor = highlightColor?.withAlphaComponent(0.5)
priceLabel.textColor = highlightColor?.withAlphaComponent(0.5)
monthlyPriceLabel.textColor = highlightColor?.withAlphaComponent(0.5)
}
}
func setupWith(
package: Purchases.Package?,
discount: (Bool, Purchases.Package?),
edgeStyle: PayWallEdgeStyle = .round,
productSelectedColor: UIColor? = nil,
productDeselectedColor: UIColor? = nil) {
guard let package = package else {
durationLabel.text = nil
discountLabel.isHidden = true
priceLabel.text = nil
monthlyPriceLabel.text = nil
return
}
self.highlightColor = productSelectedColor
self.secondaryColor = productDeselectedColor
switch edgeStyle {
case .round:
containerView.layer.cornerRadius = 12
case .soft:
containerView.layer.cornerRadius = 8
case .square:
break
}
containerView.backgroundColor = secondaryColor?.withAlphaComponent(0.1)
durationLabel.backgroundColor = secondaryColor?.withAlphaComponent(0.08)
setHighlighted(isSelected)
discountLabel.isHidden = !discount.0
if discount.0, let discount = discount.1 {
discountFormatter.numberStyle = .percent
discountLabel.text = "SAVE \(discountFormatter.string(from: discountBetween(highest: discount, current: package)) ?? "")"
}
priceFormatter.numberStyle = .currency
priceFormatter.locale = package.product.priceLocale
priceLabel.text = package.localizedPriceString
switch package.packageType {
case .lifetime:
durationLabel.text = "LIFETIME"
monthlyPriceLabel.text = "ONE TIME"
discountLabel.isHidden = true
case .annual:
durationLabel.text = "1\nYEAR"
monthlyPriceLabel.text = "\(priceFormatter.string(from: package.product.price.dividing(by: 12.0)) ?? "") / mo"
case .sixMonth:
durationLabel.text = "6\nMONTHS"
monthlyPriceLabel.text = "\(priceFormatter.string(from: package.product.price.dividing(by: 6.0)) ?? "") / mo"
case .threeMonth:
durationLabel.text = "3\nMONTHS"
monthlyPriceLabel.text = "\(priceFormatter.string(from: package.product.price.dividing(by: 3.0)) ?? "") / mo"
case .twoMonth:
durationLabel.text = "2\nMONTHS"
monthlyPriceLabel.text = "\(priceFormatter.string(from: package.product.price.dividing(by: 2.0)) ?? "") / mo"
case .monthly:
durationLabel.text = "1\nMONTH"
monthlyPriceLabel.text = "\(package.localizedPriceString) / mo"
case .weekly:
durationLabel.text = "1\nWEEK"
monthlyPriceLabel.text = "\(package.localizedPriceString) / wk"
case .custom, .unknown:
durationLabel.text = package.identifier.uppercased()
discountLabel.isHidden = true
monthlyPriceLabel.text = nil
}
}
func discountBetween(highest: Purchases.Package, current: Purchases.Package) -> NSNumber {
let highestAnnualCost : NSNumber!
switch highest.packageType {
case .annual:
highestAnnualCost = highest.product.price
case .sixMonth:
highestAnnualCost = highest.product.price.multiplying(by: 2.0)
case .threeMonth:
highestAnnualCost = highest.product.price.multiplying(by: 4.0)
case .twoMonth:
highestAnnualCost = highest.product.price.multiplying(by: 6.0)
case .monthly:
highestAnnualCost = highest.product.price.multiplying(by: 12.0)
case .weekly:
highestAnnualCost = highest.product.price.multiplying(by: 52.0)
case .lifetime, .custom, .unknown:
return 0.0
}
let currentAnnualCost : NSNumber!
switch current.packageType {
case .annual:
currentAnnualCost = current.product.price
case .sixMonth:
currentAnnualCost = current.product.price.multiplying(by: 2.0)
case .threeMonth:
currentAnnualCost = current.product.price.multiplying(by: 4.0)
case .twoMonth:
currentAnnualCost = current.product.price.multiplying(by: 6.0)
case .monthly:
currentAnnualCost = current.product.price.multiplying(by: 12.0)
case .weekly:
currentAnnualCost = current.product.price.multiplying(by: 52.0)
case .lifetime, .custom, .unknown:
return 0.0
}
return NSNumber(value: (highestAnnualCost.doubleValue - currentAnnualCost.doubleValue) / highestAnnualCost.doubleValue)
}
func buildSubviews() {
addSubview(containerView)
NSLayoutConstraint.activate([
containerView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
containerView.topAnchor.constraint(equalTo: self.topAnchor),
containerView.leftAnchor.constraint(equalTo: self.leftAnchor),
containerView.rightAnchor.constraint(equalTo: self.rightAnchor)
])
containerView.addSubview(durationLabel)
NSLayoutConstraint.activate([
durationLabel.heightAnchor.constraint(equalTo: containerView.heightAnchor, multiplier: 0.5),
durationLabel.topAnchor.constraint(equalTo: containerView.topAnchor),
durationLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor),
durationLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor),
])
containerView.addSubview(priceLabel)
NSLayoutConstraint.activate([
priceLabel.topAnchor.constraint(equalTo: durationLabel.bottomAnchor, constant: 8),
priceLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor),
priceLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor),
priceLabel.heightAnchor.constraint(equalTo: containerView.heightAnchor, multiplier: 0.20)
])
containerView.addSubview(monthlyPriceLabel)
NSLayoutConstraint.activate([
monthlyPriceLabel.topAnchor.constraint(equalTo: priceLabel.bottomAnchor),
monthlyPriceLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor),
monthlyPriceLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor),
monthlyPriceLabel.heightAnchor.constraint(equalToConstant: 17.0)
])
addSubview(discountLabel)
discountLabel.layer.cornerRadius = 10
discountLabel.clipsToBounds = true
NSLayoutConstraint.activate([
discountLabel.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.8),
discountLabel.heightAnchor.constraint(equalToConstant: 20),
discountLabel.centerYAnchor.constraint(equalTo: self.topAnchor),
discountLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor)
])
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
fileprivate extension Purchases.Package {
func annualCost() -> Double {
switch self.packageType {
case .annual:
return self.product.price.doubleValue
case .sixMonth:
return self.product.price.doubleValue * 2
case .threeMonth:
return self.product.price.doubleValue * 4
case .twoMonth:
return self.product.price.doubleValue * 6
case .monthly:
return self.product.price.doubleValue * 12
case .weekly:
return self.product.price.doubleValue * 52
case .lifetime, .custom, .unknown:
return 0.0
}
}
}
private class CloseButton : UIButton {
private struct Constants {
static let plusLineWidth: CGFloat = 3.0
static let plusButtonScale: CGFloat = 0.5
}
private var halfWidth: CGFloat {
return bounds.width / 2
}
private var halfHeight: CGFloat {
return bounds.height / 2
}
private var bgColor : UIColor
private var txtColor : UIColor
init(backgroundColor: UIColor, textColor: UIColor) {
bgColor = backgroundColor
txtColor = textColor
super.init(frame: .zero)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
let path = UIBezierPath(ovalIn: rect)
bgColor.withAlphaComponent(0.1).setFill()
path.fill()
//set up the width and height variables
//for the horizontal stroke
let plusWidth: CGFloat = min(bounds.width, bounds.height) * Constants.plusButtonScale
let halfPlusWidth = (plusWidth / 2) / sqrt(2)
//create the plus
let plusPath = UIBezierPath()
//move the initial point of the path
//to the start of the horizontal stroke
plusPath.move(to: CGPoint(
x: halfWidth - halfPlusWidth,
y: halfHeight - halfPlusWidth))
//add a point to the path at the end of the stroke
plusPath.addLine(to: CGPoint(
x: halfWidth + halfPlusWidth,
y: halfHeight + halfPlusWidth))
//move the initial point of the path
//to the start of the vertical stroke
plusPath.move(to: CGPoint(
x: halfWidth - halfPlusWidth,
y: halfHeight + halfPlusWidth))
//add a point to the path at the end of the stroke
plusPath.addLine(to: CGPoint(
x: halfWidth + halfPlusWidth,
y: halfHeight - halfPlusWidth))
//set the stroke color
txtColor.withAlphaComponent(0.5).setStroke()
//set the details
plusPath.lineCapStyle = .round
plusPath.lineWidth = Constants.plusLineWidth
//draw the stroke
plusPath.stroke()
}
}
import WebKit
private class WebViewController: UIViewController {
var url : URL
var webView : WKWebView!
var textColor : UIColor?
var barColor : UIColor?
init(url: URL, title: String?, textColor: UIColor?, barColor: UIColor?) {
self.url = url
self.textColor = textColor
self.barColor = barColor
super.init(nibName: nil, bundle: nil)
self.title = title
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
webView = WKWebView(frame: view.frame, configuration: WKWebViewConfiguration())
webView.load(URLRequest(url: url))
view.addSubview(webView)
// reconfig the navigation bar
navigationController?.navigationBar.tintColor = textColor
navigationController?.navigationBar.titleTextAttributes = [
NSAttributedString.Key.foregroundColor : textColor ?? .black
]
navigationController?.navigationBar.barTintColor = barColor
navigationController?.navigationBar.isTranslucent = false
UINavigationBar.appearance().shadowImage = UIImage()
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Close", style: .done, target: self, action: #selector(close))
}
@objc func close() {
dismiss(animated: true, completion: nil)
}
}
fileprivate extension UITapGestureRecognizer {
func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {
// Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: CGSize.zero)
let textStorage = NSTextStorage(attributedString: label.attributedText ?? NSAttributedString(string: ""))
// Configure layoutManager and textStorage
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
// Configure textContainer
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = label.lineBreakMode
textContainer.maximumNumberOfLines = label.numberOfLines
let labelSize = label.bounds.size
textContainer.size = labelSize
// Find the tapped character location and compare it to the specified range
let locationOfTouchInLabel = self.location(in: label)
let textBoundingBox = layoutManager.usedRect(for: textContainer)
let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x, y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y)
let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, y: locationOfTouchInLabel.y - textContainerOffset.y)
let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
return NSLocationInRange(indexOfCharacter, targetRange)
}
}