From 434d587ea3ea79ee25905faf49c485501145f491 Mon Sep 17 00:00:00 2001 From: Levi Bostian Date: Wed, 22 May 2024 15:33:19 -0500 Subject: [PATCH] chore: when in-app web content rendered, animate visibility of inline View Part of: https://linear.app/customerio/issue/MBL-311/provide-positive-ux-when-inline-messages-displayed-by-animating-height Fetching of in-app message is an async operation running in the background. There is a chance that an inline in-app message is rendered and ready to display on the screen currently in the foreground. We want to display the inline message when it's ready to show, and we also want to display these messages without causing a negative UX. This commit does the following: 1. Hides the inline View when the View is constructed by setting the height to 0. 2. When the in-app message web content is rendered, the inline View is given a height that matches the web content aspect ratio. The inline View animates it's height from 0 to the new height to make the inline View visible. Testing: This change is heavy on UI so testing is done mostly in UIKit sample app QA testing. If you want to test this commit in a sample app build, follow these instructions: 1. Open the app on device. 2. Send yourself a test inline message from Fly to one of the inline Views on Dashboard screen. 3. After you wait a few seconds, you will see the inline message animate into visibility. Notes for reviewer: * During development, I encountered difficulties with getting the animation to work when the inline View is embedded in a StackView. Because of this, I modified the Dashboard screen's UI to nest the inline Views inside of the StackView to make sure we QA test this scenario. --- .../View/DashboardViewController.swift | 4 +- .../Extensions/UIViewExtensions.swift | 19 ++++++ .../Views/InAppMessageView.swift | 64 +++++++++++-------- .../Extensions/UIViewExtensionsTests.swift | 30 +++++++++ 4 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 Sources/MessagingInApp/Extensions/UIViewExtensions.swift create mode 100644 Tests/MessagingInApp/Extensions/UIViewExtensionsTests.swift diff --git a/Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift b/Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift index 642575874..03a6ed2fd 100644 --- a/Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift +++ b/Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift @@ -39,8 +39,8 @@ class DashboardViewController: BaseViewController { // 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") - // Because the Dashboard screen contains a lot of Views and it's designed using Storyboard, we are - // adding this inline View into the UI by adding to a StackView. This allows us to dyanamically add to the Dashboard screen without complexity or breaking any of the constraints set in Storyboard. + // 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. diff --git a/Sources/MessagingInApp/Extensions/UIViewExtensions.swift b/Sources/MessagingInApp/Extensions/UIViewExtensions.swift new file mode 100644 index 000000000..9392f8cf2 --- /dev/null +++ b/Sources/MessagingInApp/Extensions/UIViewExtensions.swift @@ -0,0 +1,19 @@ +import Foundation +import UIKit + +extension UIView { + // Find the topmost superview in the view hierarchy. Probably the UIView of the UIViewController the UIView is nested in. + func getRootSuperview() -> UIView? { + guard var rootSuperview = superview else { + return nil // no superview, return nil early. + } + + while true { + if let nextLevelSuperview = rootSuperview.superview { + rootSuperview = nextLevelSuperview + } else { + return rootSuperview + } + } + } +} diff --git a/Sources/MessagingInApp/Views/InAppMessageView.swift b/Sources/MessagingInApp/Views/InAppMessageView.swift index b6b99d0ea..f3ccb7f00 100644 --- a/Sources/MessagingInApp/Views/InAppMessageView.swift +++ b/Sources/MessagingInApp/Views/InAppMessageView.swift @@ -38,17 +38,11 @@ public class InAppMessageView: UIView { } } - var heightConstraint: NSLayoutConstraint! + var runningHeightChangeAnimation: UIViewPropertyAnimator? - // 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() - } + // 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? @@ -71,25 +65,22 @@ public class InAppMessageView: UIView { } 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 + // 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 is 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 } - // 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() + viewHeightConstraint?.priority = .required + viewHeightConstraint?.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. eventBus.addObserver(InAppMessagesFetchedEvent.self) { [weak self] _ in - // We are unsure what thread this code will run on. We want to ensure that the View is updated on the main thread. + // EventBus callback function might not be on UI thread. + // Switch to UI thread to update UI. Task { @MainActor in self?.checkIfMessageAvailableToDisplay() } @@ -146,8 +137,27 @@ public class InAppMessageView: UIView { extension InAppMessageView: InlineMessageManagerDelegate { // This function is called by WebView when the content's size changes. public func sizeChanged(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 + 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() + } } } diff --git a/Tests/MessagingInApp/Extensions/UIViewExtensionsTests.swift b/Tests/MessagingInApp/Extensions/UIViewExtensionsTests.swift new file mode 100644 index 000000000..8d53813cc --- /dev/null +++ b/Tests/MessagingInApp/Extensions/UIViewExtensionsTests.swift @@ -0,0 +1,30 @@ +@testable import CioMessagingInApp +import Foundation +import SharedTests +import XCTest + +class UIViewExtensionsTest: UnitTest { + func test_getRootSuperview_givenNoSuperview_expectNil() { + let givenView = UIView() + + XCTAssertNil(givenView.getRootSuperview()) + } + + func test_getRootSuperview_givenSuperview_expectSuperview() { + let givenView = UIView() + let givenSuperview = UIView() + givenSuperview.addSubview(givenView) + + XCTAssertEqual(givenView.getRootSuperview(), givenSuperview) + } + + func test_getRootSuperview_givenMultipleLevelsOfNesting_expectRootSuperview() { + let givenView = UIView() + let givenSuperview = UIView() + let givenRootSuperview = UIView() + givenSuperview.addSubview(givenView) + givenRootSuperview.addSubview(givenSuperview) + + XCTAssertEqual(givenView.getRootSuperview(), givenRootSuperview) + } +}