Skip to content

Commit

Permalink
Merge pull request #259 from klaviyo/ab/CHNL-16220/present-webview-fr…
Browse files Browse the repository at this point in the history
…om-host-app

[CHNL-16220] present webview from host app
  • Loading branch information
ab1470 authored Jan 22, 2025
2 parents 432df3b + 465931b commit b6a7989
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class JSTestWebViewModel: KlaviyoWebViewModeling {
let loadScripts: [String: WKUserScript]?
weak var delegate: KlaviyoWebViewDelegate?

public let (navEventStream, navEventContinuation) = AsyncStream.makeStream(of: WKNavigationEvent.self)

init(url: URL) {
self.url = url
loadScripts = JSTestWebViewModel.initializeLoadScripts()
Expand All @@ -31,16 +33,8 @@ class JSTestWebViewModel: KlaviyoWebViewModeling {
return scripts
}

func preloadWebsite(timeout: UInt64) async {
// TODO: implement this
}

// MARK: handle WKWebView events

func handleNavigationEvent(_ event: WKNavigationEvent) {
// TODO: handle navigation events
}

func handleScriptMessage(_ message: WKScriptMessage) {
if message.name == "toggleMessageHandler" {
guard let dict = message.body as? [String: AnyObject] else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ class KlaviyoWebViewController: UIViewController, WKUIDelegate, KlaviyoWebViewDe
view = UIView()
view.addSubview(webView)

configureLoadScripts()
configureSubviewConstraints()
}

Expand All @@ -72,6 +71,7 @@ class KlaviyoWebViewController: UIViewController, WKUIDelegate, KlaviyoWebViewDe

@MainActor
private func loadUrl() {
configureLoadScripts()
let request = URLRequest(url: viewModel.url)
webView.load(request)
}
Expand All @@ -81,6 +81,11 @@ class KlaviyoWebViewController: UIViewController, WKUIDelegate, KlaviyoWebViewDe
loadUrl()
}

@MainActor
func dismiss() {
dismiss(animated: true)
}

// MARK: - Scripts

/// Configures the scripts to be injected into the website when the website loads.
Expand Down
94 changes: 17 additions & 77 deletions Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,27 @@ import Combine
import Foundation
import WebKit

protocol KlaviyoWebViewDelegate: AnyObject {
@_spi(KlaviyoPrivate)
public protocol KlaviyoWebViewDelegate: AnyObject {
@MainActor
func preloadUrl()

@MainActor
func evaluateJavaScript(_ script: String) async throws -> Any
}

class KlaviyoWebViewModel: KlaviyoWebViewModeling {
enum PreloadError: Error {
case timeout
case navigationFailed
}
@MainActor
func dismiss()
}

let url: URL
let loadScripts: [String: WKUserScript]?
weak var delegate: KlaviyoWebViewDelegate?
@_spi(KlaviyoPrivate)
public class KlaviyoWebViewModel: KlaviyoWebViewModeling {
public let url: URL
public let loadScripts: [String: WKUserScript]?
public weak var delegate: KlaviyoWebViewDelegate?

private let (navEventStream, navEventContinuation) = AsyncStream.makeStream(of: WKNavigationEvent.self)
public let (navEventStream, navEventContinuation) = AsyncStream.makeStream(of: WKNavigationEvent.self)

init(url: URL) {
public init(url: URL) {
self.url = url
loadScripts = KlaviyoWebViewModel.initializeLoadScripts()
}
Expand All @@ -45,76 +45,16 @@ class KlaviyoWebViewModel: KlaviyoWebViewModeling {
return scripts
}

/// Tells the delegate's ``WKWebView`` to preload the URL provided in this ViewModel.
///
/// This async method will return after the preload has completed.
///
/// By preloading, we can load the URL "headless", so that the ViewController containing
/// the ``WKWebView`` will only be presented after the site has successfully loaded.
///
/// The caller of this method should `await` completion of this method, then present the ViewController.
/// - Parameter timeout: the amount of time, in milliseconds, to wait before throwing a `timeout` error.
func preloadWebsite(timeout: UInt64) async throws {
guard let delegate else { return }

await delegate.preloadUrl()

do {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await Task.sleep(nanoseconds: timeout)
throw PreloadError.timeout
}

// Add the navigation event task to the group
group.addTask { [weak self] in
guard let self else { return }
for await event in self.navEventStream {
switch event {
case .didFinishNavigation:
return
case .didFailNavigation:
throw PreloadError.navigationFailed
case .didCommitNavigation,
.didStartProvisionalNavigation,
.didFailProvisionalNavigation,
.didReceiveServerRedirectForProvisionalNavigation:
continue
}
}
}

if let _ = try await group.next() {
// when the navigation task returns, we want to
// cancel both the timeout task and the navigation task
group.cancelAll()
}
}
} catch let error as PreloadError {
switch error {
case .timeout:
print("Operation timed out: \(error)")
throw error
case .navigationFailed:
print("Navigation failed: \(error)")
throw error
}
} catch {
print("Operation encountered an error: \(error)")
throw error
}
}

// MARK: handle WKWebView events

func handleNavigationEvent(_ event: WKNavigationEvent) {
navEventContinuation.yield(event)
}

func handleScriptMessage(_ message: WKScriptMessage) {
public func handleScriptMessage(_ message: WKScriptMessage) {
if message.name == "closeHandler" {
// TODO: handle close button tap
print("user tapped close button")

Task {
await delegate?.dismiss()
}
}
}
}
73 changes: 72 additions & 1 deletion Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,85 @@ import Combine
import Foundation
import WebKit

protocol KlaviyoWebViewModeling {
@_spi(KlaviyoPrivate)
public protocol KlaviyoWebViewModeling: AnyObject {
var url: URL { get }
var delegate: KlaviyoWebViewDelegate? { get set }

/// Scripts to be injected into the ``WKWebView`` when the website loads.
var loadScripts: [String: WKUserScript]? { get }
var navEventStream: AsyncStream<WKNavigationEvent> { get }
var navEventContinuation: AsyncStream<WKNavigationEvent>.Continuation { get }

func preloadWebsite(timeout: UInt64) async throws
func handleNavigationEvent(_ event: WKNavigationEvent)
func handleScriptMessage(_ message: WKScriptMessage)
}

// MARK: - Default implementation

extension KlaviyoWebViewModeling {
/// Tells the delegate's ``WKWebView`` to preload the URL provided in this ViewModel.
///
/// This async method will return after the preload has completed.
///
/// By preloading, we can load the URL "headless", so that the ViewController containing
/// the ``WKWebView`` will only be presented after the site has successfully loaded.
///
/// The caller of this method should `await` completion of this method, then present the ViewController.
/// - Parameter timeout: the amount of time, in milliseconds, to wait before throwing a `timeout` error.
public func preloadWebsite(timeout: UInt64) async throws {
guard let delegate else { return }

await delegate.preloadUrl()

do {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await Task.sleep(nanoseconds: timeout)
throw PreloadError.timeout
}

// Add the navigation event task to the group
group.addTask { [weak self] in
guard let self else { return }
for await event in self.navEventStream {
switch event {
case .didFinishNavigation:
return
case .didFailNavigation:
throw PreloadError.navigationFailed
case .didCommitNavigation,
.didStartProvisionalNavigation,
.didFailProvisionalNavigation,
.didReceiveServerRedirectForProvisionalNavigation:
continue
}
}
}

if let _ = try await group.next() {
// when the navigation task returns, we want to
// cancel both the timeout task and the navigation task
group.cancelAll()
}
}
} catch let error as PreloadError {
switch error {
case .timeout:
print("Operation timed out: \(error)")
throw error
case .navigationFailed:
print("Navigation failed: \(error)")
throw error
}
} catch {
print("Operation encountered an error: \(error)")
throw error
}
}

public func handleNavigationEvent(_ event: WKNavigationEvent) {
navEventContinuation.yield(event)
}
}
11 changes: 11 additions & 0 deletions Sources/KlaviyoUI/KlaviyoWebView/PreloadError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// PreloadError.swift
// klaviyo-swift-sdk
//
// Created by Andrew Balmer on 1/21/25.
//

enum PreloadError: Error {
case timeout
case navigationFailed
}
47 changes: 47 additions & 0 deletions Sources/KlaviyoUI/KlaviyoWebViewOverlayManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// KlaviyoWebViewOverlayManager.swift
// klaviyo-swift-sdk
//
// Created by Andrew Balmer on 1/15/25.
//

import SwiftUI
import UIKit

@_spi(KlaviyoPrivate)
public class KlaviyoWebViewOverlayManager {
public static let shared = KlaviyoWebViewOverlayManager()
private var isLoading: Bool = false

/// Presents a view controller on the top-most view controller
/// - Parameters:
/// - viewController: A `UIViewController` instance to present.
/// - modalPresentationStyle: The modal presentation style to use (default is `.overCurrentContext`).
///
/// - warning: For internal use only. The host app should not manually call this method, as
/// the logic for fetching and displaying forms will be handled internally within the SDK.
@_spi(KlaviyoPrivate)
@MainActor public func preloadAndShow(
viewModel: KlaviyoWebViewModeling,
modalPresentationStyle: UIModalPresentationStyle = .overCurrentContext) {
guard !isLoading else {
return
}

isLoading = true

let viewController = KlaviyoWebViewController(viewModel: viewModel)
viewController.modalPresentationStyle = modalPresentationStyle

Task {
defer { isLoading = false }

try await viewModel.preloadWebsite(timeout: 8_000_000_000)

guard let topController = UIApplication.shared.topMostViewController else {
return
}
topController.present(viewController, animated: true, completion: nil)
}
}
}
3 changes: 2 additions & 1 deletion Sources/KlaviyoUI/Models/WKNavigationEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
// Created by Andrew Balmer on 9/30/24.
//

enum WKNavigationEvent {
@_spi(KlaviyoPrivate)
public enum WKNavigationEvent {
/// Invoked when a main frame navigation starts.
case didStartProvisionalNavigation

Expand Down
34 changes: 34 additions & 0 deletions Sources/KlaviyoUI/Utilities/UIApplication+Ext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// UIApplication+Ext.swift
// klaviyo-swift-sdk
//
// Created by Andrew Balmer on 12/4/24.
//

import Foundation
import UIKit

extension UIApplication {
var topMostViewController: UIViewController? {
guard let keyWindow = getKeyWindow() else { return nil }
var topController = keyWindow.rootViewController
while let presentedController = topController?.presentedViewController {
if let navigationController = presentedController as? UINavigationController {
topController = navigationController.visibleViewController
} else if let tabBarController = presentedController as? UITabBarController {
topController = tabBarController.selectedViewController
} else {
topController = presentedController
}
}
return topController
}

private func getKeyWindow() -> UIWindow? {
connectedScenes
.filter { $0 is UIWindowScene }
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: { $0.isKeyWindow })
}
}
Loading

0 comments on commit b6a7989

Please sign in to comment.