Skip to content

Commit

Permalink
chore: dismiss message if message expires (#733)
Browse files Browse the repository at this point in the history
  • Loading branch information
levibostian authored Jun 27, 2024
1 parent 2275ca5 commit 4587e57
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 55 deletions.
16 changes: 16 additions & 0 deletions Sources/MessagingInApp/Extensions/UIViewExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,20 @@ extension UIView {
}
}
}

var heightConstraints: [NSLayoutConstraint] {
constraints.filter { $0.firstAnchor == heightAnchor }
}

var widthConstraints: [NSLayoutConstraint] {
constraints.filter { $0.firstAnchor == widthAnchor }
}

var heightConstraint: NSLayoutConstraint? {
heightConstraints.first
}

var widthConstraint: NSLayoutConstraint? {
widthConstraints.first
}
}
89 changes: 52 additions & 37 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ public class InAppMessageView: UIView {

var runningHeightChangeAnimation: UIViewPropertyAnimator?

// Get the height constraint for the View. Convenient to modify the height of the View.
var viewHeightConstraint: NSLayoutConstraint? {
constraints.first { $0.firstAnchor == heightAnchor }
}

private var inlineMessageManager: InlineMessageManager?

public init(elementId: String) {
Expand All @@ -65,16 +60,18 @@ public class InAppMessageView: UIView {
}

private func setupView() {
// Customer did not set a height constraint. Create one so the View has one.
// It's important to have only 1 active constraint for height or UIKit will ignore some constraints.
// Try to re-use a constraint if one is already added instead of replacing it. Some scenarios such as
// when UIView is nested in a UIStackView and distribution is .fillProportionally, the height constraint StackView adds is important to keep.
if viewHeightConstraint == nil {
heightAnchor.constraint(equalToConstant: 0).isActive = true

if heightConstraint == nil {
// Customer did not set a height constraint. Create one so the View has one.
let heightConstraint = heightAnchor.constraint(equalToConstant: 0)
heightConstraint.priority = .required // in case a customer sets a height constraint, by us setting the highest priority, we try to have this constraint be used.
heightConstraint.isActive = true // set isActive as the last step.
}

viewHeightConstraint?.priority = .required
viewHeightConstraint?.constant = 0 // start at height 0 so the View does not show.
heightConstraint?.constant = 0 // start at height 0 so the View does not show.
getRootSuperview()?.layoutIfNeeded() // Since we modified constraint, perform a UI refresh to apply the change.

// Begin listening to the queue for new messages.
Expand All @@ -93,19 +90,21 @@ public class InAppMessageView: UIView {
}

let queueOfMessagesForGivenElementId = localMessageQueue.getInlineMessages(forElementId: elementId)
guard let messageToDisplay = queueOfMessagesForGivenElementId.first else {
return // no messages to display, exit early. In the future we will dismiss the View.
let messageToDisplay = queueOfMessagesForGivenElementId.first

if let messageToDisplay {
displayInAppMessage(messageToDisplay)
} else {
dismissInAppMessage()
}
}

private func displayInAppMessage(_ message: Message) {
// Do not re-show the existing message if already shown to prevent the UI from flickering as it loads the same message again.
if let currentlyShownMessage = inlineMessageManager?.currentMessage, currentlyShownMessage.queueId == messageToDisplay.queueId {
if let currentlyShownMessage = inlineMessageManager?.currentMessage, currentlyShownMessage.queueId == message.queueId {
return // already showing this message, exit early.
}

displayInAppMessage(messageToDisplay)
}

private func displayInAppMessage(_ message: Message) {
guard inlineMessageManager == nil else {
// We are already displaying a messsage. In the future, we are planning on swapping the web content if there is another message in the local queue to display
// and an inline message is dismissed. Until we add this feature, exit early.
Expand All @@ -132,32 +131,48 @@ public class InAppMessageView: UIView {

inlineMessageManager = newInlineMessageManager
}

private func dismissInAppMessage() {
// If this function gets called a lot in a short amount of time (eventbus triggers multiple events), the dismiss animation does not look as expected.
// To fix this, exit early if dimiss has already been triggered.
if inlineMessageManager?.inlineMessageDelegate == nil {
return
}

inlineMessageManager?.inlineMessageDelegate = nil // remove the delegate to prevent any further callbacks from the WebView. If delegate events continue to come, this could cancel the dismiss animation and stop the dismiss action.

animateHeight(to: 0)
}

private func animateHeight(to height: CGFloat) {
// this function can be called multiple times in short period of time so we could be in the middle of 1 animation. Cancel the current one and start new.
runningHeightChangeAnimation?.stopAnimation(true)

runningHeightChangeAnimation = UIViewPropertyAnimator(duration: 0.3, curve: .easeIn, animations: {
self.heightConstraint?.constant = height // Changing the height in animation block indicates we want to animate the height change.

// Since we modified constraint, perform a UI refresh to apply the change.
// It's important that we call layoutIfNeeded on the topmost superview in the hierarchy. During development, there were animiation issues if layoutIfNeeded was called on a different superview then the root.
// Example, given this UI:
// UIViewController
// └── UIStackView
// └── InAppMessageView
// ...If we call layoutIfNeeded on superview (UIStackView), the animation will not work as expected.
// This is also why it's important that we do QA testing on the inline View when it's nested in a UIStackView.
self.getRootSuperview()?.layoutIfNeeded()
})

runningHeightChangeAnimation?.startAnimation()
}
}

extension InAppMessageView: InlineMessageManagerDelegate {
// This function is called by WebView when the content's size changes.
public func sizeChanged(width: CGFloat, height: CGFloat) {
Task { @MainActor in // only update UI on main thread. This delegate function may not get called from UI thread.
// this function can be called multiple times in short period of time so we could be in the middle of 1 animation. Cancel the current one and start new.
runningHeightChangeAnimation?.stopAnimation(true)

runningHeightChangeAnimation = UIViewPropertyAnimator(duration: 0.3, curve: .easeIn, animations: {
// 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.
self.viewHeightConstraint?.constant = height // Changing the height in animation block indicates we want to animate the height change.

// Since we modified constraint, perform a UI refresh to apply the change.
// It's important that we call layoutIfNeeded on the topmost superview in the hierarchy. During development, there were animiation issues if layoutIfNeeded was called on a different superview then the root.
// Example, given this UI:
// UIViewController
// └── UIStackView
// └── InAppMessageView
// ...If we call layoutIfNeeded on superview (UIStackView), the animation will not work as expected.
// This is also why it's important that we do QA testing on the inline View when it's nested in a UIStackView.
self.getRootSuperview()?.layoutIfNeeded()
})

runningHeightChangeAnimation?.startAnimation()
// 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.
self.animateHeight(to: height)
}
}
}
56 changes: 56 additions & 0 deletions Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,14 @@ class MessageQueueManagerMock: MessageQueueManager, Mock {
}

public func resetMock() {
resetCallsCount = 0

mockCalled = false // do last as resetting properties above can make this true
processFetchedMessagesCallsCount = 0
processFetchedMessagesReceivedArguments = nil
processFetchedMessagesReceivedInvocations = []

mockCalled = false // do last as resetting properties above can make this true
getIntervalCallsCount = 0

mockCalled = false // do last as resetting properties above can make this true
Expand Down Expand Up @@ -651,6 +659,54 @@ class MessageQueueManagerMock: MessageQueueManager, Mock {
mockCalled = false // do last as resetting properties above can make this true
}

// MARK: - reset

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

/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
*/
var resetClosure: (() -> Void)?

/// Mocked function for `reset()`. Your opportunity to return a mocked value and check result of mock in test code.
func reset() {
mockCalled = true
resetCallsCount += 1
resetClosure?()
}

// MARK: - processFetchedMessages

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

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

/// Mocked function for `processFetchedMessages(_ fetchedMessages: [Message])`. Your opportunity to return a mocked value and check result of mock in test code.
func processFetchedMessages(_ fetchedMessages: [Message]) {
mockCalled = true
processFetchedMessagesCallsCount += 1
processFetchedMessagesReceivedArguments = fetchedMessages
processFetchedMessagesReceivedInvocations.append(fetchedMessages)
processFetchedMessagesClosure?(fetchedMessages)
}

// MARK: - getInterval

/// Number of times the function was called.
Expand Down
1 change: 1 addition & 0 deletions Tests/MessagingInApp/MessagingInAppIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ extension MessagingInAppIntegrationTest {
}

func onDoneFetching(messages: [Message]) {
// swiftlint:disable:next force_cast
(Gist.shared.messageQueueManager as! MessageQueueManagerImpl).processFetchedMessages(messages)
}

Expand Down
Loading

0 comments on commit 4587e57

Please sign in to comment.