diff --git a/Sources/AppcuesKit/Appcues.swift b/Sources/AppcuesKit/Appcues.swift index d4c9e9c78..1af7b3bd1 100644 --- a/Sources/AppcuesKit/Appcues.swift +++ b/Sources/AppcuesKit/Appcues.swift @@ -21,6 +21,9 @@ public class Appcues: NSObject { @available(iOS 13.0, *) public static var elementTargeting: AppcuesElementTargeting = UIKitElementTargeting() + @available(iOS 13.0, *) + internal static var customComponentRegistry = CustomComponentRegistry() + let container = DIContainer() let config: Appcues.Config @@ -118,6 +121,17 @@ public class Appcues: NSObject { PushAutoConfig.configureAutomatically() } + /// Register a view controller that can be rendered in an `Experience`. + /// - Parameters: + /// - identifier: View name + /// - type: View controller type + public static func registerCustomComponent(identifier: String, type: AppcuesCustomComponentViewController.Type) { + // NOTE: this can't be @objc because the AppcuesCustomComponentView protocol inherits from UIView + guard #available(iOS 13.0, *) else { return } + + customComponentRegistry.registerCustomComponent(identifier: identifier, type: type) + } + /// Get the current version of the Appcues SDK. /// - Returns: Current version of the Appcues SDK. @objc(sdkVersion) diff --git a/Sources/AppcuesKit/Data/Models/Experience/ExperienceComponent.swift b/Sources/AppcuesKit/Data/Models/Experience/ExperienceComponent.swift index 549886667..01c10c9b5 100644 --- a/Sources/AppcuesKit/Data/Models/Experience/ExperienceComponent.swift +++ b/Sources/AppcuesKit/Data/Models/Experience/ExperienceComponent.swift @@ -27,6 +27,7 @@ internal indirect enum ExperienceComponent { case embed(EmbedModel) case optionSelect(OptionSelectModel) case textInput(TextInputModel) + case customComponent(CustomComponentModel) subscript(dynamicMember keyPath: KeyPath) -> T { switch self { @@ -39,6 +40,7 @@ internal indirect enum ExperienceComponent { case .embed(let model): return model[keyPath: keyPath] case .optionSelect(let model): return model[keyPath: keyPath] case .textInput(let model): return model[keyPath: keyPath] + case .customComponent(let model): return model[keyPath: keyPath] } } } @@ -80,6 +82,8 @@ extension ExperienceComponent: Decodable { self = .optionSelect(try modelContainer.decode(OptionSelectModel.self)) case "textInput": self = .textInput(try modelContainer.decode(TextInputModel.self)) + case "customComponent": + self = .customComponent(try modelContainer.decode(CustomComponentModel.self)) default: let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "unknown type '\(type)'") throw DecodingError.valueNotFound(Self.self, context) @@ -279,6 +283,18 @@ extension ExperienceComponent { var textDescription: String? { label.textDescription } } + struct CustomComponentModel: ComponentModel { + let id: UUID + + let identifier: String + + let configDecoder: PluginDecoder + + let style: Style? + + var textDescription: String? { nil } + } + struct SpacerModel: ComponentModel, Decodable { let id: UUID let spacing: Double? @@ -360,3 +376,29 @@ extension ExperienceComponent.Style { let intrinsicSize: ExperienceComponent.IntrinsicSize? } } + +extension ExperienceComponent.CustomComponentModel: Decodable { + private enum CodingKeys: CodingKey { + case id, identifier, style, config + } + + private struct CustomComponentDecoder: PluginDecoder { + private let container: KeyedDecodingContainer + + init(_ container: KeyedDecodingContainer) { + self.container = container + } + + func decode(_ type: T.Type) -> T? { + try? container.decode(T.self, forKey: .config) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + identifier = try container.decode(String.self, forKey: .identifier) + configDecoder = CustomComponentDecoder(container) + style = try container.decodeIfPresent(ExperienceComponent.Style.self, forKey: .style) + } +} diff --git a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesCloseAction.swift b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesCloseAction.swift index b32b6b681..b142f7715 100644 --- a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesCloseAction.swift +++ b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesCloseAction.swift @@ -30,6 +30,12 @@ internal class AppcuesCloseAction: AppcuesExperienceAction { markComplete = config?.markComplete ?? false } + init(appcues: Appcues?, renderContext: RenderContext, markComplete: Bool) { + self.appcues = appcues + self.renderContext = renderContext + self.markComplete = markComplete + } + func execute(completion: @escaping ActionRegistry.Completion) { guard let appcues = appcues else { return completion() } diff --git a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesContinueAction.swift b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesContinueAction.swift index e7c35f328..e1b8246ba 100644 --- a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesContinueAction.swift +++ b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesContinueAction.swift @@ -40,6 +40,12 @@ internal class AppcuesContinueAction: AppcuesExperienceAction { } } + init(appcues: Appcues?, renderContext: RenderContext, stepReference: StepReference) { + self.appcues = appcues + self.renderContext = renderContext + self.stepReference = stepReference + } + func execute(completion: @escaping ActionRegistry.Completion) { guard let appcues = appcues else { return completion() } diff --git a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesTrackAction.swift b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesTrackAction.swift index 10da7fe3e..62595f1ca 100644 --- a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesTrackAction.swift +++ b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesTrackAction.swift @@ -30,6 +30,12 @@ internal class AppcuesTrackAction: AppcuesExperienceAction { self.attributes = config.attributes } + init(appcues: Appcues?, eventName: String, attributes: [String: Any]? = nil) { + self.appcues = appcues + self.eventName = eventName + self.attributes = attributes + } + func execute(completion: ActionRegistry.Completion) { guard let appcues = appcues else { return completion() } diff --git a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesUpdateProfileAction.swift b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesUpdateProfileAction.swift index 6f0d65937..e89971cc1 100644 --- a/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesUpdateProfileAction.swift +++ b/Sources/AppcuesKit/Presentation/Actions/Appcues/AppcuesUpdateProfileAction.swift @@ -31,6 +31,11 @@ internal class AppcuesUpdateProfileAction: AppcuesExperienceAction { } } + init(appcues: Appcues?, properties: [String: Any]) { + self.appcues = appcues + self.properties = properties + } + func execute(completion: ActionRegistry.Completion) { guard let appcues = appcues else { return completion() } diff --git a/Sources/AppcuesKit/Presentation/CustomComponents/AppcuesCustomComponentViewController.swift b/Sources/AppcuesKit/Presentation/CustomComponents/AppcuesCustomComponentViewController.swift new file mode 100644 index 000000000..541a6d7df --- /dev/null +++ b/Sources/AppcuesKit/Presentation/CustomComponents/AppcuesCustomComponentViewController.swift @@ -0,0 +1,13 @@ +// +// AppcuesCustomComponentViewController.swift +// AppcuesKit +// +// Created by Matt on 2024-10-22. +// Copyright © 2024 Appcues. All rights reserved. +// + +import UIKit + +public protocol AppcuesCustomComponentViewController: UIViewController { + init?(configuration: AppcuesExperiencePluginConfiguration, actionController: AppcuesExperienceActions) +} diff --git a/Sources/AppcuesKit/Presentation/CustomComponents/AppcuesExperienceActions.swift b/Sources/AppcuesKit/Presentation/CustomComponents/AppcuesExperienceActions.swift new file mode 100644 index 000000000..c661d7b8e --- /dev/null +++ b/Sources/AppcuesKit/Presentation/CustomComponents/AppcuesExperienceActions.swift @@ -0,0 +1,66 @@ +// +// AppcuesExperienceActions.swift +// AppcuesKit +// +// Created by Matt on 2024-10-22. +// Copyright © 2024 Appcues. All rights reserved. +// + +import UIKit + +public class AppcuesExperienceActions { + private weak var appcues: Appcues? + private let renderContext: RenderContext + private let identifier: String + + var actions: [Experience.Action]? + + init(appcues: Appcues?, renderContext: RenderContext, identifier: String) { + self.appcues = appcues + self.renderContext = renderContext + self.identifier = identifier + } + + @available(iOS 13.0, *) + public func triggerBlockActions() { + guard let actions = actions, let actionRegistry = appcues?.container.resolve(ActionRegistry.self) else { return } + actionRegistry.enqueue( + actionModels: actions, + level: .step, + renderContext: renderContext, + interactionType: "Button Tapped", + viewDescription: "Custom component \(identifier)" + ) + } + + @available(iOS 13.0, *) + public func track(name: String, properties: [String: Any]? = nil) { + enqueue(AppcuesTrackAction(appcues: appcues, eventName: name, attributes: properties)) + } + + @available(iOS 13.0, *) + public func nextStep() { + enqueue(AppcuesContinueAction(appcues: appcues, renderContext: renderContext, stepReference: .offset(1))) + } + + @available(iOS 13.0, *) + public func previousStep() { + enqueue(AppcuesContinueAction(appcues: appcues, renderContext: renderContext, stepReference: .offset(-1))) + } + + @available(iOS 13.0, *) + public func close(markComplete: Bool = false) { + enqueue(AppcuesCloseAction(appcues: appcues, renderContext: renderContext, markComplete: markComplete)) + } + + @available(iOS 13.0, *) + public func updateProfile(properties: [String: Any]) { + enqueue(AppcuesUpdateProfileAction(appcues: appcues, properties: properties)) + } + + @available(iOS 13.0, *) + private func enqueue(_ action: AppcuesExperienceAction) { + guard let actionRegistry = appcues?.container.resolve(ActionRegistry.self) else { return } + actionRegistry.enqueue(actionInstances: [action]) {} + } +} diff --git a/Sources/AppcuesKit/Presentation/Public/Plugins/AppcuesExperiencePluginConfiguration.swift b/Sources/AppcuesKit/Presentation/Public/Plugins/AppcuesExperiencePluginConfiguration.swift index a230392d3..09cb12fcb 100644 --- a/Sources/AppcuesKit/Presentation/Public/Plugins/AppcuesExperiencePluginConfiguration.swift +++ b/Sources/AppcuesKit/Presentation/Public/Plugins/AppcuesExperiencePluginConfiguration.swift @@ -10,7 +10,7 @@ import Foundation /// An object that decodes instances of a plugin configuration from an Experience JSON model. @objc -internal class AppcuesExperiencePluginConfiguration: NSObject { +public class AppcuesExperiencePluginConfiguration: NSObject { /// Context in which a plugin can be applied. @objc @@ -43,7 +43,7 @@ internal class AppcuesExperiencePluginConfiguration: NSObject { /// Returns a value of the type you specify, decoded from a JSON object. /// - Parameter type: The type of the value to decode from the supplied plugin decoder. /// - Returns: A value of the specified type, if the decoder can parse the data. - internal func decode(_ type: T.Type) -> T? { + public func decode(_ type: T.Type) -> T? { return decoder.decode(type) } } diff --git a/Sources/AppcuesKit/Presentation/Traits/Appcues/AppcuesBackgroundContentTrait.swift b/Sources/AppcuesKit/Presentation/Traits/Appcues/AppcuesBackgroundContentTrait.swift index 6b540eefc..ae3735bed 100644 --- a/Sources/AppcuesKit/Presentation/Traits/Appcues/AppcuesBackgroundContentTrait.swift +++ b/Sources/AppcuesKit/Presentation/Traits/Appcues/AppcuesBackgroundContentTrait.swift @@ -18,6 +18,7 @@ internal class AppcuesBackgroundContentTrait: AppcuesStepDecoratingTrait, Appcue weak var metadataDelegate: AppcuesTraitMetadataDelegate? + private weak var appcues: Appcues? private let renderContext: RenderContext private let level: AppcuesExperiencePluginConfiguration.Level @@ -26,6 +27,7 @@ internal class AppcuesBackgroundContentTrait: AppcuesStepDecoratingTrait, Appcue private weak var backgroundViewController: UIViewController? required init?(configuration: AppcuesExperiencePluginConfiguration) { + self.appcues = configuration.appcues self.renderContext = configuration.renderContext guard let config = configuration.decode(Config.self) else { return nil } @@ -46,7 +48,7 @@ internal class AppcuesBackgroundContentTrait: AppcuesStepDecoratingTrait, Appcue func decorate(containerController: AppcuesExperienceContainerViewController) throws { guard level == .group || level == .experience else { return } - let emptyViewModel = ExperienceStepViewModel(renderContext: renderContext) + let emptyViewModel = ExperienceStepViewModel(renderContext: renderContext, appcues: appcues) backgroundViewController = applyBackground(with: emptyViewModel, parent: containerController) } diff --git a/Sources/AppcuesKit/Presentation/Traits/TraitComposer.swift b/Sources/AppcuesKit/Presentation/Traits/TraitComposer.swift index fa27c6378..10a1cc2ea 100644 --- a/Sources/AppcuesKit/Presentation/Traits/TraitComposer.swift +++ b/Sources/AppcuesKit/Presentation/Traits/TraitComposer.swift @@ -16,12 +16,14 @@ internal protocol TraitComposing: AnyObject { @available(iOS 13.0, *) internal class TraitComposer: TraitComposing { + private weak var appcues: Appcues? private let traitRegistry: TraitRegistry private let actionRegistry: ActionRegistry private let config: Appcues.Config private let notificationCenter: NotificationCenter init(container: DIContainer) { + appcues = container.owner traitRegistry = container.resolve(TraitRegistry.self) actionRegistry = container.resolve(ActionRegistry.self) notificationCenter = container.resolve(NotificationCenter.self) @@ -89,7 +91,8 @@ internal class TraitComposer: TraitComposing { step: $0.step, actionRegistry: actionRegistry, renderContext: experience.renderContext, - config: config + config: config, + appcues: appcues ) let stepViewController = ExperienceStepViewController( viewModel: viewModel, diff --git a/Sources/AppcuesKit/Presentation/UI/Components/AppcuesCustomComponent.swift b/Sources/AppcuesKit/Presentation/UI/Components/AppcuesCustomComponent.swift new file mode 100644 index 000000000..496d88fca --- /dev/null +++ b/Sources/AppcuesKit/Presentation/UI/Components/AppcuesCustomComponent.swift @@ -0,0 +1,46 @@ +// +// AppcuesCustomComponent.swift +// AppcuesKit +// +// Created by Matt on 2024-10-22. +// Copyright © 2024 Appcues. All rights reserved. +// + +import SwiftUI + +@available(iOS 13.0, *) +internal struct CustomComponentRepresentable: UIViewControllerRepresentable { + private let type: AppcuesCustomComponentViewController.Type + private let configuration: AppcuesExperiencePluginConfiguration + private let actionController: AppcuesExperienceActions + + init?(on viewModel: ExperienceStepViewModel, for model: ExperienceComponent.CustomComponentModel) { + guard let customComponentData = viewModel.customComponent(for: model) else { return nil } + self.type = customComponentData.type + self.configuration = customComponentData.config + self.actionController = customComponentData.actionController + } + + func makeUIViewController(context: Context) -> UIViewController { + let viewController = type.init(configuration: configuration, actionController: actionController) + return viewController ?? UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // no-op + } +} + +@available(iOS 13.0, *) +internal struct AppcuesCustomComponent: View { + let model: ExperienceComponent.CustomComponentModel + + @EnvironmentObject var viewModel: ExperienceStepViewModel + + var body: some View { + let style = AppcuesStyle(from: model.style) + + CustomComponentRepresentable(on: viewModel, for: model) + .applyAllAppcues(style) + } +} diff --git a/Sources/AppcuesKit/Presentation/UI/Components/ExperienceComponent+View.swift b/Sources/AppcuesKit/Presentation/UI/Components/ExperienceComponent+View.swift index d108e791d..cea8620b8 100644 --- a/Sources/AppcuesKit/Presentation/UI/Components/ExperienceComponent+View.swift +++ b/Sources/AppcuesKit/Presentation/UI/Components/ExperienceComponent+View.swift @@ -30,6 +30,8 @@ extension ExperienceComponent { AnyView(AppcuesTextInput(model: model)) case .optionSelect(let model): AnyView(AppcuesOptionSelect(model: model)) + case .customComponent(let model): + AnyView(AppcuesCustomComponent(model: model)) case .spacer(let model): AnyView(Spacer(minLength: CGFloat(model.spacing))) } diff --git a/Sources/AppcuesKit/Presentation/UI/CustomComponentRegistry.swift b/Sources/AppcuesKit/Presentation/UI/CustomComponentRegistry.swift new file mode 100644 index 000000000..57b36df65 --- /dev/null +++ b/Sources/AppcuesKit/Presentation/UI/CustomComponentRegistry.swift @@ -0,0 +1,49 @@ +// +// CustomComponentRegistry.swift +// AppcuesKit +// +// Created by Matt on 2023-05-19. +// Copyright © 2023 Appcues. All rights reserved. +// + +import UIKit + +internal struct CustomComponentData { + let type: AppcuesCustomComponentViewController.Type + let config: AppcuesExperiencePluginConfiguration + let actionController: AppcuesExperienceActions +} + +@available(iOS 13.0, *) +internal class CustomComponentRegistry { + private var registeredComponents: [String: AppcuesCustomComponentViewController.Type] = [:] + + init() {} + + func registerCustomComponent(identifier: String, type: AppcuesCustomComponentViewController.Type) { + registeredComponents[identifier] = type + } + + func customComponent( + for model: ExperienceComponent.CustomComponentModel, + renderContext: RenderContext, + appcuesInstance: Appcues? + ) -> CustomComponentData? { + guard let type = registeredComponents[model.identifier] else { return nil } + + return CustomComponentData( + type: type, + config: AppcuesExperiencePluginConfiguration( + model.configDecoder, + level: .step, + renderContext: renderContext, + appcues: appcuesInstance + ), + actionController: AppcuesExperienceActions( + appcues: appcuesInstance, + renderContext: renderContext, + identifier: model.identifier + ) + ) + } +} diff --git a/Sources/AppcuesKit/Presentation/UI/ExperienceStepViewModel.swift b/Sources/AppcuesKit/Presentation/UI/ExperienceStepViewModel.swift index a179c3c45..1bfa476e3 100644 --- a/Sources/AppcuesKit/Presentation/UI/ExperienceStepViewModel.swift +++ b/Sources/AppcuesKit/Presentation/UI/ExperienceStepViewModel.swift @@ -21,8 +21,15 @@ internal class ExperienceStepViewModel: ObservableObject { private let actions: [UUID: [Experience.Action]] private let actionRegistry: ActionRegistry? private let renderContext: RenderContext + private weak var appcues: Appcues? - init(step: Experience.Step.Child, actionRegistry: ActionRegistry, renderContext: RenderContext, config: Appcues.Config?) { + init( + step: Experience.Step.Child, + actionRegistry: ActionRegistry, + renderContext: RenderContext, + config: Appcues.Config?, + appcues: Appcues? + ) { self.step = step // Update the action list to be keyed by the UUID. self.actions = step.actions.reduce(into: [:]) { dict, item in @@ -31,11 +38,12 @@ internal class ExperienceStepViewModel: ObservableObject { } self.actionRegistry = actionRegistry self.renderContext = renderContext + self.appcues = appcues self.enableTextScaling = config?.enableTextScaling ?? false } // Create an empty view model for contexts that require an `ExperienceStepViewModel` but aren't in a step context. - init(renderContext: RenderContext) { + init(renderContext: RenderContext, appcues: Appcues?) { self.step = Experience.Step.Child( id: UUID(), type: "", @@ -50,6 +58,7 @@ internal class ExperienceStepViewModel: ObservableObject { self.actions = [:] self.actionRegistry = nil self.renderContext = renderContext + self.appcues = appcues self.enableTextScaling = false } @@ -67,6 +76,16 @@ internal class ExperienceStepViewModel: ObservableObject { // An unknown trigger value will get lumped into Dictionary[nil] and be ignored. Dictionary(grouping: actions[id] ?? []) { ActionType(rawValue: $0.trigger) } } + + func customComponent(for model: ExperienceComponent.CustomComponentModel) -> CustomComponentData? { + let customComponentData = Appcues.customComponentRegistry.customComponent( + for: model, + renderContext: renderContext, + appcuesInstance: appcues + ) + customComponentData?.actionController.actions = actions[model.id] + return customComponentData + } } @available(iOS 13.0, *) @@ -76,7 +95,7 @@ extension ExperienceComponent { var components: [UUID: ExperienceData.FormItem] = [:] switch self { - case .text, .button, .image, .spacer, .embed: + case .text, .button, .image, .spacer, .embed, .customComponent: break case .stack(let model): model.items.forEach {