diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj index 279cf605..89ff679a 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 4EF54F242A6F456B00F5E407 /* CartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF54F232A6F456B00F5E407 /* CartManager.swift */; }; 4EF54F272A6F4C4F00F5E407 /* CartViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF54F262A6F4C4F00F5E407 /* CartViewController.swift */; }; 4EF54F312A6F63C000F5E407 /* CartViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4EF54F2F2A6F63C000F5E407 /* CartViewController.xib */; }; + 6A8D64D22AF2A10600FE4E4A /* ShopifyCheckout in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8D64D12AF2A10600FE4E4A /* ShopifyCheckout */; }; + 6A8D64D42AF5635000FE4E4A /* PreloadBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8D64D32AF5635000FE4E4A /* PreloadBanner.swift */; }; 86250DE42AD5521C002E45C2 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86250DE32AD5521C002E45C2 /* AppConfiguration.swift */; }; /* End PBXBuildFile section */ @@ -43,6 +45,7 @@ 4EF54F232A6F456B00F5E407 /* CartManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartManager.swift; sourceTree = ""; }; 4EF54F262A6F4C4F00F5E407 /* CartViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartViewController.swift; sourceTree = ""; }; 4EF54F2F2A6F63C000F5E407 /* CartViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CartViewController.xib; sourceTree = ""; }; + 6A8D64D32AF5635000FE4E4A /* PreloadBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadBanner.swift; sourceTree = ""; }; 86250DE32AD5521C002E45C2 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -111,6 +114,7 @@ 4EF54F262A6F4C4F00F5E407 /* CartViewController.swift */, 4EF54F2F2A6F63C000F5E407 /* CartViewController.xib */, 4EA7F9B42A9D2B9D003276A1 /* SettingsViewController.swift */, + 6A8D64D32AF5635000FE4E4A /* PreloadBanner.swift */, ); name = Application; sourceTree = ""; @@ -246,6 +250,7 @@ 4EF54F242A6F456B00F5E407 /* CartManager.swift in Sources */, 4EBBA76F2A5F0CE200193E19 /* ProductViewController.swift in Sources */, 4EF54F272A6F4C4F00F5E407 /* CartViewController.swift in Sources */, + 6A8D64D42AF5635000FE4E4A /* PreloadBanner.swift in Sources */, 4EBBA76B2A5F0CE200193E19 /* AppDelegate.swift in Sources */, 86250DE42AD5521C002E45C2 /* AppConfiguration.swift in Sources */, 4EBBA7AA2A5F124F00193E19 /* StorefrontClient.swift in Sources */, diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/PreloadBanner.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/PreloadBanner.swift new file mode 100644 index 00000000..7905f9b5 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/PreloadBanner.swift @@ -0,0 +1,86 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import UIKit + +class PreloadBanner { + static let shared = PreloadBanner() + + private let bannerView: UIView + private let label: UILabel + private let height: CGFloat = 30 + private let yVisible: CGFloat = UIScreen.main.bounds.height - 113 + private let yHidden: CGFloat = UIScreen.main.bounds.height - 108 + + private init() { + bannerView = UIView( + frame: CGRect( + x: 0, + y: yHidden, + width: UIScreen.main.bounds.width, + height: height + ) + ) + bannerView.backgroundColor = .systemGreen + + // Initialize the label + label = UILabel(frame: bannerView.bounds) + label.textAlignment = .center + label.textColor = .white + label.font = UIFont.systemFont(ofSize: 14) + bannerView.addSubview(label) + bannerView.isHidden = true + bannerView.alpha = 0 + + // Add the banner view to the appropriate window scene + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.addSubview(bannerView) + } + } + + func showBanner(withText text: String = "Preloaded checkout") { + label.text = text + + // Animate the banner view to show it + UIView.animate(withDuration: 0.3) { + self.bannerView.frame.origin.y = self.yVisible + self.bannerView.alpha = 1 + self.bannerView.isHidden = false + } + + // Schedule a timer to hide the banner after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.hideBanner() + } + } + + func hideBanner() { + UIView.animate(withDuration: 0.3, animations: { + self.bannerView.frame.origin.y = self.yHidden + self.bannerView.alpha = 0 + }, completion: {(_ completed) in + self.bannerView.isHidden = true + }) + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift index 70d46390..633b4b74 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift @@ -25,8 +25,7 @@ import Buy import UIKit import ShopifyCheckout -class ProductViewController: UIViewController { - +class ProductViewController: UIViewController, CheckoutEventListener { // MARK: Properties @IBOutlet private var image: UIImageView! @@ -55,6 +54,12 @@ class ProductViewController: UIViewController { title = "Browse" tabBarItem.image = UIImage(systemName: "books.vertical") + + ShopifyCheckout.events.addEventListener(self, for: .load) + } + + deinit { + ShopifyCheckout.events.removeEventListener(self, for: .load) } // MARK: UIViewController Lifecycle @@ -74,6 +79,15 @@ class ProductViewController: UIViewController { reloadProduct() } + func handleCheckoutEvent(_ event: CheckoutEvent, message: String? = nil) { + switch event { + case .load: + if ShopifyCheckout.configuration.preloading.enabled { + PreloadBanner.shared.showBanner(withText: "Preloaded checkout in \(message ?? "")") + } + } + } + // MARK: Actions @IBAction func addToCart() { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift index 9523da97..a59a3198 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift @@ -27,6 +27,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + var preloadBanner: PreloadBanner? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } @@ -47,6 +50,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window.rootViewController = tabBarController window.makeKeyAndVisible() + preloadBanner = PreloadBanner.shared + self.window = window } } diff --git a/Sources/ShopifyCheckout/CheckoutView.swift b/Sources/ShopifyCheckout/CheckoutView.swift index 582d33eb..f42981a6 100644 --- a/Sources/ShopifyCheckout/CheckoutView.swift +++ b/Sources/ShopifyCheckout/CheckoutView.swift @@ -70,6 +70,10 @@ class CheckoutView: WKWebView { super.init(frame: frame, configuration: configuration) navigationDelegate = self + +// if #available(iOS 16.4, *) { +// self.isInspectable = true +// } } required init?(coder: NSCoder) { @@ -116,6 +120,8 @@ extension CheckoutView: WKScriptMessageHandler { } } +private var timer: Date? + extension CheckoutView: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { @@ -161,18 +167,32 @@ extension CheckoutView: WKNavigationDelegate { return .allow } - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - viewDelegate?.checkoutViewDidStartNavigation() - } + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + timer = Date() + viewDelegate?.checkoutViewDidStartNavigation() + } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - viewDelegate?.checkoutViewDidFinishNavigation() + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + timer = nil } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - CheckoutView.cache = nil - viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) - } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + viewDelegate?.checkoutViewDidFinishNavigation() + + if let startTime = timer { + let endTime = Date() + let diff = endTime.timeIntervalSince(startTime) + ShopifyCheckout.events.triggerEvent(.load, message: "\(String(format: "%.2f", diff))s") + } + + timer = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + timer = nil + CheckoutView.cache = nil + viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) + } private func isExternalLink(_ action: WKNavigationAction) -> Bool { if action.navigationType == .linkActivated && action.targetFrame == nil { return true } diff --git a/Sources/ShopifyCheckout/ShopifyCheckout.swift b/Sources/ShopifyCheckout/ShopifyCheckout.swift index 47a36480..ae49a3cd 100644 --- a/Sources/ShopifyCheckout/ShopifyCheckout.swift +++ b/Sources/ShopifyCheckout/ShopifyCheckout.swift @@ -51,3 +51,37 @@ public func present(checkout url: URL, from: UIViewController, delegate: Checkou viewController.presentationController?.delegate = rootViewController from.present(viewController, animated: true) } + +public enum CheckoutEvent: Hashable { + case load +} + +public protocol CheckoutEventListener: AnyObject { + func handleCheckoutEvent(_ event: CheckoutEvent, message: String?) +} + +public var events = Events() + +public struct Events { + private var eventListeners = [CheckoutEvent: [CheckoutEventListener]]() + + public mutating func addEventListener(_ listener: CheckoutEventListener, for event: CheckoutEvent) { + if eventListeners[event] == nil { + eventListeners[event] = [CheckoutEventListener]() + } + eventListeners[event]?.append(listener) + } + + public mutating func removeEventListener(_ listener: CheckoutEventListener, for event: CheckoutEvent) { + eventListeners[event]?.removeAll { $0 === listener } + } + + func triggerEvent(_ event: CheckoutEvent, message: String?) { + guard let listeners = eventListeners[event] else { + return + } + for listener in listeners { + listener.handleCheckoutEvent(event, message: message) + } + } +}