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 22, 2024
1 parent 4d63f69 commit e79e89c
Show file tree
Hide file tree
Showing 8 changed files with 346 additions and 193 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
5 changes: 4 additions & 1 deletion Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ protocol GistInstance: AutoMockable {
}

public class Gist: GistInstance, GistDelegate {
var messageQueueManager: MessageQueueManager = DIGraphShared.shared.messageQueueManager
var shownModalMessageQueueIds: Set<String> = [] // all modal messages that have been shown in the app already.
var messageQueueManager: MessageQueueManager {
DIGraphShared.shared.messageQueueManager
}

private var messageManagers: [ModalMessageManager] = []
public var siteId: String = ""
public var dataCenter: String = ""
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 {
@Atomic var interval: Double = 600
@Atomic 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 and inline messages.
Expand All @@ -30,6 +32,14 @@ class MessageQueueManagerImpl: MessageQueueManager {
setup(skipQueueCheck: false)
}

func getInterval() -> Double {
interval

Check warning on line 36 in Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift#L35-L36

Added lines #L35 - L36 were not covered by tests
}

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

Check warning on line 40 in Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift#L39-L40

Added lines #L39 - L40 were not covered by tests
}

func setup(skipQueueCheck: Bool) {
queueTimer?.invalidate()
queueTimer = nil
Expand Down
7 changes: 4 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 @@ -55,10 +56,10 @@ class QueueManager {
private func updatePollingInterval(headers: [AnyHashable: Any]) {
if let newPollingIntervalString = headers["x-gist-queue-polling-interval"] as? String,
let newPollingInterval = Double(newPollingIntervalString),
newPollingInterval != Gist.shared.messageQueueManager.interval {
newPollingInterval != DIGraphShared.shared.messageQueueManager.getInterval() {

Check warning on line 59 in Sources/MessagingInApp/Gist/Managers/QueueManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/QueueManager.swift#L59

Added line #L59 was not covered by tests
DispatchQueue.main.async {
Gist.shared.messageQueueManager.interval = newPollingInterval
Gist.shared.messageQueueManager.setup(skipQueueCheck: true)
DIGraphShared.shared.messageQueueManager.setInterval(newPollingInterval)
DIGraphShared.shared.messageQueueManager.setup(skipQueueCheck: true)

Check warning on line 62 in Sources/MessagingInApp/Gist/Managers/QueueManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/QueueManager.swift#L61-L62

Added lines #L61 - L62 were not covered by tests
Logger.instance.info(message: "Polling interval changed to: \(newPollingInterval) seconds")
}
}
Expand Down
47 changes: 42 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

Check warning on line 42 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L41-L42

Added lines #L41 - L42 were not covered by tests
}
set {
heightConstraint.constant = newValue
layoutIfNeeded()

Check warning on line 46 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L44-L46

Added lines #L44 - L46 were not covered by tests
}
}

private var inlineMessageManager: InlineMessageManager?

public init(elementId: String) {
Expand All @@ -53,11 +66,25 @@ 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() {
// 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 where existingViewConstraint.firstAnchor == heightAnchor {
existingViewConstraint.isActive = false

Check warning on line 74 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L74

Added line #L74 was not covered by tests
}

// 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

Check warning on line 89 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L89

Added line #L89 was not covered by tests
}
Expand All @@ -68,7 +95,6 @@ public class InAppMessageView: UIView {
}

displayInAppMessage(messageToDisplay)
// }
}

private func displayInAppMessage(_ message: Message) {
Expand Down Expand Up @@ -96,6 +122,15 @@ public class InAppMessageView: UIView {
webView.delegate = self
addSubview(webView)

// Setup the WebView to be the same size as this View. When this View changes size, the WebView will change, too.
webView.translatesAutoresizingMaskIntoConstraints = false // Required in order for this inline View to have full control over the AutoLayout constraints for the WebView.
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: topAnchor),
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
webView.bottomAnchor.constraint(equalTo: bottomAnchor)
])

inlineMessageManager = newInlineMessageManager
}
}
Expand All @@ -106,7 +141,9 @@ extension InAppMessageView: GistViewDelegate {
// When this function is called, it's an indicator that the web content has been loaded into the WebView. Report to the manager that we are done loading the message.
inlineMessageManager?.doneLoadingMessage()

Check warning on line 142 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L140-L142

Added lines #L140 - L142 were not covered by tests

// In a future commit, we will change the height of the View to display the web content.
// 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

Check warning on line 146 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L144-L146

Added lines #L144 - L146 were not covered by tests
}

public func action(message: Message, currentRoute: String, action: String, name: String) {}

Check warning on line 149 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L149

Added line #L149 was not covered by tests
Expand Down
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 e79e89c

Please sign in to comment.