Skip to content

Commit

Permalink
✨ Add AppcuesTargetElementTrait
Browse files Browse the repository at this point in the history
  • Loading branch information
iujames authored and mmaatttt committed Apr 11, 2023
1 parent 68c6439 commit d614708
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// AppcuesTargetElementTrait.swift
// AppcuesKit
//
// Created by James Ellis on 1/17/23.
// Copyright © 2023 Appcues. All rights reserved.
//

import UIKit

@available(iOS 13.0, *)
internal class AppcuesTargetElementTrait: BackdropDecoratingTrait {
struct Config: Decodable {
let selector: ElementSelector
}

private class FrameObserverView: UIView {
private var oldBounds: CGRect = .zero
var onChange: ((_ bounds: CGRect) -> Void)?

override func layoutSubviews() {
super.layoutSubviews()
if oldBounds != bounds {
onChange?(bounds)
}
oldBounds = bounds
}
}

static let type: String = "@appcues/target-element"

weak var metadataDelegate: TraitMetadataDelegate?

private let config: Config

private lazy var frameObserverView = FrameObserverView()

required init?(configuration: ExperiencePluginConfiguration, level: ExperienceTraitLevel) {
guard let config = configuration.decode(Config.self) else { return nil }
self.config = config
}

func decorate(backdropView: UIView) throws {
metadataDelegate?.set([ "targetRectangle": try calculateRect(bounds: backdropView.bounds) ])

// Monitor the backdrop bounds to recalculate target-element position on changes.
backdropView.insertSubview(frameObserverView, at: 0)
frameObserverView.pin(to: backdropView)

frameObserverView.onChange = { [weak self] bounds in
// this observer will ignore selector errors and use last known position on failure
if let targetRectangle = try? self?.calculateRect(bounds: bounds) {
self?.metadataDelegate?.set([ "targetRectangle": targetRectangle ])
// Force an update so that traits depending on this value will update.
self?.metadataDelegate?.publish()
}
}
}

func undecorate(backdropView: UIView) throws {
metadataDelegate?.unset(keys: [ "targetRectangle" ])

frameObserverView.removeFromSuperview()
frameObserverView.onChange = nil
}

private func calculateRect(bounds: CGRect) throws -> CGRect {
guard let window = UIApplication.shared.windows.first(where: { !($0 is DebugUIWindow) }) else {
throw TraitError(description: "No active window found")
}

let view = try window.viewMatchingSelector(config.selector)
return view.convert(view.bounds, to: nil)
}
}

private extension UIView {

func viewMatchingSelector(_ target: ElementSelector) throws -> UIView {
let views = viewsMatchingSelector(target)

guard !views.isEmpty else {
throw TraitError(description: "No view matching selector \(target)")
}

// if only a single match of anything, use it
if views.count == 1 {
return views[0]
}

// try to use single match on accessibilityIdentifier
if let match = views.findSingleMatch(criteria: target.accessibilityIdentifier, accessor: { $0.accessibilityIdentifier }) {
return match
}

// try to use single match on tag
if let match = views.findSingleMatch(criteria: target.tag, accessor: { $0.tag }) {
return match
}

// try to use single match on description
if let match = views.findSingleMatch(criteria: target.description, accessor: { $0.description }) {
return match
}

throw TraitError(description: "multiple non-distinct views (\(views.count)) matched selector \(target)")
}

func viewsMatchingSelector(_ target: ElementSelector) -> [UIView] {
var views: [UIView] = []

if let current = self.appcuesSelector {
if (target.accessibilityIdentifier != nil && target.accessibilityIdentifier == current.accessibilityIdentifier) ||
(target.tag != nil && target.tag == current.tag) ||
(target.description != nil && target.description == current.description) {
views.append(self)
}
}

for subview in self.subviews {
views.append(contentsOf: subview.viewsMatchingSelector(target))
}

return views
}
}

private extension Array where Element == UIView {
// find a single item that matches the criteria, based on the accessor
// i.e. a single item that matches the accessibilityIdentifier
func findSingleMatch(criteria: String?, accessor: (ElementSelector) -> String?) -> UIView? {
guard let criteria = criteria else { return nil }
let matches = self.filter {
guard let selector = $0.appcuesSelector else { return false }
return accessor(selector) == criteria
}
if matches.count == 1 {
return matches[0]
}
return nil
}
}
1 change: 1 addition & 0 deletions Sources/AppcuesKit/Presentation/Traits/TraitRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal class TraitRegistry {
register(trait: AppcuesPagingDotsTrait.self)
register(trait: AppcuesBackgroundContentTrait.self)
register(trait: AppcuesTargetRectangleTrait.self)
register(trait: AppcuesTargetElementTrait.self)
register(trait: AppcuesBackdropKeyholeTrait.self)
}

Expand Down

0 comments on commit d614708

Please sign in to comment.