diff --git a/CHANGELOG.md b/CHANGELOG.md index d7bfaa4d2..82efe78cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: add `postHogNoMask` SwiftUI view modifier to explicitly mark any View as non-maskable ([#277](https://github.com/PostHog/posthog-ios/pull/277)) + ## 3.17.2 - 2024-12-23 - fix: ignore additional keyboard windows for $screen event ([#279](https://github.com/PostHog/posthog-ios/pull/279)) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 1ae79dfd4..6d1fce369 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -126,6 +126,8 @@ DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */; }; DA5B85882CD21CBB00686389 /* AutocaptureEventProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */; }; DA979D7B2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */; }; + DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */; }; + DAB565DF2D14C5660088F720 /* PostHogTagViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */; }; DAC699D62CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */; }; DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */; }; DACF6D5D2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */; }; @@ -403,6 +405,8 @@ DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocaptureEventProcessing.swift; sourceTree = ""; }; DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = ""; }; DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureEventTrackerSpec.swift; sourceTree = ""; }; + DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostHogNoMaskViewModifier.swift; sourceTree = ""; }; + DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogTagViewModifier.swift; sourceTree = ""; }; DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegration.swift; sourceTree = ""; }; DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardingPickerViewDelegate.swift; sourceTree = ""; }; DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegrationSpec.swift; sourceTree = ""; }; @@ -596,9 +600,8 @@ 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */, 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */, 69ED1A5B2C7F15F300FE7A91 /* PostHogSessionManager.swift */, - DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */, - 69ED1A872C89B73100FE7A91 /* PostHogSwiftUIViewModifiers.swift */, 69ED1A9E2C8F451B00FE7A91 /* PostHogPersonProfiles.swift */, + DAB565D82D14C51E0088F720 /* SwiftUI */, ); path = PostHog; sourceTree = ""; @@ -794,6 +797,17 @@ name = Products; sourceTree = ""; }; + DAB565D82D14C51E0088F720 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */, + DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */, + 69ED1A872C89B73100FE7A91 /* PostHogSwiftUIViewModifiers.swift */, + DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; DAD76A222D006BF7003E1A43 /* SwiftUI */ = { isa = PBXGroup; children = ( @@ -1161,6 +1175,7 @@ 690FF05F2AE7E2D400A0B06B /* Data+Gzip.swift in Sources */, 69EE82BE2BA9C8AA00EB9542 /* ViewLayoutTracker.swift in Sources */, 69261D1F2AD9681300232EC7 /* PostHogConsumerPayload.swift in Sources */, + DAB565DF2D14C5660088F720 /* PostHogTagViewModifier.swift in Sources */, 6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */, 69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */, DA0CA6F12CFF6B6300AF9500 /* UIWindow+.swift in Sources */, @@ -1213,6 +1228,7 @@ 69EE82BA2BA9C50400EB9542 /* PostHogReplayIntegration.swift in Sources */, 3AE3FB472992AB0000AFFC18 /* Hedgelog.swift in Sources */, 69B7F60C2CF7703400A48BCC /* UIImage+Util.swift in Sources */, + DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */, 69261D132AD5685B00232EC7 /* PostHogFeatureFlags.swift in Sources */, 699C5FE62C20178E007DB818 /* UUIDUtils.swift in Sources */, 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */, diff --git a/PostHog/PostHogMaskViewModifier.swift b/PostHog/PostHogMaskViewModifier.swift deleted file mode 100644 index b1c0922d2..000000000 --- a/PostHog/PostHogMaskViewModifier.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// PostHogMaskViewModifier.swift -// PostHog -// -// Created by Yiannis Josephides on 09/10/2024. -// - -#if os(iOS) && canImport(SwiftUI) - - import SwiftUI - - public extension View { - func postHogMask(_ isEnabled: Bool = true) -> some View { - modifier(PostHogMaskViewModifier(enabled: isEnabled)) - } - } - - private struct PostHogMaskViewTagger: UIViewRepresentable { - func makeUIView(context _: Context) -> PostHogMaskViewTaggerView { - PostHogMaskViewTaggerView() - } - - func updateUIView(_: PostHogMaskViewTaggerView, context _: Context) { - // nothing - } - } - - private struct PostHogMaskViewModifier: ViewModifier { - let enabled: Bool - - func body(content: Content) -> some View { - content.background(viewTagger) - } - - @ViewBuilder - private var viewTagger: some View { - if enabled { - PostHogMaskViewTagger() - } - } - } - - private class PostHogMaskViewTaggerView: UIView { - private weak var maskedView: UIView? - override func layoutSubviews() { - super.layoutSubviews() - // ### Why grandparent view? - // - // Because of SwiftUI-to-UIKit view bridging: - // OriginalView (SwiftUI) <- we tag here - // L PostHogMaskViewTagger (ViewRepresentable) - // L PostHogMaskViewTaggerView (UIView) <- we are here - maskedView = superview?.superview - superview?.superview?.postHogNoCapture = true - } - - override func removeFromSuperview() { - super.removeFromSuperview() - maskedView?.postHogNoCapture = false - maskedView = nil - } - } - - extension UIView { - var postHogNoCapture: Bool { - get { objc_getAssociatedObject(self, &AssociatedKeys.phNoCapture) as? Bool ?? false } - set { objc_setAssociatedObject(self, &AssociatedKeys.phNoCapture, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - } - } -#endif diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 43fc4d5d7..a8e83e47f 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -230,6 +230,11 @@ } private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) { + // User explicitly marked this view (and its subviews) as non-maskable through `.postHogNoMask()` view modifier + if view.postHogNoMask { + return + } + if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView if isTextViewSensitive(textView) { maskableWidgets.append(view.toAbsoluteRect(window)) @@ -343,7 +348,7 @@ } } - // manually masked views through view modifier `PostHogMaskViewModifier` + // manually masked views through `.postHogMask()` view modifier if view.postHogNoCapture { maskableWidgets.append(view.toAbsoluteRect(window)) return diff --git a/PostHog/SwiftUI/PostHogMaskViewModifier.swift b/PostHog/SwiftUI/PostHogMaskViewModifier.swift new file mode 100644 index 000000000..227674e7e --- /dev/null +++ b/PostHog/SwiftUI/PostHogMaskViewModifier.swift @@ -0,0 +1,53 @@ +// +// PostHogMaskViewModifier.swift +// PostHog +// +// Created by Yiannis Josephides on 09/10/2024. +// + +#if os(iOS) && canImport(SwiftUI) + + import SwiftUI + + public extension View { + /** + Marks a SwiftUI View to be masked in PostHog session replay recordings. + + Because of the nature of how we intercept SwiftUI view hierarchy (and how it maps to UIKit), + we can't always be 100% confident that a view should be masked and may accidentally mark a + sensitive view as non-sensitive instead. + + Use this modifier to explicitly mask sensitive views in session replay recordings. + + For example: + ```swift + // This view will be masked in recordings + SensitiveDataView() + .postHogMask() + + // Conditionally mask based on a flag + SensitiveDataView() + .postHogMask(shouldMask) + ``` + + - Parameter isEnabled: Whether masking should be enabled. Defaults to true. + - Returns: A modified view that will be masked in session replay recordings when enabled + */ + func postHogMask(_ isEnabled: Bool = true) -> some View { + modifier( + PostHogTagViewModifier { uiViews in + uiViews.forEach { $0.postHogNoCapture = isEnabled } + } onRemove: { uiViews in + uiViews.forEach { $0.postHogNoCapture = false } + } + ) + } + } + + extension UIView { + var postHogNoCapture: Bool { + get { objc_getAssociatedObject(self, &AssociatedKeys.phNoCapture) as? Bool ?? false } + set { objc_setAssociatedObject(self, &AssociatedKeys.phNoCapture, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + } +#endif diff --git a/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift b/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift new file mode 100644 index 000000000..e4c1c452f --- /dev/null +++ b/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift @@ -0,0 +1,54 @@ +// +// PostHogNoMaskViewModifier.swift +// PostHog +// +// Created by Yiannis Josephides on 09/10/2024. +// + +#if os(iOS) && canImport(SwiftUI) + + import SwiftUI + + public extension View { + /** + Marks a SwiftUI View to be excluded from masking in PostHog session replay recordings. + + There are cases where PostHog SDK will unintentionally mask some SwiftUI views. + + Because of the nature of how we intercept SwiftUI view hierarchy (and how it maps to UIKit), + we can't always be 100% confident that a view should be masked. For that reason, we prefer to + take a proactive and prefer to mask views if we're not sure. + + Use this modifier to prevent views from being masked in session replay recordings. + + For example: + ```swift + // This view may be accidentally masked by PostHog SDK + SomeSafeView() + + // This custom view (and all its subviews) will not be masked in recordings + SomeSafeView() + .postHogNoMask() + ``` + + - Returns: A modified view that will not be masked in session replay recordings + */ + func postHogNoMask() -> some View { + modifier( + PostHogTagViewModifier { uiViews in + uiViews.forEach { $0.postHogNoMask = true } + } onRemove: { uiViews in + uiViews.forEach { $0.postHogNoMask = false } + } + ) + } + } + + extension UIView { + var postHogNoMask: Bool { + get { objc_getAssociatedObject(self, &AssociatedKeys.phNoMask) as? Bool ?? false } + set { objc_setAssociatedObject(self, &AssociatedKeys.phNoMask, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + } + +#endif diff --git a/PostHog/PostHogSwiftUIViewModifiers.swift b/PostHog/SwiftUI/PostHogSwiftUIViewModifiers.swift similarity index 77% rename from PostHog/PostHogSwiftUIViewModifiers.swift rename to PostHog/SwiftUI/PostHogSwiftUIViewModifiers.swift index 9466eba9d..dd70b9dda 100644 --- a/PostHog/PostHogSwiftUIViewModifiers.swift +++ b/PostHog/SwiftUI/PostHogSwiftUIViewModifiers.swift @@ -9,25 +9,15 @@ import Foundation import SwiftUI - struct PostHogSwiftUIViewModifier: ViewModifier { - let viewEventName: String - - let screenEvent: Bool - - let properties: [String: Any]? - - func body(content: Content) -> some View { - content.onAppear { - if screenEvent { - PostHogSDK.shared.screen(viewEventName, properties: properties) - } else { - PostHogSDK.shared.capture(viewEventName, properties: properties) - } - } - } - } - public extension View { + /** + Marks a SwiftUI View to be tracked as a $screen event in PostHog when onAppear is called. + + - Parameters: + - screenName: The name of the screen. Defaults to the type of the view. + - properties: Additional properties to be tracked with the screen. + - Returns: A modified view that will be tracked as a screen in PostHog. + */ func postHogScreenView(_ screenName: String? = nil, _ properties: [String: Any]? = nil) -> some View { @@ -46,4 +36,22 @@ } } + private struct PostHogSwiftUIViewModifier: ViewModifier { + let viewEventName: String + + let screenEvent: Bool + + let properties: [String: Any]? + + func body(content: Content) -> some View { + content.onAppear { + if screenEvent { + PostHogSDK.shared.screen(viewEventName, properties: properties) + } else { + PostHogSDK.shared.capture(viewEventName, properties: properties) + } + } + } + } + #endif diff --git a/PostHog/SwiftUI/PostHogTagViewModifier.swift b/PostHog/SwiftUI/PostHogTagViewModifier.swift new file mode 100644 index 000000000..d33db2e9d --- /dev/null +++ b/PostHog/SwiftUI/PostHogTagViewModifier.swift @@ -0,0 +1,365 @@ +// +// PostHogTagViewModifier.swift +// PostHog +// +// Created by Yiannis Josephides on 19/12/2024. +// + +// Inspired from: https://github.com/siteline/swiftui-introspect + +#if os(iOS) && canImport(SwiftUI) + import SwiftUI + + typealias PostHogTagViewHandler = ([UIView]) -> Void + + /** + This is a helper view modifier for retrieving a list of underlying UIKit views for the current SwiftUI view. + + This implementation injects two hidden views into the SwiftUI view hierarchy, with the purpose of using them to retrieve the generated UIKit views for this SwiftUI view. + + The two injected views basically sandwich the current SwiftUI view: + - The first view is an anchor view, which defines how far **down** we need to traverse the view hierarchy (added as a background view). + - The second view is a tagger view, which defines how far **up** we traverse the view hierarchy (added as an overlay view). + - Any view in between the two should be the generated UIKit views that correspond to the current View + + ``` + View Hierarchy Tree: + + UIHostingController + │ + ▼ + _UIHostingView (Common ancestor) + │ + ┌──────┴──────┐ + ▼ ▼ + UnrelatedView | + │ + PostHogTagView + (overlay) + │ + ▼ + _UIGeneratedView (e.g generated views in an HStack) + │ + ▼ + _UIGeneratedView (e.g generated views in an HStack) + │ + ▼ + PostHogTagAnchorView + (background) + + The general approach is: + + 1. PostHogTagAnchorView injected as background (bottom boundary) + 2. PostHogTagView injected as overlay (top boundary) + 3. System renders SwiftUI view hierarchy in UIKit + 4. Find the common ancestor of the PostHogTagAnchorView and PostHogTagView (e.g _UIHostingView) + 5. Retrieve all of the descendants of common ancestor that are between PostHogTagView and PostHogTagAnchorView (excluding tagged views) + + This logic is implemented in the `getTargetViews` function, which is called from PostHogTagView. + + ``` + */ + struct PostHogTagViewModifier: ViewModifier { + private let id = UUID() + + let onChange: PostHogTagViewHandler + let onRemove: PostHogTagViewHandler + + /** + This is a helper view modifier for retrieving a list of underlying UIKit views for the current SwiftUI view. + + If, for example, this modifier is applied on an instance of an HStack, the returned list will contain the underlying UIKit views embedded in the HStack. + For single views, the returned list will contain a single element, the view itself. + + - Parameters: + - onChange: called when the underlying UIKit views are detected, or when they are layed out. + - onRemove: called when the underlying UIKit views are removed from the view hierarchy, for cleanup. + */ + init(onChange: @escaping PostHogTagViewHandler, onRemove: @escaping PostHogTagViewHandler) { + self.onChange = onChange + self.onRemove = onRemove + } + + func body(content: Content) -> some View { + content + .background( + PostHogTagAnchorView(id: id) + .accessibility(hidden: true) + .frame(width: 0, height: 0) + ) + .overlay( + PostHogTagView(id: id, onChange: onChange, onRemove: onRemove) + .accessibility(hidden: true) + .frame(width: 0, height: 0) + ) + } + } + + struct PostHogTagView: UIViewControllerRepresentable { + final class Coordinator { + var onChangeHandler: PostHogTagViewHandler? + var onRemoveHandler: PostHogTagViewHandler? + + private var _targets: [Weak] + var cachedTargets: [UIView] { + get { _targets.compactMap(\.value) } + set { _targets = newValue.map(Weak.init) } + } + + init( + onRemove: PostHogTagViewHandler? + ) { + _targets = [] + onRemoveHandler = onRemove + } + } + + @Binding + private var observed: Void // workaround for state changes not triggering view updates + private let id: UUID + private let onChangeHandler: PostHogTagViewHandler? + private let onRemoveHandler: PostHogTagViewHandler? + + init( + id: UUID, + onChange: PostHogTagViewHandler?, + onRemove: PostHogTagViewHandler? + ) { + _observed = .constant(()) + self.id = id + onChangeHandler = onChange + onRemoveHandler = onRemove + } + + func makeCoordinator() -> Coordinator { + // dismantleUIViewController is Static, so we need to store the onRemoveHandler + // somewhere where we can access it during view distruction + Coordinator(onRemove: onRemoveHandler) + } + + func makeUIViewController(context: Context) -> PostHogTagViewController { + let controller = PostHogTagViewController(id: id) { controller in + let targets = getTargetViews(from: controller) + if !targets.isEmpty { + context.coordinator.cachedTargets = targets + onChangeHandler?(targets) + } + } + + return controller + } + + func updateUIViewController(_: PostHogTagViewController, context _: Context) { + // nothing + } + + static func dismantleUIViewController(_ controller: PostHogTagViewController, coordinator: Coordinator) { + // using cached targets should be good here + let targets = coordinator.cachedTargets.isEmpty ? getTargetViews(from: controller) : coordinator.cachedTargets + if !targets.isEmpty { + coordinator.onRemoveHandler?(targets) + } + + controller.handler = nil + } + } + + func getTargetViews(from controller: PostHogTagViewController) -> [UIView] { + guard + let taggerView = controller.view, + let anchorView = taggerView.postHogAnchor, + let commonAncestor = anchorView.nearestCommonAncestor(with: taggerView) + else { + return [] + } + + return commonAncestor + .allDescendants(between: anchorView, and: taggerView) + .filter { !$0.postHogView } // exclude injected views + } + + private struct PostHogTagAnchorView: UIViewControllerRepresentable { + var id: UUID + var isAnchor: Bool = false + + func makeUIViewController(context _: Context) -> some UIViewController { + PostHogTagAnchorViewController(id: id) + } + + func updateUIViewController(_: UIViewControllerType, context _: Context) { + // + } + } + + private class PostHogTagAnchorViewController: UIViewController { + let id: UUID + + init(id: UUID) { + self.id = id + super.init(nibName: nil, bundle: nil) + TaggingStore.shared[id, default: .init()].anchor = self + } + + required init?(coder _: NSCoder) { + id = UUID() + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + view.postHogView = true + } + } + + final class PostHogTagViewController: UIViewController { + let id: UUID + var handler: (() -> Void)? + + init( + id: UUID, + handler: ((PostHogTagViewController) -> Void)? + ) { + self.id = id + super.init(nibName: nil, bundle: nil) + self.handler = { [weak self] in + guard let self else { + return + } + handler?(self) + } + + TaggingStore.shared[id, default: .init()].tagger = self + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + id = UUID() + super.init(nibName: nil, bundle: nil) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + parent?.preferredStatusBarStyle ?? super.preferredStatusBarStyle + } + + override func viewDidLoad() { + super.viewDidLoad() + view.postHogController = self + view.postHogView = true + handler?() + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + handler?() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + handler?() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + handler?() + } + } + + private extension UIView { + var postHogController: PostHogTagViewController? { + get { objc_getAssociatedObject(self, &AssociatedKeys.phController) as? PostHogTagViewController } + set { objc_setAssociatedObject(self, &AssociatedKeys.phController, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var postHogView: Bool { + get { objc_getAssociatedObject(self, &AssociatedKeys.phView) as? Bool ?? false } + set { objc_setAssociatedObject(self, &AssociatedKeys.phView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + func allDescendants(between bottomEntity: UIView, and topEntity: UIView) -> some Sequence { + descendants + .lazy + .drop(while: { $0 !== bottomEntity }) + .prefix(while: { $0 !== topEntity }) + } + + var ancestors: some Sequence { + sequence(first: self, next: { $0.superview }).dropFirst() + } + + var descendants: some Sequence { + recursiveSequence([self], children: { $0.subviews }).dropFirst() + } + + func isDescendant(of other: UIView) -> Bool { + ancestors.contains(other) + } + + func nearestCommonAncestor(with other: UIView) -> UIView? { + var nearestAncestor: UIView? = self + + while let currentEntity = nearestAncestor, !other.isDescendant(of: currentEntity) { + nearestAncestor = currentEntity.superview + } + + return nearestAncestor + } + + var postHogAnchor: UIView? { + if let controller = postHogController { + return TaggingStore.shared[controller.id]?.anchor?.view + } + return nil + } + } + + /** + A helper store for storing reference pairs between anchor and tagger views + */ + @MainActor private enum TaggingStore { + static var shared: [UUID: Pair] = [:] + + struct Pair { + weak var anchor: PostHogTagAnchorViewController? + weak var tagger: PostHogTagViewController? + } + } + + /** + Recursively iterates over a sequence of elements, applying a function to each element to get its children. + + - Parameters: + - sequence: The sequence of elements to iterate over. + - children: A function that takes an element and returns a sequence of its children. + - Returns: An AnySequence that iterates over all elements and their children. + */ + private func recursiveSequence(_ sequence: S, children: @escaping (S.Element) -> S) -> AnySequence { + AnySequence { + var mainIterator = sequence.makeIterator() + // Current iterator, or `nil` if all sequences are exhausted: + var iterator: AnyIterator? + + return AnyIterator { + guard let iterator, let element = iterator.next() else { + if let element = mainIterator.next() { + iterator = recursiveSequence(children(element), children: children).makeIterator() + return element + } + return nil + } + return element + } + } + } + + /** + Boxing a weak reference to a reference type. + */ + final class Weak { + weak var value: T? + + public init(_ wrappedValue: T? = nil) { + value = wrappedValue + } + } + +#endif diff --git a/PostHog/Utils/AssociatedKeys.swift b/PostHog/Utils/AssociatedKeys.swift index fe6bebb06..8fdf4c51f 100644 --- a/PostHog/Utils/AssociatedKeys.swift +++ b/PostHog/Utils/AssociatedKeys.swift @@ -10,5 +10,8 @@ import Foundation enum AssociatedKeys { static var phForwardingDelegate: UInt8 = 0 static var phNoCapture: UInt8 = 0 + static var phNoMask: UInt8 = 0 + static var phController: UInt8 = 0 + static var phView: UInt8 = 0 static var phLabel: UInt8 = 0 }