diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/PreviewGridViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/PreviewGridViewController.swift new file mode 100644 index 00000000..e184ccd5 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/PreviewGridViewController.swift @@ -0,0 +1,65 @@ +// +// PreviewGridViewController.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/23/24. +// + +#if DEBUG +import Foundation +import UIKit + +/// A sample grid view with randomly-colored tiles for internal Xcode preview purposes only. +class PreviewGridViewController: UIViewController, UICollectionViewDataSource { + var collectionView: UICollectionView! + + override func viewDidLoad() { + super.viewDidLoad() + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.4)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .flexible(12) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 12 + section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16) + + let layout = UICollectionViewCompositionalLayout(section: section) + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") + collectionView.dataSource = self + + view.addSubview(collectionView) + + // Disable autoresizing mask translation for Auto Layout + collectionView.translatesAutoresizingMaskIntoConstraints = false + + // Set up constraints using safeAreaLayoutGuide + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + // MARK: UICollectionViewDataSource + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + 12 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) + cell.backgroundColor = UIColor( + red: .random(in: 0...1), + green: .random(in: 0...1), + blue: .random(in: 0...1), + alpha: 1.0) + cell.layer.cornerRadius = 12.0 + return cell + } +} +#endif diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/PreviewTabViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/PreviewTabViewController.swift new file mode 100644 index 00000000..ad667b67 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/PreviewTabViewController.swift @@ -0,0 +1,36 @@ +// +// PreviewTabViewController.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/23/24. +// + +#if DEBUG +import Foundation +import UIKit + +/// A sample tab view for internal Xcode preview purposes only. +class PreviewTabViewController: UITabBarController { + override func viewDidLoad() { + super.viewDidLoad() + + let firstVC = PreviewGridViewController() + let secondVC = UIViewController() + let thirdVC = UIViewController() + + firstVC.title = "Browse" + + firstVC.tabBarItem = UITabBarItem(title: "Browse", image: UIImage(systemName: "house"), tag: 0) + secondVC.tabBarItem = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1) + thirdVC.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 2) + + viewControllers = [ + UINavigationController(rootViewController: firstVC), + UINavigationController(rootViewController: secondVC), + UINavigationController(rootViewController: thirdVC) + ] + + firstVC.navigationController?.navigationBar.prefersLargeTitles = true + } +} +#endif diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift index a2198d08..03e959e7 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift @@ -37,9 +37,9 @@ class KlaviyoWebViewController: UIViewController, WKUIDelegate { view.addSubview(webView) + configureSubviewConstraints() configureLoadScripts() configureScriptEvaluator() - configureSubviewConstraints() } override func viewDidLoad() { diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift index 1f3a6e99..e80e0827 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift @@ -41,4 +41,10 @@ class KlaviyoWebViewModel: KlaviyoWebViewModeling { func handleScriptMessage(_ message: WKScriptMessage) { // TODO: handle script message } + + // MARK: handle user events + + func dismiss() { + // TODO: handle dismiss + } } diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift index 5c34cfac..dc725139 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift @@ -20,4 +20,5 @@ protocol KlaviyoWebViewModeling { func handleNavigationEvent(_ event: WKNavigationEvent) func handleScriptMessage(_ message: WKScriptMessage) + func dismiss() } diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebWrapperStyle.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebWrapperStyle.swift new file mode 100644 index 00000000..2bfc2b4e --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebWrapperStyle.swift @@ -0,0 +1,32 @@ +// +// KlaviyoWebWrapperStyle.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/28/24. +// + +import UIKit + +public struct KlaviyoWebWrapperStyle { + public enum BackgroundStyle { + case blurred(effect: UIBlurEffect.Style) + case tinted(color: UIColor = .black, opacity: Float) + } + + var backgroundStyle: BackgroundStyle + var insets: NSDirectionalEdgeInsets + var cornerRadius: CGFloat + var shadowStyle: ShadowStyle? +} + +extension KlaviyoWebWrapperStyle { + static var `default` = Self( + backgroundStyle: .blurred(effect: .systemUltraThinMaterialDark), + insets: NSDirectionalEdgeInsets(top: 24, leading: 36, bottom: 24, trailing: 36), + cornerRadius: 16.0, + shadowStyle: .init( + color: UIColor.black.cgColor, + opacity: 0.5, + offset: CGSize(width: 5, height: 5), + radius: 8)) +} diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebWrapperViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebWrapperViewController.swift new file mode 100644 index 00000000..5e084d13 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebWrapperViewController.swift @@ -0,0 +1,169 @@ +// +// File.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/22/24. +// + +import Foundation +import UIKit + +public class KlaviyoWebWrapperViewController: UIViewController { + // MARK: - Properties + + let viewModel: KlaviyoWebViewModeling + let style: KlaviyoWebWrapperStyle + + private lazy var dismissGestureRecognizer: UITapGestureRecognizer = { + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDismissGesture)) + tapRecognizer.numberOfTapsRequired = 1 + tapRecognizer.numberOfTouchesRequired = 1 + return tapRecognizer + }() + + // MARK: - Subviews + + private lazy var blurEffectView: UIVisualEffectView? = { + guard case let .blurred(effect) = style.backgroundStyle else { return nil } + + let blurEffect = UIBlurEffect(style: effect) + let blurEffectView = UIVisualEffectView(effect: blurEffect) + + blurEffectView.addGestureRecognizer(dismissGestureRecognizer) + + return blurEffectView + }() + + private lazy var tintView: UIView? = { + guard case let .tinted(color, opacity) = style.backgroundStyle else { return nil } + + let tintView = UIView() + tintView.backgroundColor = color + tintView.layer.opacity = opacity + + tintView.addGestureRecognizer(dismissGestureRecognizer) + + return tintView + }() + + private lazy var shadowContainerView: UIView? = { + guard let shadowProperties = style.shadowStyle else { return nil } + + let shadowContainerView = UIView() + + shadowContainerView.layer.shadowColor = shadowProperties.color + shadowContainerView.layer.shadowOpacity = shadowProperties.opacity + shadowContainerView.layer.shadowOffset = shadowProperties.offset + shadowContainerView.layer.shadowRadius = shadowProperties.radius + + shadowContainerView.layer.masksToBounds = false + + return shadowContainerView + }() + + private lazy var webViewController: KlaviyoWebViewController = { + let webViewController = KlaviyoWebViewController(viewModel: viewModel) + guard let webView = webViewController.view else { return webViewController } + webView.layer.cornerRadius = 16 + webView.layer.masksToBounds = true + + return webViewController + }() + + // MARK: - View Initialization + + init(viewModel: KlaviyoWebViewModeling, style: KlaviyoWebWrapperStyle = .default) { + self.viewModel = viewModel + self.style = style + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + loadSubviews() + } + + func loadSubviews() { + if let blurEffectView { + view.addSubview(blurEffectView) + blurEffectView.pin(to: view) + } + + if let tintView { + view.addSubview(tintView) + tintView.pin(to: view) + } + + guard let webView = webViewController.view else { return } + let webViewInsets = style.insets + + if let shadowContainerView { + view.addSubview(shadowContainerView) + shadowContainerView.addSubview(webView) + + shadowContainerView.pin(to: view.safeAreaLayoutGuide, insets: webViewInsets) + webView.pin(to: shadowContainerView) + + addChild(webViewController) + webViewController.didMove(toParent: self) + } else { + view.addSubview(webView) + + webView.pin(to: view.safeAreaLayoutGuide, insets: webViewInsets) + + addChild(webViewController) + webViewController.didMove(toParent: self) + } + } + + // MARK: - user interactions + + @objc private func handleDismissGesture() { + viewModel.dismiss() + } +} + +// MARK: - Previews + +#if DEBUG +func createKlaviyoWebPreview(url: URL, style: KlaviyoWebWrapperStyle) -> UIViewController { + let viewModel = KlaviyoWebViewModel(url: url) + let viewController = KlaviyoWebWrapperViewController(viewModel: viewModel, style: style) + + // Add a dummy view in the background to preview what the KlaviyoWebWrapperViewController + // might look like when it's displayed on top of a view in an app. + let childViewController = PreviewTabViewController() + viewController.view.addSubview(childViewController.view) + viewController.view.sendSubviewToBack(childViewController.view) + viewController.addChild(childViewController) + childViewController.didMove(toParent: viewController) + + return viewController +} +#endif + +#if swift(>=5.9) +@available(iOS 17.0, *) +#Preview("Default style") { + let url = URL(string: "https://www.google.com")! + return createKlaviyoWebPreview(url: url, style: .default) +} + +@available(iOS 17.0, *) +#Preview("Tinted background") { + let url = URL(string: "https://www.google.com")! + let style = KlaviyoWebWrapperStyle( + backgroundStyle: .tinted(opacity: 0.6), + insets: NSDirectionalEdgeInsets(top: 24, leading: 36, bottom: 24, trailing: 36), + cornerRadius: 24, + shadowStyle: .default) + + return createKlaviyoWebPreview(url: url, style: style) +} +#endif diff --git a/Sources/KlaviyoUI/Styles/ShadowStyle.swift b/Sources/KlaviyoUI/Styles/ShadowStyle.swift new file mode 100644 index 00000000..dbcd20e2 --- /dev/null +++ b/Sources/KlaviyoUI/Styles/ShadowStyle.swift @@ -0,0 +1,23 @@ +// +// ShadowStyle.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/28/24. +// + +import UIKit + +public struct ShadowStyle { + let color: CGColor + let opacity: Float + let offset: CGSize + let radius: CGFloat +} + +extension ShadowStyle { + public static var `default`: Self = .init( + color: UIColor.black.cgColor, + opacity: 0.5, + offset: CGSize(width: 5, height: 5), + radius: 8) +} diff --git a/Sources/KlaviyoUI/Utilities/UIView+Ext.swift b/Sources/KlaviyoUI/Utilities/UIView+Ext.swift new file mode 100644 index 00000000..f0a0c820 --- /dev/null +++ b/Sources/KlaviyoUI/Utilities/UIView+Ext.swift @@ -0,0 +1,36 @@ +// +// UIView+Ext.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/25/24. +// + +import UIKit + +extension UIView { + /// Pins the view to the edges of the parent view. + /// - Parameter parentView: The parent view to pin this view to. + func pin(to parentView: UIView) { + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + leadingAnchor.constraint(equalTo: parentView.leadingAnchor), + trailingAnchor.constraint(equalTo: parentView.trailingAnchor), + topAnchor.constraint(equalTo: parentView.topAnchor), + bottomAnchor.constraint(equalTo: parentView.bottomAnchor) + ]) + } + + /// Pins the view to the edges of the given safe area with optional insets. + /// - Parameters: + /// - safeArea: The UILayoutGuide (usually `view.safeAreaLayoutGuide`) to pin the view to. + /// - insets: Optional insets for each side, default is `.zero`. + func pin(to safeArea: UILayoutGuide, insets: NSDirectionalEdgeInsets = .zero) { + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + topAnchor.constraint(equalTo: safeArea.topAnchor, constant: insets.top), + leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: insets.leading), + bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -insets.bottom), + trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -insets.trailing) + ]) + } +}