Skip to content

Commit

Permalink
feat: capture control events for autocapture (#202)
Browse files Browse the repository at this point in the history
* fix: just track touchupinside and valuechanged for some elements

* fix: fix tvos issue

* feat: capture control events

* fix: remove row selected event
  • Loading branch information
PouriaAmini authored Jul 26, 2024
1 parent 8305d48 commit 6564e33
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 70 deletions.
5 changes: 4 additions & 1 deletion Sources/Amplitude/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public struct Constants {
static let AMP_APPLICATION_BACKGROUNDED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Application Backgrounded"
static let AMP_DEEP_LINK_OPENED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Deep Link Opened"
static let AMP_SCREEN_VIEWED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Screen Viewed"
static let AMP_USER_INTERACTION_EVENT = "\(AMP_AMPLITUDE_PREFIX)User Interaction"
static let AMP_ELEMENT_INTERACTED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Element Interacted"

static let AMP_REVENUE_EVENT = "revenue_amount"

static let AMP_APP_VERSION_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Version"
Expand All @@ -97,10 +98,12 @@ public struct Constants {
static let AMP_APP_VIEW_CONTROLLER = "\(AMP_AMPLITUDE_PREFIX)View Controller"
static let AMP_APP_TITLE = "\(AMP_AMPLITUDE_PREFIX)Title"
static let AMP_APP_TARGET_ACCESSIBILITY_LABEL = "\(AMP_AMPLITUDE_PREFIX)Target Accessibility Label"
static let AMP_APP_TARGET_ACCESSIBILITY_IDENTIFIER = "\(AMP_AMPLITUDE_PREFIX)Target Accessibility Identifier"
static let AMP_APP_ACTION = "\(AMP_AMPLITUDE_PREFIX)Action"
static let AMP_APP_TARGET_VIEW_CLASS = "\(AMP_AMPLITUDE_PREFIX)Target View Class"
static let AMP_APP_TARGET_TEXT = "\(AMP_AMPLITUDE_PREFIX)Target Text"
static let AMP_APP_HIERARCHY = "\(AMP_AMPLITUDE_PREFIX)Hierarchy"
static let AMP_APP_ACTION_METHOD = "\(AMP_AMPLITUDE_PREFIX)Action Method"
static let AMP_APP_GESTURE_RECOGNIZER = "\(AMP_AMPLITUDE_PREFIX)Gesture Recognizer"

public struct Configuration {
Expand Down
6 changes: 5 additions & 1 deletion Sources/Amplitude/Events/UserInteractionEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ public class UserInteractionEvent: BaseEvent {
viewController: String? = nil,
title: String? = nil,
accessibilityLabel: String? = nil,
accessibilityIdentifier: String? = nil,
action: String,
targetViewClass: String,
targetText: String? = nil,
hierarchy: String,
actionMethod: String? = nil,
gestureRecognizer: String? = nil
) {
self.init(eventType: Constants.AMP_USER_INTERACTION_EVENT, eventProperties: [
self.init(eventType: Constants.AMP_ELEMENT_INTERACTED_EVENT, eventProperties: [
Constants.AMP_APP_VIEW_CONTROLLER: viewController,
Constants.AMP_APP_TITLE: title,
Constants.AMP_APP_TARGET_ACCESSIBILITY_LABEL: accessibilityLabel,
Constants.AMP_APP_TARGET_ACCESSIBILITY_IDENTIFIER: accessibilityIdentifier,
Constants.AMP_APP_ACTION: action,
Constants.AMP_APP_TARGET_VIEW_CLASS: targetViewClass,
Constants.AMP_APP_TARGET_TEXT: targetText,
Constants.AMP_APP_HIERARCHY: hierarchy,
Constants.AMP_APP_ACTION_METHOD: actionMethod,
Constants.AMP_APP_GESTURE_RECOGNIZER: gestureRecognizer
])
}
Expand Down
147 changes: 87 additions & 60 deletions Sources/Amplitude/Plugins/iOS/UIKitUserInteractions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,49 @@
import UIKit

class UIKitUserInteractions {
struct EventData {
enum Source {
case actionMethod

case gestureRecognizer
}

let viewController: String?

let title: String?

let accessibilityLabel: String?

let accessibilityIdentifier: String?

let targetViewClass: String

let targetText: String?

let hierarchy: String

fileprivate func userInteractionEvent(for action: String, from source: Source? = nil, withName sourceName: String? = nil) -> UserInteractionEvent {
return UserInteractionEvent(
viewController: viewController,
title: title,
accessibilityLabel: accessibilityLabel,
accessibilityIdentifier: accessibilityIdentifier,
action: action,
targetViewClass: targetViewClass,
targetText: targetText,
hierarchy: hierarchy,
actionMethod: source == .actionMethod ? sourceName : nil,
gestureRecognizer: source == .gestureRecognizer ? sourceName : nil
)
}
}

fileprivate static let amplitudeInstances = NSHashTable<Amplitude>.weakObjects()

private static let lock = NSLock()

private static let addNotificationObservers: Void = {
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didBeginEditing), name: UITextField.textDidBeginEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didEndEditing), name: UITextField.textDidEndEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didBeginEditing), name: UITextView.textDidBeginEditingNotification, object: nil)
NotificationCenter.default.addObserver(UIKitUserInteractions.self, selector: #selector(didEndEditing), name: UITextView.textDidEndEditingNotification, object: nil)
}()

Expand All @@ -26,17 +61,9 @@ class UIKitUserInteractions {
addNotificationObservers
}

@objc static func didBeginEditing(_ notification: NSNotification) {
guard let view = notification.object as? UIView else { return }
let userInteractionEvent = view.eventFromData(with: "didBeginEditing")
amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
}

@objc static func didEndEditing(_ notification: NSNotification) {
guard let view = notification.object as? UIView else { return }
let userInteractionEvent = view.eventFromData(with: "didEndEditing")
let userInteractionEvent = view.eventData.userInteractionEvent(for: "didEndEditing")
amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
Expand Down Expand Up @@ -67,14 +94,12 @@ extension UIApplication {
let sendActionResult = amp_sendAction(action, to: target, from: sender, for: event)

guard sendActionResult,
let view = sender as? UIView,
view.amp_shouldTrack(action, for: target),
let actionName = NSStringFromSelector(action)
.components(separatedBy: ":")
.first
let control = sender as? UIControl,
control.amp_shouldTrack(action, for: target),
let actionEvent = control.event(for: action, to: target)?.description
else { return sendActionResult }

let userInteractionEvent = view.eventFromData(with: actionName)
let userInteractionEvent = control.eventData.userInteractionEvent(for: actionEvent, from: .actionMethod, withName: NSStringFromSelector(action))

UIKitUserInteractions.amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
Expand All @@ -90,7 +115,7 @@ extension UIGestureRecognizer {

guard state == .ended, let view else { return }

let gestureType = switch self {
let gestureAction: String? = switch self {
case is UITapGestureRecognizer: "Tap"
case is UISwipeGestureRecognizer: "Swipe"
case is UIPanGestureRecognizer: "Pan"
Expand All @@ -100,62 +125,29 @@ extension UIGestureRecognizer {
case is UIRotationGestureRecognizer: "Rotation"
case is UIScreenEdgePanGestureRecognizer: "Screen Edge Pan"
#endif
default: "Custom Gesture"
default: nil
}

let userInteractionEvent = eventFromData(with: gestureType, from: view)
guard let gestureAction else { return }

let userInteractionEvent = view.eventData.userInteractionEvent(for: gestureAction, from: .gestureRecognizer, withName: descriptiveTypeName)

UIKitUserInteractions.amplitudeInstances.allObjects.forEach {
$0.track(event: userInteractionEvent)
}
}

func eventFromData(with action: String, from view: UIView) -> UserInteractionEvent {
let viewData = view.extractData(with: action)
return UserInteractionEvent(
viewController: viewData.viewController,
title: viewData.title,
accessibilityLabel: viewData.accessibilityLabel,
action: viewData.action,
targetViewClass: viewData.targetViewClass,
targetText: viewData.targetText,
hierarchy: viewData.hierarchy,
gestureRecognizer: descriptiveTypeName)
}
}

extension UIView {
private static let viewHierarchyDelimiter = " -> "

struct ViewData {
let viewController: String?
let title: String?
let accessibilityLabel: String?
let action: String
let targetViewClass: String
let targetText: String?
let hierarchy: String
}
private static let viewHierarchyDelimiter = ""

func eventFromData(with action: String) -> UserInteractionEvent {
let viewData = extractData(with: action)
return UserInteractionEvent(
viewController: viewData.viewController,
title: viewData.title,
accessibilityLabel: viewData.accessibilityLabel,
action: viewData.action,
targetViewClass: viewData.targetViewClass,
targetText: viewData.targetText,
hierarchy: viewData.hierarchy)
}

func extractData(with action: String) -> ViewData {
var eventData: UIKitUserInteractions.EventData {
let viewController = owningViewController
return ViewData(
return UIKitUserInteractions.EventData(
viewController: viewController?.descriptiveTypeName,
title: viewController?.title,
accessibilityLabel: accessibilityLabel,
action: action,
accessibilityIdentifier: accessibilityIdentifier,
targetViewClass: descriptiveTypeName,
targetText: amp_title,
hierarchy: sequence(first: self, next: \.superview)
Expand All @@ -164,9 +156,44 @@ extension UIView {
}
}

extension UIControl {
func event(for action: Selector, to target: Any?) -> UIControl.Event? {
var events: [UIControl.Event] = [
.touchDown, .touchDownRepeat, .touchDragInside, .touchDragOutside,
.touchDragEnter, .touchDragExit, .touchUpInside, .touchUpOutside,
.touchCancel, .valueChanged, .editingDidBegin, .editingChanged,
.editingDidEnd, .editingDidEndOnExit, .primaryActionTriggered
]
if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) {
events.append(.menuActionTriggered)
}

return events.first { event in
self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false
}
}
}

extension UIControl.Event {
var description: String? {
if UIControl.Event.allTouchEvents.contains(self) {
return "Touch"
} else if UIControl.Event.allEditingEvents.contains(self) {
return "Edit"
} else if self == .valueChanged {
return "Value Change"
} else if self == .primaryActionTriggered {
return "Primary Action"
} else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered {
return "Menu Action"
}
return nil
}
}

extension UIResponder {
var owningViewController: UIViewController? {
return self as? UIViewController ?? next?.owningViewController
self as? UIViewController ?? next?.owningViewController
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ class UIKitUserInteractionsTests: XCTestCase {
button.accessibilityLabel = "Accessibility Button"
mockVC.view.addSubview(button)

let buttonData = button.extractData(with: "action")
let buttonData = button.eventData

XCTAssertEqual(buttonData.viewController, "UIViewController")
XCTAssertEqual(buttonData.title, "Mock VC Title")
XCTAssertEqual(buttonData.accessibilityLabel, "Accessibility Button")
XCTAssertEqual(buttonData.action, "action")
XCTAssertEqual(buttonData.targetViewClass, "UIButton")
XCTAssertEqual(buttonData.targetText, "Test Button")
XCTAssertTrue(buttonData.hierarchy.hasSuffix("UIButton -> UIView"))
XCTAssertTrue(buttonData.hierarchy.hasSuffix("UIButton UIView"))
}

func testExtractDataForCustomView() {
Expand All @@ -33,24 +32,22 @@ class UIKitUserInteractionsTests: XCTestCase {
let customView = CustomView()
mockVC.view.addSubview(customView)

let customViewData = customView.extractData(with: "action")
let customViewData = customView.eventData

XCTAssertEqual(customViewData.viewController, "UIViewController")
XCTAssertEqual(customViewData.title, "Mock VC Title")
XCTAssertNil(customViewData.accessibilityLabel)
XCTAssertEqual(customViewData.action, "action")
XCTAssertEqual(customViewData.targetViewClass, "CustomView")
XCTAssertTrue(customViewData.hierarchy.hasSuffix("CustomView -> UIView"))
XCTAssertTrue(customViewData.hierarchy.hasSuffix("CustomView UIView"))
}

func testExtractDataForOrphanView() {
let orphanView = UIView()
let orphanData = orphanView.extractData(with: "action")
let orphanData = orphanView.eventData

XCTAssertNil(orphanData.viewController)
XCTAssertNil(orphanData.title)
XCTAssertNil(orphanData.accessibilityLabel)
XCTAssertEqual(orphanData.action, "action")
XCTAssertEqual(orphanData.targetViewClass, "UIView")
XCTAssertNil(orphanData.targetText)
XCTAssertEqual(orphanData.hierarchy, "UIView")
Expand Down

0 comments on commit 6564e33

Please sign in to comment.