Skip to content

Commit

Permalink
Rewrite Modal IAM in Swift (#2929)
Browse files Browse the repository at this point in the history
* Add HTML view stub

* Add HTML display functionality

* Fix comment

* Update InAppMessageEnvironment to hold extensions wrapper object

* Add InAppMessageExtensions wrapper object

* Add additional HTML examples

* Remove unnecessary print statement

* Add default HTML theme

* Add InAppMessageWebView

* Add BeveledLoadingView

* Add HTMLView

* Update MediaView to check for when image loader is available

* Add InAppMessageViewUtils for shared functionality between in-app layouts

* Update FullScreenView to use shared in-app message utils

* pbxproj changes

* Clean-up

* Padding

* Pass InAppMessageNativeBridgeExtension into HTML InAppMessageEnvironment

* Remove tvOS and watchOS checks in AirshipAutomationSwift

* Make InAppMessageExtensions wrapper into a struct

* Add additional testing examples adn update existing ones

* Update modal default theme

* Make InAppMessageWebView transparent

* Update view utilities for sizing Modal and HTML views

* Make HTMLView default to clear when no background color is specified

* Update button group to allow relative button sizing

* Wire up ModalView display

* Add ModalView

* Make footer a button instead of media

* pbxproj changes

* Add jurassic park font

* Update examples to use jurassic park font

* Rename ModalView to InAppModalMessageView to prevent pod lint issue

* Fix warnings

* Fix ParentClampingResize modifier

* Fix modal theming

* Add @mainactor to displayModal and displayHTML

---------

Co-authored-by: crow <[email protected]>
  • Loading branch information
crow and crow authored Jan 26, 2024
1 parent 011d9f6 commit aa24639
Show file tree
Hide file tree
Showing 24 changed files with 889 additions and 105 deletions.
4 changes: 4 additions & 0 deletions Airship/Airship.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,7 @@
99E8D7DC2B55C4C20099B6F3 /* MediaTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7DB2B55C4C20099B6F3 /* MediaTheme.swift */; };
99E8D7DE2B55C73B0099B6F3 /* ThemeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7DD2B55C73B0099B6F3 /* ThemeExtensions.swift */; };
99F662B02B5DDC2900696098 /* BeveledLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F662AF2B5DDC2900696098 /* BeveledLoadingView.swift */; };
99F662B22B60425E00696098 /* InAppMessageModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F662B12B60425E00696098 /* InAppMessageModalView.swift */; };
A61517B226A9C4C3008A41C4 /* SubscriptionListEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61517B126A9C4C3008A41C4 /* SubscriptionListEditor.swift */; };
A61517B326A9C4C3008A41C4 /* SubscriptionListEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61517B126A9C4C3008A41C4 /* SubscriptionListEditor.swift */; };
A61517B526AEEAAB008A41C4 /* SubscriptionListUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61517B426AEEAAB008A41C4 /* SubscriptionListUpdate.swift */; };
Expand Down Expand Up @@ -3168,6 +3169,7 @@
99EC01901FE095B600B9C408 /* UAInAppMessageFullScreenDisplayContent.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UAInAppMessageFullScreenDisplayContent.h; sourceTree = "<group>"; };
99EC01971FE1FED100B9C408 /* UAInAppMessageFullScreenAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UAInAppMessageFullScreenAdapter.h; sourceTree = "<group>"; };
99F662AF2B5DDC2900696098 /* BeveledLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeveledLoadingView.swift; sourceTree = "<group>"; };
99F662B12B60425E00696098 /* InAppMessageModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageModalView.swift; sourceTree = "<group>"; };
A61517B126A9C4C3008A41C4 /* SubscriptionListEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SubscriptionListEditor.swift; path = AirshipCore/Source/SubscriptionListEditor.swift; sourceTree = SOURCE_ROOT; };
A61517B426AEEAAB008A41C4 /* SubscriptionListUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SubscriptionListUpdate.swift; path = AirshipCore/Source/SubscriptionListUpdate.swift; sourceTree = SOURCE_ROOT; };
A61517C026B009D6008A41C4 /* SubscriptionListAPIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionListAPIClient.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4184,6 +4186,7 @@
children = (
99E0BD0C2B4DD4AB00465B37 /* FullScreenView.swift */,
999DC85D2B5B721D0048C6AF /* HTMLView.swift */,
99F662B12B60425E00696098 /* InAppMessageModalView.swift */,
99E8D7962B4F17260099B6F3 /* CloseButton.swift */,
990A09932B5CA5B700244D90 /* InAppMessageExtensions.swift */,
99E8D7BE2B50C2C10099B6F3 /* ButtonGroup.swift */,
Expand Down Expand Up @@ -7499,6 +7502,7 @@
6E16208F2B3116BA009240B2 /* DefaultDisplayCoordinator.swift in Sources */,
99E0BD0F2B4DD71A00465B37 /* InAppMessageHostingController.swift in Sources */,
6E2E3CA22B32723C00B8515B /* InAppMessageNativeBridgeExtension.swift in Sources */,
99F662B22B60425E00696098 /* InAppMessageModalView.swift in Sources */,
6E0F4BE92B3264A400673CA4 /* DeferredAutomationData.swift in Sources */,
6E1528332B4DF2E600DF1377 /* ScheduleConditionsChangedNotifier.swift in Sources */,
6E1A9BC52B5EE1EE00A6489B /* ScheduleExecuteResult.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter {
scene: scene.scene,
analytics: analytics
)

case .modal(_):
// TODO
return .finished
case .modal(let modal):
return await displayModal(
modal,
scene: scene.scene,
analytics: analytics
)
case .html(let html):
return await displayHTML(
html,
Expand Down Expand Up @@ -125,6 +127,40 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter {
}
}

@MainActor
private func displayModal(
_ modal: InAppMessageDisplayContent.Modal,
scene: UIWindowScene,
analytics: InAppMessageAnalyticsProtocol
) async -> DisplayResult {
return await withCheckedContinuation { continuation in
let listener = InAppMessageDisplayListener(
analytics: analytics
) { result in
continuation.resume(returning: result)
}

let window = UIWindow.makeModalReadyWindow(scene: scene)
let environment = InAppMessageEnvironment(
delegate: listener,
theme: Theme.modal(ModalTheme()),
extensions: InAppMessageExtensions(imageProvider: AssetCacheImageProvider(assets: assets))
) {
window.animateOut()
}

let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { orientation, _ in
InAppMessageModalView(displayContent: modal)
}

let viewController = InAppMessageHostingController(rootView: rootView)
viewController.modalPresentationStyle = UIModalPresentationStyle.fullScreen
window.rootViewController = viewController

window.animateIn()
}
}

@MainActor
private func displayHTML(
_ html: InAppMessageDisplayContent.HTML,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ public enum InAppMessageDisplayContent: Sendable, Equatable {
public var media: InAppMessageMediaInfo?

/// The footer
public var footer: InAppMessageMediaInfo?
public var footer: InAppMessageButtonInfo?

/// The buttons
public var buttons: [InAppMessageButtonInfo]?
Expand Down Expand Up @@ -191,7 +191,7 @@ public enum InAppMessageDisplayContent: Sendable, Equatable {
heading: InAppMessageTextInfo? = nil,
body: InAppMessageTextInfo? = nil,
media: InAppMessageMediaInfo? = nil,
footer: InAppMessageMediaInfo? = nil,
footer: InAppMessageButtonInfo? = nil,
buttons: [InAppMessageButtonInfo],
buttonLayoutType: InAppMessageButtonLayoutType? = nil,
template: Template,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ private let defaultButtonMargin: CGFloat = 15
private let defaultFooterMargin: CGFloat = 0
private let buttonDefaultBorderWidth: CGFloat = 2

struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

struct ButtonGroup: View {
@EnvironmentObject var environment: InAppMessageEnvironment
let layout:InAppMessageButtonLayoutType
let buttons:[InAppMessageButtonInfo]

/// Prevent cycling onPreferenceChange to set rest of the buttons' minHeight to the largest button's height
@State private var buttonMinHeight: CGFloat = 33
@State private var lastButtonHeight: CGFloat?

var stackedButtonSpacing: CGFloat {
switch environment.theme {
case .banner(let theme):
Expand Down Expand Up @@ -38,7 +50,25 @@ struct ButtonGroup: View {
}

private func makeButtonView(buttonInfo: InAppMessageButtonInfo, roundedEdge: RoundedEdge = .all) -> some View {
return ButtonView(buttonInfo: buttonInfo, roundedEdge: roundedEdge).environmentObject(environment)
return ButtonView(buttonInfo: buttonInfo, roundedEdge: roundedEdge, relativeMinHeight: $buttonMinHeight)
.frame(minHeight:buttonMinHeight)
.environmentObject(environment)
.background(
GeometryReader {
Color.tappableClear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .global).size.height) }
.onPreferenceChange(ViewHeightKey.self) { value in
DispatchQueue.main.async {
let buttonHeight = round(value)
/// Prevent cycling by storing the last button height
if self.lastButtonHeight ?? 0 != buttonHeight {
/// Minium button height is the height of the largest button in the group
self.buttonMinHeight = max(buttonMinHeight, buttonHeight)
self.lastButtonHeight = buttonHeight
}
}
}
)
}

var body: some View {
Expand All @@ -49,6 +79,8 @@ struct ButtonGroup: View {
makeButtonView(buttonInfo: button)
}
}

.fixedSize(horizontal: false, vertical: true) /// Hug children in vertical axis
case .joined:
HStack(spacing: 0) {
ForEach(Array(buttons.enumerated()), id: \.element.identifier) { index, button in
Expand All @@ -68,13 +100,13 @@ struct ButtonGroup: View {
makeButtonView(buttonInfo: button)
}
}
}
}.fixedSize(horizontal: false, vertical: true) /// Hug children in horizontal axis and veritcal axis
case .separate:
HStack(spacing: separateButtonSpacing) {
ForEach(buttons, id: \.identifier) { button in
makeButtonView(buttonInfo: button)
}
}
}.fixedSize(horizontal: false, vertical: true) /// Hug children in horizontal axis and veritcal axis
}
}
}
Expand All @@ -85,14 +117,22 @@ struct ButtonView: View {
let buttonInfo: InAppMessageButtonInfo
let roundedEdge: RoundedEdge

@ScaledMetric var scaledPadding: CGFloat = 8
@ScaledMetric var scaledPadding: CGFloat = 12

@State private var isPressed = false
private let pressedOpacity: Double = 0.7

internal init(buttonInfo: InAppMessageButtonInfo, roundedEdge:RoundedEdge = .all) {
/// Min height of the button that can be dynamically set to size to the largest button in the group
/// This is so buttons normalize in height to match the button with the largest font size
@Binding private var relativeMinHeight:CGFloat

internal init(buttonInfo: InAppMessageButtonInfo,
roundedEdge:RoundedEdge = .all,
relativeMinHeight: Binding<CGFloat>? = nil) {
self.buttonInfo = buttonInfo
self.roundedEdge = roundedEdge

_relativeMinHeight = relativeMinHeight ?? Binding.constant(CGFloat(0))
}

private var buttonHeight: CGFloat {
Expand Down Expand Up @@ -120,22 +160,15 @@ struct ButtonView: View {

var body: some View {
Button(action:onTap) {
ZStack {
Rectangle()
.foregroundColor(buttonInfo.backgroundColor?.color)
.roundEdge(radius: buttonInfo.borderRadius ?? 0,
edge: roundedEdge,
borderColor: buttonInfo.borderColor?.color ?? .clear,
borderWidth: 2)
buttonLabel
.frame(minHeight:buttonHeight)
.padding(scaledPadding)
}
.frame(maxWidth: .infinity, minHeight: max(relativeMinHeight, buttonHeight))
.background(buttonInfo.backgroundColor?.color)
.roundEdge(radius: buttonInfo.borderRadius ?? 0,
edge: roundedEdge,
borderColor: buttonInfo.borderColor?.color ?? .clear,
borderWidth: 2)
}.frame(minHeight: buttonHeight)
}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.pressable(isPressed: $isPressed)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ struct HTMLView: View {
}.applyIf(!isModal) {
$0.padding(additionalPadding)
.padding(-24) /// Undo default padding when in fullscreen
.addBackground(color: displayContent.backgroundColor?.color ?? Color.black)
.addBackground(color: displayContent.backgroundColor?.color ?? Color.clear)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/* Copyright Airship and Contributors */

