Skip to content

Commit

Permalink
chore: add AutoLayout constraints to make inline messages visible in …
Browse files Browse the repository at this point in the history
…UIKit app

Part of: https://linear.app/customerio/issue/MBL-310/create-a-uikit-uiview-that-displays-an-inline-in-app-message-sent-from

This commit implements UIKit AutoLayout constraints to the Inline View and the WebView. Adding these constraints make inline messages visible inside of UIKit apps! The Inline View will start at height 0 and then after the webview content is loaded, it will instantly change the height to display the full in-app message.

Testing:
All testing is done manually in sample apps. AutoLayout constraints change the visuals of Views so viewing how the View looks in an app is required to test.

If you want to test this commit in a sample app build, follow these instructions:
* Open the UIKit app on device.
* Immediately after the app opens, send yourself a test in-app message with element ID "test".
* Wait 10 seconds. In this time, the in-app SDK will fetch the test in-app message and then will display it inline on the screen.

commit-id:5ab2c403
  • Loading branch information
levibostian committed May 17, 2024
1 parent f506cd0 commit d783dc5
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 199 deletions.
327 changes: 185 additions & 142 deletions Apps/APN-UIKit/APN UIKit/Base.lproj/Main.storyboard

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CioDataPipelines
import CioMessagingInApp
import UIKit

