From 02c559deb4c78c3393a7ed79d62eac6263d38493 Mon Sep 17 00:00:00 2001 From: Andrew Balmer Date: Fri, 4 Oct 2024 16:18:51 -0500 Subject: [PATCH] [CHNL-11919] create native view to show web view (#212) * added KlaviyoWebViewController.swift * added FileIO.swift implemented `FileIO` removed `internal` access control fixed `getFileContents(...)` * initial KlaviyoWebViewController implementation removed config parameter added `createWebView` comments * added `configureSubviewConstraints()` * added preview added preview title * added `createWebViewConfiguration()` method added `createWebViewConfiguration` comments * added `WKNavigationEvent` enum added documentation to `WKNavigationEvent` enum * inject URL * added `FileIO.getFileUrl(...)` method * added placeholder code for WKNavigationDelegate methods * added placeholder code to handle WKScriptMessage * refactored webView initialization * added KlaviyoWebViewModel * inject viewModel into viewController * added `handleNavigationEvent` method added // MARK * call `handleNavigationEvent` from viewController * added `hanldeScriptMessage` method * added `configureScripts()` renamed `configureScripts` and added documentation renamed `scripts` to `loadScripts` added TODO * added resources to Package.swift added bridge.js file * added // MARK labels added // MARK labels added MARK * added script execution logic * changed script executor injection to enable callbacks * made `loadScripts` optional * created `KlaviyoWebViewModeling` protocol * implemented protocol * reorganized files * added `return` to preview * added swift version check * reverted Xcode CI build versions --- .github/workflows/swift.yml | 2 +- Package.swift | 3 +- .../KlaviyoWebViewController.swift | 136 ++++++++++++++++++ .../KlaviyoWebView/KlaviyoWebViewModel.swift | 41 ++++++ .../KlaviyoWebViewModeling.swift | 23 +++ .../Resources/Scripts/bridge.js | 6 + .../KlaviyoUI/Models/WKNavigationEvent.swift | 26 ++++ Sources/KlaviyoUI/Utilities/FileIO.swift | 35 +++++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift create mode 100644 Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift create mode 100644 Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift create mode 100644 Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js create mode 100644 Sources/KlaviyoUI/Models/WKNavigationEvent.swift create mode 100644 Sources/KlaviyoUI/Utilities/FileIO.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 454bf0f6..e832e28f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -19,7 +19,7 @@ jobs: runs-on: macos-13 strategy: matrix: - xcode: ['15.2', '15.4', '16.0'] + xcode: ['14.3.1', '15.1'] config: ['debug', 'release'] steps: - uses: actions/checkout@v3 diff --git a/Package.swift b/Package.swift index be1bf6a4..57a18cce 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,8 @@ let package = Package( .target( name: "KlaviyoUI", dependencies: ["KlaviyoSwift"], - path: "Sources/KlaviyoUI"), + path: "Sources/KlaviyoUI", + resources: [.process("KlaviyoWebView/Resources")]), .testTarget( name: "KlaviyoUITests", dependencies: [ diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift new file mode 100644 index 00000000..0c3b4eae --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift @@ -0,0 +1,136 @@ +// +// KlaviyoWebViewController.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 9/28/24. +// + +import Combine +import UIKit +import WebKit + +class KlaviyoWebViewController: UIViewController, WKUIDelegate { + var webView: WKWebView! + private let viewModel: KlaviyoWebViewModeling + private var cancellables = Set() + + // MARK: - Initializers + + init(viewModel: KlaviyoWebViewModeling) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View loading + + override func loadView() { + super.loadView() + + let config = createWebViewConfiguration() + webView = createWebView(with: config) + webView.navigationDelegate = self + webView.uiDelegate = self + + view.addSubview(webView) + + configureLoadScripts() + configureScriptEvaluator() + configureSubviewConstraints() + } + + override func viewDidLoad() { + let request = URLRequest(url: viewModel.url) + webView.load(request) + } + + // MARK: - WKWebView configuration + + func createWebViewConfiguration() -> WKWebViewConfiguration { + let config = WKWebViewConfiguration() + // customize any WKWebViewConfiguration properties here + // ex: config.allowsInlineMediaPlayback = true + return config + } + + func createWebView(with config: WKWebViewConfiguration) -> WKWebView { + let webView = WKWebView(frame: .zero, configuration: config) + // customize any WKWebView behaviors here + // ex: webView.allowsBackForwardNavigationGestures = true + return webView + } + + // MARK: - Scripts + + /// Configures the scripts to be injected into the website when the website loads. + func configureLoadScripts() { + guard let scriptsDict = viewModel.loadScripts else { return } + + for (name, script) in scriptsDict { + webView.configuration.userContentController.addUserScript(script) + webView.configuration.userContentController.add(self, name: name) + } + } + + func configureScriptEvaluator() { + viewModel.scriptSubject.sink { [weak self] script, callback in + Task { [weak self] in + do { + let result = try await self?.webView.evaluateJavaScript(script) + callback?(.success(result)) + } catch { + callback?(.failure(error)) + } + } + }.store(in: &cancellables) + } + + // MARK: - Layout + + func configureSubviewConstraints() { + webView.translatesAutoresizingMaskIntoConstraints = false + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + } +} + +extension KlaviyoWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + viewModel.handleNavigationEvent(.didStartProvisionalNavigation) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + viewModel.handleNavigationEvent(.didFailProvisionalNavigation) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + viewModel.handleNavigationEvent(.didFinishNavigation) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + viewModel.handleNavigationEvent(.didFailNavigation) + } +} + +extension KlaviyoWebViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + viewModel.handleScriptMessage(message) + } +} + +// MARK: - Previews + +#if swift(>=5.9) +@available(iOS 17.0, *) +#Preview("Klaviyo.com") { + let url = URL(string: "https://www.klaviyo.com")! + let viewModel = KlaviyoWebViewModel(url: url) + return KlaviyoWebViewController(viewModel: viewModel) +} +#endif diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift new file mode 100644 index 00000000..7bab7e89 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift @@ -0,0 +1,41 @@ +// +// KlaviyoWebViewModel.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 9/30/24. +// + +import Combine +import Foundation +import WebKit + +class KlaviyoWebViewModel: KlaviyoWebViewModeling { + let url: URL + let loadScripts: [String: WKUserScript]? + + /// Publishes scripts for the `WKWebView` to execute. + let scriptSubject = PassthroughSubject<(script: String, callback: ((Result) -> Void)?), Never>() + + init(url: URL) { + self.url = url + loadScripts = KlaviyoWebViewModel.initializeLoadScripts() + } + + private static func initializeLoadScripts() -> [String: WKUserScript] { + var scripts: [String: WKUserScript] = [:] + + // TODO: initialize scripts + + return scripts + } + + // MARK: handle WKWebView events + + func handleNavigationEvent(_ event: WKNavigationEvent) { + // TODO: handle navigation events + } + + func handleScriptMessage(_ message: WKScriptMessage) { + // TODO: handle script message + } +} diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift new file mode 100644 index 00000000..ec2bccc0 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift @@ -0,0 +1,23 @@ +// +// KlaviyoWebViewModeling.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/1/24. +// + +import Combine +import Foundation +import WebKit + +protocol KlaviyoWebViewModeling { + var url: URL { get } + + /// Scripts to be injected into the ``WKWebView`` when the website loads. + var loadScripts: [String: WKUserScript]? { get } + + /// Publishes scripts for the ``WKWebView`` to execute. + var scriptSubject: PassthroughSubject<(script: String, callback: ((Result) -> Void)?), Never> { get } + + func handleNavigationEvent(_ event: WKNavigationEvent) + func handleScriptMessage(_ message: WKScriptMessage) +} diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js b/Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js new file mode 100644 index 00000000..94d16c8d --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js @@ -0,0 +1,6 @@ +// +// Bridge.js +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/1/24. +// diff --git a/Sources/KlaviyoUI/Models/WKNavigationEvent.swift b/Sources/KlaviyoUI/Models/WKNavigationEvent.swift new file mode 100644 index 00000000..00afc221 --- /dev/null +++ b/Sources/KlaviyoUI/Models/WKNavigationEvent.swift @@ -0,0 +1,26 @@ +// +// WKNavigationEvent.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 9/30/24. +// + +enum WKNavigationEvent { + /// Invoked when a main frame navigation starts. + case didStartProvisionalNavigation + + /// Invoked when a server redirect is received for the main frame. + case didReceiveServerRedirectForProvisionalNavigation + + /// Invoked when an error occurs while starting to load data for the main frame. + case didFailProvisionalNavigation + + /// Invoked when content starts arriving for the main frame. + case didCommitNavigation + + /// Invoked when a main frame navigation completes. + case didFinishNavigation + + /// Invoked when an error occurs during a committed main frame navigation. + case didFailNavigation +} diff --git a/Sources/KlaviyoUI/Utilities/FileIO.swift b/Sources/KlaviyoUI/Utilities/FileIO.swift new file mode 100644 index 00000000..387339f4 --- /dev/null +++ b/Sources/KlaviyoUI/Utilities/FileIO.swift @@ -0,0 +1,35 @@ +// +// FileIO.swift +// KlaviyoSwiftUIWebView +// +// Created by Andrew Balmer on 9/27/24. +// + +import Foundation + +enum FileIOError: Error { + case notFound +} + +enum FileIO { + static func getFileUrl(path: String, type: String) throws -> URL { + guard let fileUrl = Bundle.module.url(forResource: path, withExtension: type) else { + throw FileIOError.notFound + } + + return fileUrl + } + + static func getFileContents(path: String, type: String) throws -> String { + guard let path = Bundle.module.path(forResource: path, ofType: type) else { + throw FileIOError.notFound + } + + do { + let contents = try String(contentsOfFile: path, encoding: String.Encoding.utf8) + return contents + } catch { + throw error + } + } +}