import SwiftUI

#if canImport(AirshipCore)
import AirshipCore
#endif

struct InAppMessageModalView: View {
@EnvironmentObject var environment: InAppMessageEnvironment
let displayContent: InAppMessageDisplayContent.Modal

@Environment(\.orientation) var orientation

private var padding: EdgeInsets {
environment.theme.modalTheme.additionalPadding
}

private var headerTheme: TextTheme {
environment.theme.modalTheme.headerTheme
}

private var bodyTheme: TextTheme {
environment.theme.modalTheme.bodyTheme
}

private var mediaTheme: MediaTheme {
environment.theme.modalTheme.mediaTheme
}

private var dismissIconResource: String {
environment.theme.modalTheme.dismissIconResource
}

private var maxHeight: CGFloat {
CGFloat(environment.theme.modalTheme.maxHeight)
}

private var maxWidth: CGFloat {
CGFloat(environment.theme.modalTheme.maxWidth)
}

@ViewBuilder
private var headerView: some View {
let theme = environment.theme.fullScreenTheme

if let heading = displayContent.heading {
TextView(textInfo: heading, textTheme:headerTheme)
.padding(theme.headerTheme.additionalPadding)
.padding(headerTheme.additionalPadding)
}
}

@ViewBuilder
private var bodyView: some View {
if let body = displayContent.body {
TextView(textInfo: body, textTheme:bodyTheme)
.applyTextTheme(headerTheme)
.padding(bodyTheme.additionalPadding)
}
}

@ViewBuilder
private var mediaView: some View {
if let media = displayContent.media {
MediaView(mediaInfo: media, mediaTheme: mediaTheme, imageLoader: environment.imageLoader)
.padding(.horizontal, -mediaTheme.additionalPadding.leading).padding(mediaTheme.additionalPadding)
}
}

@ViewBuilder
private var buttonsView: some View {
if let buttons = displayContent.buttons, let layout = displayContent.buttonLayoutType, !buttons.isEmpty {
ButtonGroup(layout: layout,
buttons: buttons)
.environmentObject(environment)
}
}

@ViewBuilder
private var footerButton: some View {
if let footer = displayContent.footer {
ButtonView(buttonInfo: footer)
.frame(height:Theme.defaultFooterHeight)
.environmentObject(environment)
}
}

private var orientationChangePublisher = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()

init(displayContent: InAppMessageDisplayContent.Modal) {
self.displayContent = displayContent
}

var body: some View {
ZStack {
ScrollView {
VStack(spacing:24) {
switch displayContent.template {
case .headerMediaBody:
headerView
mediaView
bodyView
case .headerBodyMedia:
headerView
bodyView
mediaView
case .mediaHeaderBody, .none: /// None should never be hit
mediaView.padding(.top, -padding.top) /// Remove top padding when media is on top
headerView
bodyView
}

}.padding(padding)
.background(Color.tappableClear)
}
VStack {
Spacer()
VStack(spacing:24) {
buttonsView
footerButton
}
.padding(padding)
.background(displayContent.backgroundColor?.color ?? Color.black)
}
}.addBackground(color: displayContent.backgroundColor?.color ?? Color.black)
.addCloseButton(dismissButtonColor: displayContent.dismissButtonColor?.color ?? Color.white,
dismissIconResource: dismissIconResource,
circleColor: .tappableClear, /// Probably should just do this everywhere and remove circleColor entirely
onUserDismissed: { environment.onUserDismissed() })
.cornerRadius(displayContent.borderRadius ?? 0)
.parentClampingResize(maxWidth: maxWidth, maxHeight: maxHeight)
.padding(padding)
.addBackground(color: .shadowColor)
}
}
Loading

0 comments on commit aa24639

Please sign in to comment.