Skip to content

Commit

Permalink
✨ Add support for custom components in experiences
Browse files Browse the repository at this point in the history
  • Loading branch information
mmaatttt committed Nov 6, 2024
1 parent 60f0fd5 commit 9223b99
Show file tree
Hide file tree
Showing 15 changed files with 286 additions and 7 deletions.
14 changes: 14 additions & 0 deletions Sources/AppcuesKit/Appcues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal indirect enum ExperienceComponent {
case embed(EmbedModel)
case optionSelect(OptionSelectModel)
case textInput(TextInputModel)
case customComponent(CustomComponentModel)

subscript<T>(dynamicMember keyPath: KeyPath<ComponentModel, T>) -> T {
switch self {
Expand All @@ -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]
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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<CodingKeys>

init(_ container: KeyedDecodingContainer<CodingKeys>) {
self.container = container
}

func decode<T: Decodable>(_ 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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]) {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T: Decodable>(_ type: T.Type) -> T? {
public func decode<T: Decodable>(_ type: T.Type) -> T? {
return decoder.decode(type)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand All @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/AppcuesKit/Presentation/Traits/TraitComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down
Loading

0 comments on commit 9223b99

Please sign in to comment.