Skip to content

Commit

Permalink
chore: when in-app web content rendered, animate visibility of inline…
Browse files Browse the repository at this point in the history
… View (#724)

Co-authored-by: Ahmed Ali <[email protected]>
  • Loading branch information
levibostian and Ahmed-Ali authored Jun 12, 2024
1 parent 4c3182b commit 5d39cb6
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 29 deletions.
4 changes: 2 additions & 2 deletions Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions Sources/MessagingInApp/Extensions/UIViewExtensions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
64 changes: 37 additions & 27 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 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
}

// 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()
}
Expand Down Expand Up @@ -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()
}
}
}
30 changes: 30 additions & 0 deletions Tests/MessagingInApp/Extensions/UIViewExtensionsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 5d39cb6

Please sign in to comment.