class DashboardViewController: BaseViewController {
Expand All @@ -15,6 +16,8 @@ class DashboardViewController: BaseViewController {
@IBOutlet var versionsLabel: UILabel!
@IBOutlet var userInfoLabel: UILabel!
@IBOutlet var settings: UIImageView!
@IBOutlet var inlineInAppView: InAppMessageView!

var dashboardRouter: DashboardRouting?
var notificationUtil = DIGraphShared.shared.notificationUtil
var storage = DIGraphShared.shared.storage
Expand All @@ -28,6 +31,26 @@ class DashboardViewController: BaseViewController {

override func viewDidLoad() {
super.viewDidLoad()

// In a future PR, we will remove the asyncAfter(). this is only for testing in sample apps because when app opens, the local queue is empty. so wait to check messages until first fetch is done.
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
inlineInAppView.elementId = "dashboard-announcement"

// We want to test that Inline Views can be used by customers who prefer to use code to make the UI.
// Construct a new instance of the View, add it to the ViewController, then set constraints to make it visible.
let newInlineViewUsingUIAsCode = InAppMessageView(elementId: "dashboard-announcement-code")
// Add the View to the screen.
view.addSubview(newInlineViewUsingUIAsCode)

// We are adding the new View between 2 Buttons in the StackView. No specific reason why, not sure where else to put it.
newInlineViewUsingUIAsCode.widthAnchor.constraint(equalToConstant: UIScreen.main.bounds.width).isActive = true
newInlineViewUsingUIAsCode.topAnchor.constraint(equalTo: randomEventButton.bottomAnchor).isActive = true
newInlineViewUsingUIAsCode.bottomAnchor.constraint(equalTo: customEventButton.topAnchor).isActive = true
newInlineViewUsingUIAsCode.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
newInlineViewUsingUIAsCode.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
newInlineViewUsingUIAsCode.centerXAnchor.constraint(equalTo: randomEventButton.centerXAnchor).isActive = true
}

configureDashboardRouter()
addNotifierObserver()
addUserInteractionToImageViews()
Expand Down
11 changes: 4 additions & 7 deletions Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ protocol GistInstance: AutoMockable {
}

public class Gist: GistInstance, GistDelegate {
var messageQueueManager: MessageQueueManager = DIGraphShared.shared.messageQueueManager
var messageQueueManager: MessageQueueManager {
DIGraphShared.shared.messageQueueManager
}

var shownMessageQueueIds: Set<String> = []
private var messageManagers: [ModalMessageManager] = []
public var siteId: String = ""
Expand Down Expand Up @@ -156,12 +159,6 @@ public class Gist: GistInstance, GistDelegate {
func removeMessageManager(instanceId: String) {
messageManagers.removeAll(where: { $0.currentMessage.instanceId == instanceId })
}

// Inline messages

func getInlineMessages(forElementId elementId: String) -> [Message] {
messageQueueManager.getInlineMessages(forElementId: elementId)
}
}

// Convenient way for other modules to access instance as well as being able to mock instance in tests.
Expand Down
14 changes: 12 additions & 2 deletions Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import Foundation
import UIKit

protocol MessageQueueManager: AutoMockable {
var interval: Double { get set }
func getInterval() -> Double
func setInterval(_ newInterval: Double)
func setup()
// sourcery:Name=setupSkipQueueCheck
// sourcery:DuplicateMethod=setup
Expand All @@ -15,8 +16,9 @@ protocol MessageQueueManager: AutoMockable {
}

// sourcery: InjectRegisterShared = "MessageQueueManager"
// sourcery: InjectSingleton
class MessageQueueManagerImpl: MessageQueueManager {
var interval: Double = 600
private var interval: Double = 600
private var queueTimer: Timer?
// The local message store is used to keep messages that can't be displayed because the route rule doesnt match.
var localMessageStore: [String: Message] = [:]
Expand All @@ -28,6 +30,14 @@ class MessageQueueManagerImpl: MessageQueueManager {
setup(skipQueueCheck: false)
}

func getInterval() -> Double {
interval
}

func setInterval(_ newInterval: Double) {
interval = newInterval
}

func setup(skipQueueCheck: Bool) {
queueTimer?.invalidate()
queueTimer = nil
Expand Down
9 changes: 6 additions & 3 deletions Sources/MessagingInApp/Gist/Managers/QueueManager.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CioInternalCommon
import Foundation

class QueueManager {
Expand Down Expand Up @@ -53,12 +54,14 @@ class QueueManager {
}

private func updatePollingInterval(headers: [AnyHashable: Any]) {
let messageQueueManager = DIGraphShared.shared.messageQueueManager

if let newPollingIntervalString = headers["x-gist-queue-polling-interval"] as? String,
let newPollingInterval = Double(newPollingIntervalString),
newPollingInterval != Gist.shared.messageQueueManager.interval {
newPollingInterval != messageQueueManager.getInterval() {
DispatchQueue.main.async {
Gist.shared.messageQueueManager.interval = newPollingInterval
Gist.shared.messageQueueManager.setup(skipQueueCheck: true)
messageQueueManager.setInterval(newPollingInterval)
messageQueueManager.setup(skipQueueCheck: true)
Logger.instance.info(message: "Polling interval changed to: \(newPollingInterval) seconds")
}
}
Expand Down
54 changes: 49 additions & 5 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ public class InAppMessageView: UIView {
}
}

var heightConstraint: NSLayoutConstraint!

// Get the View's current height or change the height by setting a new value.
private var viewHeight: CGFloat {
get {
heightConstraint.constant
}
set {
heightConstraint.constant = newValue
layoutIfNeeded()
}
}

private var inlineMessageManager: InlineMessageManager?

public init(elementId: String) {
Expand All @@ -53,11 +66,30 @@ public class InAppMessageView: UIView {
// An element id will not be set yet. No need to check for messages to display.
}

private func setupView() {}
private func setupView() {
// Enable auto layout constraints
translatesAutoresizingMaskIntoConstraints = false

// Remove any existing height constraints added by customer.
// This is required as only 1 height constraint can be active at a time. Our height constraint will be ignored
// if we do not do this.
for existingViewConstraint in constraints {
if existingViewConstraint.firstAnchor == heightAnchor {
existingViewConstraint.isActive = false
}
}

// Create a view constraint for the height of the View.
// This allows us to dynamically update the height at a later time.
//
// Set the initial height of the view to 0 so it's not visible.
heightConstraint = heightAnchor.constraint(equalToConstant: 0)
heightConstraint.priority = .required
heightConstraint.isActive = true
layoutIfNeeded()
}

private func checkIfMessageAvailableToDisplay() {
// In a future PR, we will remove the asyncAfter(). this is only for testing in sample apps because when app opens, the local queue is empty. so wait to check messages until first fetch is done.
// DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
guard let elementId = elementId else {
return
}
Expand All @@ -68,7 +100,6 @@ public class InAppMessageView: UIView {
}

displayInAppMessage(messageToDisplay)
// }
}

private func displayInAppMessage(_ message: Message) {
Expand All @@ -88,13 +119,26 @@ public class InAppMessageView: UIView {
webView.delegate = self
addSubview(webView)

webView.translatesAutoresizingMaskIntoConstraints = false
// Setup the WebView to be the same size as this View. When this View changes size, the WebView will change, too.
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: topAnchor),
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
webView.bottomAnchor.constraint(equalTo: bottomAnchor)
])

inlineMessageManager = newInlineMessageManager
}
}

extension InAppMessageView: GistViewDelegate {
// This function is called by WebView when the content's size changes.
public func sizeChanged(message: Message, width: CGFloat, height: CGFloat) {}
public func sizeChanged(message: Message, width: CGFloat, height: CGFloat) {
// We keep the width the same to what the customer set it as.
// Update the height to match the aspect ratio of the web content.
viewHeight = height
}

public func action(message: Message, currentRoute: String, action: String, name: String) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,27 @@ extension DIGraphShared {
GistInAppProvider()
}

// MessageQueueManager
// MessageQueueManager (singleton)
var messageQueueManager: MessageQueueManager {
getOverriddenInstance() ??
newMessageQueueManager
sharedMessageQueueManager
}

private var newMessageQueueManager: MessageQueueManager {
var sharedMessageQueueManager: MessageQueueManager {
// Use a DispatchQueue to make singleton thread safe. You must create unique dispatchqueues instead of using 1 shared one or you will get a crash when trying
// to call DispatchQueue.sync{} while already inside another DispatchQueue.sync{} call.
DispatchQueue(label: "DIGraphShared_MessageQueueManager_singleton_access").sync {
if let overridenDep: MessageQueueManager = getOverriddenInstance() {
return overridenDep
}
let existingSingletonInstance = self.singletons[String(describing: MessageQueueManager.self)] as? MessageQueueManager
let instance = existingSingletonInstance ?? _get_messageQueueManager()
self.singletons[String(describing: MessageQueueManager.self)] = instance
return instance
}
}

private func _get_messageQueueManager() -> MessageQueueManager {
MessageQueueManagerImpl()
}
}
Expand Down
96 changes: 59 additions & 37 deletions Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -498,45 +498,15 @@ class MessageQueueManagerMock: MessageQueueManager, Mock {
Mocks.shared.add(mock: self)
}

/**
When setter of the property called, the value given to setter is set here.
When the getter of the property called, the value set here will be returned. Your chance to mock the property.
*/
var underlyingInterval: Double!
/// `true` if the getter or setter of property is called at least once.
var intervalCalled: Bool {
intervalGetCalled || intervalSetCalled
}

/// `true` if the getter called on the property at least once.
var intervalGetCalled: Bool {
intervalGetCallsCount > 0
}

var intervalGetCallsCount = 0
/// `true` if the setter called on the property at least once.
var intervalSetCalled: Bool {
intervalSetCallsCount > 0
}
public func resetMock() {
getIntervalCallsCount = 0

var intervalSetCallsCount = 0
/// The mocked property with a getter and setter.
var interval: Double {
get {
mockCalled = true
intervalGetCallsCount += 1
return underlyingInterval
}
set(value) {
mockCalled = true
intervalSetCallsCount += 1
underlyingInterval = value
}
}
mockCalled = false // do last as resetting properties above can make this true
setIntervalCallsCount = 0
setIntervalReceivedArguments = nil
setIntervalReceivedInvocations = []

public func resetMock() {
intervalGetCallsCount = 0
intervalSetCallsCount = 0
mockCalled = false // do last as resetting properties above can make this true
setupCallsCount = 0

mockCalled = false // do last as resetting properties above can make this true
Expand All @@ -562,6 +532,58 @@ class MessageQueueManagerMock: MessageQueueManager, Mock {
mockCalled = false // do last as resetting properties above can make this true
}

// MARK: - getInterval

/// Number of times the function was called.
@Atomic private(set) var getIntervalCallsCount = 0
/// `true` if the function was ever called.
var getIntervalCalled: Bool {
getIntervalCallsCount > 0
}

/// Value to return from the mocked function.
var getIntervalReturnValue: Double!
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
The closure has first priority to return a value for the mocked function. If the closure returns `nil`,
then the mock will attempt to return the value for `getIntervalReturnValue`
*/
var getIntervalClosure: (() -> Double)?

/// Mocked function for `getInterval()`. Your opportunity to return a mocked value and check result of mock in test code.
func getInterval() -> Double {
mockCalled = true
getIntervalCallsCount += 1
return getIntervalClosure.map { $0() } ?? getIntervalReturnValue
}

// MARK: - setInterval

/// Number of times the function was called.
@Atomic private(set) var setIntervalCallsCount = 0
/// `true` if the function was ever called.
var setIntervalCalled: Bool {
setIntervalCallsCount > 0
}

/// The arguments from the *last* time the function was called.
@Atomic private(set) var setIntervalReceivedArguments: Double?
/// Arguments from *all* of the times that the function was called.
@Atomic private(set) var setIntervalReceivedInvocations: [Double] = []
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
*/
var setIntervalClosure: ((Double) -> Void)?

/// Mocked function for `setInterval(_ newInterval: Double)`. Your opportunity to return a mocked value and check result of mock in test code.
func setInterval(_ newInterval: Double) {
mockCalled = true
setIntervalCallsCount += 1
setIntervalReceivedArguments = newInterval
setIntervalReceivedInvocations.append(newInterval)
setIntervalClosure?(newInterval)
}

// MARK: - setup

/// Number of times the function was called.
Expand Down

0 comments on commit d783dc5

Please sign in to comment.