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 28, 2024
1 parent 5581d89 commit 183e553
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 192 deletions.
329 changes: 187 additions & 142 deletions Apps/APN-UIKit/APN UIKit/Base.lproj/Main.storyboard

Large diffs are not rendered by default.

21 changes: 21 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,9 @@ class DashboardViewController: BaseViewController {
@IBOutlet var versionsLabel: UILabel!
@IBOutlet var userInfoLabel: UILabel!
@IBOutlet var settings: UIImageView!
@IBOutlet var inlineInAppViewCreatedInStoryboard: InAppMessageView!
@IBOutlet var buttonStackView: UIStackView!

var dashboardRouter: DashboardRouting?
var notificationUtil = DIGraphShared.shared.notificationUtil
var storage = DIGraphShared.shared.storage
Expand All @@ -28,6 +32,23 @@ 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
inlineInAppViewCreatedInStoryboard.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.
// It's important that we test inline Views that are nested in a UIStackView. See comments in inline View code to learn more.
buttonStackView.addArrangedSubview(newInlineViewUsingUIAsCode)

// Customers are responsible for setting the width of the View.
newInlineViewUsingUIAsCode.translatesAutoresizingMaskIntoConstraints = false
newInlineViewUsingUIAsCode.widthAnchor.constraint(equalTo: buttonStackView.widthAnchor).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(skipQueueCheck: Bool)
func fetchUserMessagesFromLocalStore()
func removeMessageFromLocalStore(message: Message)
Expand All @@ -12,8 +13,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 @@ -23,6 +25,14 @@ class MessageQueueManagerImpl: MessageQueueManager {
DIGraphShared.shared.gist
}

func getInterval() -> Double {
interval

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift#L28-L29

Added lines #L28 - L29 were not covered by tests
}

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

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift#L32-L33

Added lines #L32 - L33 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
44 changes: 40 additions & 4 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 @@ -54,12 +67,24 @@ public class InAppMessageView: UIView {
}

private func setupView() {
// next, configure the View such as setting the position and size. This will come in a future change.
// 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 Down Expand Up @@ -88,13 +113,24 @@ public class InAppMessageView: UIView {
}
addSubview(inlineView)

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

inlineMessageManager = newInlineMessageManager
}
}

extension InAppMessageView: InlineMessageManagerDelegate {
// This function is called by WebView when the content's size changes.
public func sizeChanged(width: CGFloat, height: CGFloat) {
// 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 134 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L131-L134

Added lines #L131 - L134 were not covered by tests
}
}
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
setupReceivedArguments = nil
setupReceivedInvocations = []
Expand All @@ -560,6 +530,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 183e553

Please sign in to comment.