From 4fe2ae4ca1b82dc84d5bd460cfbe7c742dd4401b Mon Sep 17 00:00:00 2001 From: adam1929 <103995671+adam1929@users.noreply.github.com> Date: Thu, 11 Jan 2024 13:42:07 +0100 Subject: [PATCH] In-app Content Block feature to invoke action programatically --- Documentation/IN_APP_CONTENT_BLOCKS.md | 98 ++++++++++++++++++- .../ExampleInAppContentBlockCallback.swift | 39 +++++--- .../InAppContentBlocksViewController.swift | 3 +- .../StaticInAppContentBlockView.swift | 77 +++++++++------ 4 files changed, 172 insertions(+), 45 deletions(-) diff --git a/Documentation/IN_APP_CONTENT_BLOCKS.md b/Documentation/IN_APP_CONTENT_BLOCKS.md index 33e4ad80..03d6bd80 100644 --- a/Documentation/IN_APP_CONTENT_BLOCKS.md +++ b/Documentation/IN_APP_CONTENT_BLOCKS.md @@ -169,4 +169,100 @@ class CustomInAppContentBlockCallback: InAppContentBlockCallbackType { handleUrlByYourApp(action.url) } } -``` \ No newline at end of file +``` + +### Custom presentation of In-app content block + +In case that UI presentation of StaticInAppContentBlockView does not fit UX design of your application (for example customized animations) you may create own UIView element that wraps existing StaticInAppContentBlockView instance. +Setup could differ from your use case but you should keep these 4 principles: + +1. Prepare StaticInAppContentBlockView instance with deferred load: +```swift +class CustomView: UIViewController { + + lazy var placeholder = StaticInAppContentBlockView(placeholder: "placeholder_1", deferredLoad: true) + + override func viewDidLoad() { + super.viewDidLoad() + placeholder.behaviourCallback = CustomBehaviourCallback( + placeholder.behaviourCallback, + placeholder, + self + ) + placeholderView.reload() + } +} +``` +2. Hook your CustomView to listen on In-app Content Block message arrival with customized behaviourCallback: +```swift +class CustomBehaviourCallback: InAppContentBlockCallbackType { + + private let originalBehaviour: InAppContentBlockCallbackType + private let ownerView: StaticInAppContentBlockView + private let viewDelegate: InAppCbViewDelegate + + init( + _ originalBehaviour: InAppContentBlockCallbackType, + _ ownerView: StaticInAppContentBlockView, + _ viewDelegate: InAppCbViewDelegate + ) { + self.originalBehaviour = originalBehaviour + self.ownerView = ownerView + self.viewDelegate = viewDelegate + } + + func onMessageShown(placeholderId: String, contentBlock: ExponeaSDK.InAppContentBlockResponse) { + // Calling originalBehavior tracks 'show' event and opens URL + originalBehaviour.onMessageShown(placeholderId: placeholderId, contentBlock: contentBlock) + viewDelegate.showMessage(contentBlock) + } + + func onNoMessageFound(placeholderId: String) { + viewDelegate.showNoMessage() + } + + func onError(placeholderId: String, contentBlock: ExponeaSDK.InAppContentBlockResponse?, errorMessage: String) { + // Calling originalBehavior tracks 'error' event + originalBehaviour.onError(placeholderId: placeholderId, contentBlock: contentBlock, errorMessage: errorMessage) + viewDelegate.showError() + } + + func onCloseClicked(placeholderId: String, contentBlock: ExponeaSDK.InAppContentBlockResponse) { + // Calling originalBehavior tracks 'close' event + originalBehaviour.onCloseClicked(placeholderId: placeholderId, contentBlock: contentBlock) + viewDelegate.hideMe() + } + + func onActionClicked(placeholderId: String, contentBlock: ExponeaSDK.InAppContentBlockResponse, action: ExponeaSDK.InAppContentBlockAction) { + // Calling originalBehavior tracks 'click' event + originalBehaviour.onActionClicked(placeholderId: placeholderId, contentBlock: contentBlock, action: action) + } +} +``` +3. Show retrieved message in your customized UIView: +```swift +protocol InAppCbViewDelegate { + func showMessage(_ contentBlock: ExponeaSDK.InAppContentBlockResponse) + func showNoMessage() + func showError() + func hideMe() +} + +class CustomView: UIViewController, InAppCbViewDelegate { + /// Update your customized content. + /// This method could be called multiple times for every content block update, especially in case that multiple messages are assigned to given "placeholder_1" ID + func showMessage(_ contentBlock: ExponeaSDK.InAppContentBlockResponse) { + // ... + } +} +``` +4. Invoke clicked action manually. For example if your CustomView contains UIButton that is registered with `addTarget` for action URL and is calling `onMyActionClick` method: +```swift +@objc func onMyActionClick(sender: UIButton) { + // retrieve `actionUrl` from extended UIButton or stored in some private field, it is up to you + let actionUrl = getActionUrl(sender) + placeholder.invokeActionClick(actionUrl: actionUrl) +} +``` + +That is all, now your CustomView will receive all In-app Content Block data. diff --git a/ExponeaSDK/Example/Views/InAppContentBlocks/ExampleInAppContentBlockCallback.swift b/ExponeaSDK/Example/Views/InAppContentBlocks/ExampleInAppContentBlockCallback.swift index ce5c6803..013a7cb6 100644 --- a/ExponeaSDK/Example/Views/InAppContentBlocks/ExampleInAppContentBlockCallback.swift +++ b/ExponeaSDK/Example/Views/InAppContentBlocks/ExampleInAppContentBlockCallback.swift @@ -12,11 +12,14 @@ import ExponeaSDK class ExampleInAppContentBlockCallback: InAppContentBlockCallbackType { private let originalBehaviour: InAppContentBlockCallbackType + private let ownerView: StaticInAppContentBlockView init( - originalBehaviour: InAppContentBlockCallbackType + originalBehaviour: InAppContentBlockCallbackType, + ownerView: StaticInAppContentBlockView ) { self.originalBehaviour = originalBehaviour + self.ownerView = ownerView } func onMessageShown(placeholderId: String, contentBlock: ExponeaSDK.InAppContentBlockResponse) { @@ -52,26 +55,34 @@ class ExampleInAppContentBlockCallback: InAppContentBlockCallbackType { ) } + private var actionClickBounce = false + func onActionClicked(placeholderId: String, contentBlock: ExponeaSDK.InAppContentBlockResponse, action: ExponeaSDK.InAppContentBlockAction) { - Exponea.shared.trackInAppContentBlockClickWithoutTrackingConsent( - placeholderId: placeholderId, - action: action, - message: contentBlock - ) Exponea.logger.log(.verbose, message: "Handling In-app content block action \(action.url ?? "none")") guard let actionUrl = action.url, let url = actionUrl.cleanedURL() else { return } - switch action.type { - case .browser: - UIApplication.shared.open(url, options: [:], completionHandler: nil) - case .deeplink: - if !openUniversalLink(url, application: UIApplication.shared) { - openURLSchemeDeeplink(url, application: UIApplication.shared) + if actionClickBounce { + actionClickBounce = false + Exponea.shared.trackInAppContentBlockClickWithoutTrackingConsent( + placeholderId: placeholderId, + action: action, + message: contentBlock + ) + switch action.type { + case .browser: + UIApplication.shared.open(url, options: [:], completionHandler: nil) + case .deeplink: + if !openUniversalLink(url, application: UIApplication.shared) { + openURLSchemeDeeplink(url, application: UIApplication.shared) + } + case .close: + Exponea.logger.log(.error, message: "In-app content block close has to be handled elsewhere") } - case .close: - Exponea.logger.log(.error, message: "In-app content block close has to be handled elsewhere") + } else { + actionClickBounce = true + ownerView.invokeActionClick(actionUrl: actionUrl) } } diff --git a/ExponeaSDK/Example/Views/InAppContentBlocks/InAppContentBlocksViewController.swift b/ExponeaSDK/Example/Views/InAppContentBlocks/InAppContentBlocksViewController.swift index 3d8b16a1..c4d5db6f 100644 --- a/ExponeaSDK/Example/Views/InAppContentBlocks/InAppContentBlocksViewController.swift +++ b/ExponeaSDK/Example/Views/InAppContentBlocks/InAppContentBlocksViewController.swift @@ -169,7 +169,8 @@ class InAppContentBlocksViewController: UIViewController, UITableViewDelegate, U } } let origBehaviour = placeholder.behaviourCallback - placeholder.behaviourCallback = ExampleInAppContentBlockCallback(originalBehaviour: origBehaviour) + placeholder.behaviourCallback = ExampleInAppContentBlockCallback(originalBehaviour: origBehaviour, ownerView: placeholder + ) placeholder.translatesAutoresizingMaskIntoConstraints = false placeholder.topAnchor.constraint(equalTo: view.topAnchor, constant: 80).isActive = true placeholder.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10).isActive = true diff --git a/ExponeaSDK/ExponeaSDK/Classes/InAppContentBlocks/StaticInAppContentBlockView.swift b/ExponeaSDK/ExponeaSDK/Classes/InAppContentBlocks/StaticInAppContentBlockView.swift index c8e379f3..9d99f3d8 100644 --- a/ExponeaSDK/ExponeaSDK/Classes/InAppContentBlocks/StaticInAppContentBlockView.swift +++ b/ExponeaSDK/ExponeaSDK/Classes/InAppContentBlocks/StaticInAppContentBlockView.swift @@ -179,7 +179,50 @@ public final class StaticInAppContentBlockView: UIView, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - let result = inAppContentBlocksManager.inAppContentBlockMessages.first(where: { $0.tags?.contains(webView.tag) == true }) + let handled = handleUrlClick(navigationAction.request.url) + decisionHandler(handled ? .cancel : .allow) + } + + private func determineActionType(_ action: ActionInfo) -> InAppContentBlockActionType { + if action.actionUrl == "https://exponea.com/close_action" { + return .close + } + if action.actionUrl.starts(with: "http://") || action.actionUrl.starts(with: "https://") { + return .browser + } + return .deeplink + } + + // directly calls `contentReadyCompletion` with given contentReady flag + // use this method in case that no layout is going to be invoked + private func notifyContentReadyState(_ contentReady: Bool) { + onMain { + self.contentReadyCompletion?(contentReady) + } + } + + // registers contentReady flag that will be used for `contentReadyCompletion` when layout/bounds will be updated + private func prepareContentReadyState(_ contentReady: Bool) { + contentReadyFlag = contentReady + } + + public func invokeActionClick(actionUrl: String) { + Exponea.logger.log(.verbose, message: "InAppCB: Manual action \(actionUrl) invoked on placeholder \(placeholder)") + _ = handleUrlClick(actionUrl.cleanedURL()) + } + + private func handleUrlClick(_ actionUrl: URL?) -> Bool { + guard let actionUrl else { + Exponea.logger.log(.warning, message: "InAppCB: Unknown action URL: \(String(describing: actionUrl))") + return false + } + guard let message = assignedMessage else { + Exponea.logger.log(.error, message: "InAppCB: Placeholder \(placeholder) has invalid state - action or message is invalid") + behaviourCallback.onError(placeholderId: placeholder, contentBlock: nil, errorMessage: "Invalid action definition") + // webView has to stop navigation, missing message data are internal issue + return true + } + let result = inAppContentBlocksManager.inAppContentBlockMessages.first(where: { $0.tags?.contains(webview.tag) == true }) let webAction: WebActionManager = .init { let indexOfMessage: Int = self.inAppContentBlocksManager.inAppContentBlockMessages.firstIndex(where: { $0.id == result?.id ?? "" }) ?? 0 let currentDisplay = self.inAppContentBlocksManager.inAppContentBlockMessages[indexOfMessage].displayState @@ -213,36 +256,12 @@ public final class StaticInAppContentBlockView: UIView, WKNavigationDelegate { Exponea.logger.log(.error, message: "WebActionManager error \(error.localizedDescription)") } webAction.htmlPayload = result?.personalizedMessage?.htmlPayload - let handled = webAction.handleActionClick(navigationAction.request.url) + let handled = webAction.handleActionClick(actionUrl) if handled { - Exponea.logger.log(.verbose, message: "[HTML] Action \(navigationAction.request.url?.absoluteString ?? "Invalid") has been handled") - decisionHandler(.cancel) + Exponea.logger.log(.verbose, message: "[HTML] Action \(actionUrl.absoluteString) has been handled") } else { - Exponea.logger.log(.verbose, message: "[HTML] Action \(navigationAction.request.url?.absoluteString ?? "Invalid") has not been handled, continue") - decisionHandler(.allow) - } - } - - private func determineActionType(_ action: ActionInfo) -> InAppContentBlockActionType { - if action.actionUrl == "https://exponea.com/close_action" { - return .close - } - if action.actionUrl.starts(with: "http://") || action.actionUrl.starts(with: "https://") { - return .browser - } - return .deeplink - } - - // directly calls `contentReadyCompletion` with given contentReady flag - // use this method in case that no layout is going to be invoked - private func notifyContentReadyState(_ contentReady: Bool) { - onMain { - self.contentReadyCompletion?(contentReady) + Exponea.logger.log(.verbose, message: "[HTML] Action \(actionUrl.absoluteString) has not been handled, continue") } - } - - // registers contentReady flag that will be used for `contentReadyCompletion` when layout/bounds will be updated - private func prepareContentReadyState(_ contentReady: Bool) { - contentReadyFlag = contentReady + return handled } }