From a3637b15427357aa575e26580ee34fa07f5d4492 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 12:16:39 -0800 Subject: [PATCH 01/24] signal updates --- Sources/AnalyticsLive/LivePlugins/Bundler.swift | 4 +++- .../Signals/AutoTracking/SwiftUI/Typealiases.swift | 7 ++++--- Sources/AnalyticsLive/Signals/Signals.swift | 8 +++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/AnalyticsLive/LivePlugins/Bundler.swift b/Sources/AnalyticsLive/LivePlugins/Bundler.swift index c23cba5..10cd5a4 100755 --- a/Sources/AnalyticsLive/LivePlugins/Bundler.swift +++ b/Sources/AnalyticsLive/LivePlugins/Bundler.swift @@ -64,7 +64,9 @@ internal class Bundler { if let tempLocalUrl = tempLocalUrl, error == nil { // Success do { - _ = try FileManager.default.replaceItemAt(localUrl, withItemAt: tempLocalUrl) + //_ = try FileManager.default.replaceItemAt(localUrl, withItemAt: tempLocalUrl) + try? FileManager.default.removeItem(at: localUrl) + _ = try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl) success = true } catch (let writeError) { print("Error writing file \(localUrl) : \(writeError)") diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift index b7de55e..11a2ee7 100755 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift @@ -6,15 +6,16 @@ // import Foundation +import SwiftUI // These aliases need to go into the containing app. They're included here // simply as a placeholder and are not exported. // Navigation -//typealias NavigationLink = SignalNavigationLink -//@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -//typealias NavigationStack = SignalNavigationStack +typealias NavigationLink = SignalNavigationLink +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +typealias NavigationStack = SignalNavigationStack // Selection & Input Controls typealias Button = SignalButton diff --git a/Sources/AnalyticsLive/Signals/Signals.swift b/Sources/AnalyticsLive/Signals/Signals.swift index 63792f8..633e4ce 100755 --- a/Sources/AnalyticsLive/Signals/Signals.swift +++ b/Sources/AnalyticsLive/Signals/Signals.swift @@ -15,6 +15,7 @@ public class Signals: Plugin { internal var signalObject: JSClass? = nil internal var signalAdd: JSFunction? = nil + internal var signalGetNextIndex: JSFunction? = nil internal var processSignals: JSFunction? = nil internal var engine: JSEngine? = nil internal var broadcasters = [SignalBroadcaster]() @@ -46,9 +47,9 @@ public class Signals: Plugin { } public var nextIndex: Int { - var result: Int = -1 - guard let signalObject else { return result } - guard let index = signalObject.call(method: "_getNextIndex", args: nil)?.typed(as: Int.self) else { return result } + var result: Int = 0 + guard let signalGetNextIndex else { return result } + guard let index = signalGetNextIndex.call(args: nil)?.typed(as: Int.self) else { return result } result = index return result } @@ -283,6 +284,7 @@ extension Signals { if signalObject == nil { signalObject = engine?["signals"]?.typed(as: JSClass.self) signalAdd = signalObject?.value(for: "_add")?.typed(as: JSFunction.self) + signalGetNextIndex = signalObject?.value(for: "_getNextIndex")?.typed(as: JSFunction.self) } if processSignals == nil { From 5415a15e0332e54abb0877a5200b26f96f9c84f5 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 12:33:06 -0800 Subject: [PATCH 02/24] nav rework --- .../project.pbxproj | 356 ++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 42 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../SwiftUINavExample/ContentView.swift | 274 +++++++++++ .../SwiftUINavExampleApp.swift | 46 ++ .../Signals/AutoTracking/.DS_Store | Bin 8196 -> 10244 bytes .../Navigation/NavigationObserver.swift | 348 ++++++++++++++ .../SwiftUI/SignalNavigation.swift | 436 ------------------ .../AutoTracking/SwiftUI/Typealiases.swift | 6 - 12 files changed, 1125 insertions(+), 442 deletions(-) create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift create mode 100644 Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift create mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift delete mode 100755 Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalNavigation.swift diff --git a/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e4f273d --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj @@ -0,0 +1,356 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 33DAC85B2F046BC80019EC55 /* AnalyticsLive in Frameworks */ = {isa = PBXBuildFile; productRef = 33DAC85A2F046BC80019EC55 /* AnalyticsLive */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 33DAC84B2F046B010019EC55 /* SwiftUINavExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUINavExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 33DAC84D2F046B010019EC55 /* SwiftUINavExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftUINavExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33DAC8482F046B010019EC55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 33DAC85B2F046BC80019EC55 /* AnalyticsLive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33DAC8422F046B010019EC55 = { + isa = PBXGroup; + children = ( + 33DAC84D2F046B010019EC55 /* SwiftUINavExample */, + 33DAC84C2F046B010019EC55 /* Products */, + ); + sourceTree = ""; + }; + 33DAC84C2F046B010019EC55 /* Products */ = { + isa = PBXGroup; + children = ( + 33DAC84B2F046B010019EC55 /* SwiftUINavExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33DAC84A2F046B010019EC55 /* SwiftUINavExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33DAC8562F046B020019EC55 /* Build configuration list for PBXNativeTarget "SwiftUINavExample" */; + buildPhases = ( + 33DAC8472F046B010019EC55 /* Sources */, + 33DAC8482F046B010019EC55 /* Frameworks */, + 33DAC8492F046B010019EC55 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 33DAC84D2F046B010019EC55 /* SwiftUINavExample */, + ); + name = SwiftUINavExample; + packageProductDependencies = ( + 33DAC85A2F046BC80019EC55 /* AnalyticsLive */, + ); + productName = SwiftUINavExample; + productReference = 33DAC84B2F046B010019EC55 /* SwiftUINavExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33DAC8432F046B010019EC55 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 33DAC84A2F046B010019EC55 = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 33DAC8462F046B010019EC55 /* Build configuration list for PBXProject "SwiftUINavExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33DAC8422F046B010019EC55; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33DAC8592F046BC80019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 33DAC84C2F046B010019EC55 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33DAC84A2F046B010019EC55 /* SwiftUINavExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33DAC8492F046B010019EC55 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33DAC8472F046B010019EC55 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 33DAC8542F046B020019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33DAC8552F046B020019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 33DAC8572F046B020019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUINavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 33DAC8582F046B020019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUINavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33DAC8462F046B010019EC55 /* Build configuration list for PBXProject "SwiftUINavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8542F046B020019EC55 /* Debug */, + 33DAC8552F046B020019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33DAC8562F046B020019EC55 /* Build configuration list for PBXNativeTarget "SwiftUINavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8572F046B020019EC55 /* Debug */, + 33DAC8582F046B020019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33DAC8592F046BC80019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../analytics-swift-live"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 33DAC85A2F046BC80019EC55 /* AnalyticsLive */ = { + isa = XCSwiftPackageProductDependency; + productName = AnalyticsLive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33DAC8432F046B010019EC55 /* Project object */; +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..82dea23 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "226ac2774b9590833966f6d13ad78a8190d097e5ace610e05206de14f85e3485", + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "f604cef21bb7269f76136c68bd664c6c993f8e28", + "version" : "1.9.1" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "substrata-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/substrata-swift.git", + "state" : { + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift b/Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift new file mode 100644 index 0000000..1f197aa --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift @@ -0,0 +1,274 @@ +// +// ContentView.swift +// SwiftUINavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI + +// MARK: - Main Tab View + +struct ContentView: View { + var body: some View { + TabView { + Tab("Navigation", systemImage: "arrow.triangle.turn.up.right.diamond") { + NavigationTestView() + } + Tab("Sheets", systemImage: "square.stack") { + SheetTestView() + } + Tab("Settings", systemImage: "gear") { + SettingsView() + } + } + } +} + +// MARK: - Navigation Tests + +struct NavigationTestView: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + List { + Section("Basic NavigationLink") { + // Classic NavigationLink with destination closure + NavigationLink("Detail View (closure)") { + DetailView(title: "From Closure") + } + + // NavigationLink with label + NavigationLink { + DetailView(title: "From Label Builder") + } label: { + Label("Detail View (label)", systemImage: "star") + } + } + + Section("Value-Based Navigation") { + // Value-based - uses navigationDestination(for:) + NavigationLink("Product: iPhone", value: Product(name: "iPhone", price: 999)) + NavigationLink("Product: MacBook", value: Product(name: "MacBook", price: 1999)) + NavigationLink("Product: AirPods", value: Product(name: "AirPods", price: 249)) + } + + Section("Programmatic Navigation") { + Button("Push 3 screens programmatically") { + path.append(Product(name: "First", price: 1)) + path.append(Product(name: "Second", price: 2)) + path.append(Product(name: "Third", price: 3)) + } + + Button("Go to Category -> Product") { + path.append(Category(name: "Electronics")) + path.append(Product(name: "Gadget", price: 99)) + } + } + + Section("Nested Navigation") { + NavigationLink("Go to List View") { + NestedListView() + } + } + } + .navigationTitle("Navigation Tests") + .navigationDestination(for: Product.self) { product in + ProductDetailView(product: product) + } + .navigationDestination(for: Category.self) { category in + CategoryView(category: category) + } + } + } +} + +// MARK: - Detail Views + +struct DetailView: View { + let title: String + + var body: some View { + VStack { + Text("This is \(title)") + .font(.headline) + Text("A basic detail view") + .foregroundStyle(.secondary) + } + .navigationTitle(title) + } +} + +struct ProductDetailView: View { + let product: Product + + var body: some View { + VStack(spacing: 20) { + Text(product.name) + .font(.largeTitle) + Text("$\(product.price)") + .font(.title) + .foregroundStyle(.green) + } + .navigationTitle(product.name) + } +} + +struct CategoryView: View { + let category: Category + + var body: some View { + List { + Text("Category: \(category.name)") + NavigationLink("View Sample Product", value: Product(name: "Sample", price: 42)) + } + .navigationTitle(category.name) + } +} + +struct NestedListView: View { + var body: some View { + List { + NavigationLink("Nested Item 1") { + NestedDetailView(item: 1) + } + NavigationLink("Nested Item 2") { + NestedDetailView(item: 2) + } + NavigationLink("Nested Item 3") { + NestedDetailView(item: 3) + } + } + .navigationTitle("Nested List") + } +} + +struct NestedDetailView: View { + let item: Int + + var body: some View { + Text("Nested Item \(item) Detail") + .navigationTitle("Item \(item)") + } +} + +// MARK: - Sheet Tests + +struct SheetTestView: View { + @State private var showSheet = false + @State private var showFullScreen = false + + var body: some View { + NavigationStack { + List { + Section("Sheet Presentations") { + Button("Show Sheet") { + showSheet = true + } + + Button("Show Full Screen Cover") { + showFullScreen = true + } + } + + Section("Sheet with Navigation") { + Button("Sheet with NavigationStack") { + showSheet = true + } + } + } + .navigationTitle("Sheet Tests") + .sheet(isPresented: $showSheet) { + SheetContentView() + } + .fullScreenCover(isPresented: $showFullScreen) { + FullScreenContentView(isPresented: $showFullScreen) + } + } + } +} + +struct SheetContentView: View { + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + VStack { + Text("This is a sheet!") + .font(.headline) + + NavigationLink("Navigate inside sheet") { + Text("Pushed inside sheet!") + .navigationTitle("Sheet Detail") + } + .padding() + } + .navigationTitle("Sheet View") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +struct FullScreenContentView: View { + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + Text("Full screen cover!") + .navigationTitle("Full Screen") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { isPresented = false } + } + } + } + } +} + +// MARK: - Settings (No navigation title set!) + +struct SettingsView: View { + var body: some View { + NavigationStack { + List { + Text("Setting 1") + Text("Setting 2") + Text("Setting 3") + + NavigationLink("About") { + AboutView() + } + } + // Intentionally NO navigationTitle here - let's see what we capture + } + } +} + +struct AboutView: View { + var body: some View { + Text("SignalTestBed v1.0") + .navigationTitle("About") + } +} + +// MARK: - Models + +struct Product: Hashable { + let name: String + let price: Int +} + +struct Category: Hashable { + let name: String +} + +// MARK: - Preview + +#Preview { + ContentView() +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift b/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift new file mode 100644 index 0000000..7b165e6 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift @@ -0,0 +1,46 @@ +// +// SwiftUINavExampleApp.swift +// SwiftUINavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI +import Segment +import AnalyticsLive + +extension Analytics { + static var main = Analytics(configuration: Configuration(writeKey: "") + .flushAt(3) + .setTrackedApplicationLifecycleEvents(.all)) +} + +@main +struct SwiftUINavExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } + + init() { + // add the Analytics Live plugin to the timeline. + let lp = LivePlugins(fallbackFileURL: nil) + Analytics.main.add(plugin: lp) + + // add destination filters if desired ... + let filters = DestinationFilters() + Analytics.main.add(plugin: filters) + + // configure and add the Signals plugin if in use ... + let config = SignalsConfiguration( + writeKey: "", // this is your segment writeKey. + //relayCount: 1, // lets us see signals quickly ... , the default is 20. + broadcasters: [DebugBroadcaster()], // see console output. Default is just SegmentBroadcaster. + useSwiftUIAutoSignal: true, // automatically forward swiftUI control interaction signals to your event generators. + useNetworkAutoSignal: true) // automatically forward network req/recv signals to your event generators. + + Signals.shared.useConfiguration(config) + Analytics.main.add(plugin: Signals.shared) + } +} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store b/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store index 1569c8704a15f1c47cf375556d28f888f4afa9c1..0da1224e411c5cc7450946e0eb9c991d05377fdb 100755 GIT binary patch delta 201 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50D9QumFar4u4CxGs3?&Si4EYRs z8w;ngPi)}b%+A5WAqi5!#oz~&Ed$CTshWID;J6}InX=%byqx^JbdcGT69gnSs|Y-1 yU)aDd$P5$#0tId$;R ScreenInfo? { + // Walk down to find the actual content, not containers + if let nav = vc as? UINavigationController, + let visible = nav.visibleViewController { + return ScreenInfo.extract(from: visible) + } + if let tab = vc as? UITabBarController, + let selected = tab.selectedViewController { + return findActualContentScreen(from: selected) + } + return ScreenInfo.extract(from: vc) + } + + private func printDetails(_ info: ScreenInfo) { + print(" ├─ class: \(info.className)") + print(" ├─ title: \(info.title ?? "nil")") + print(" ├─ navTitle: \(info.navItemTitle ?? "nil")") + print(" ├─ swiftUIName: \(info.swiftUIViewName ?? "nil")") + print(" ├─ accLabel: \(info.accessibilityLabel ?? "nil")") + print(" └─ accId: \(info.accessibilityIdentifier ?? "nil")") + } +} + +// MARK: - Associated Object Keys + +private struct AssociatedKeys { + static var dismissalDetector = "dismissalDetector" +} + +// MARK: - Dismissal Detector (for swipe-to-dismiss) + +class DismissalDetector: NSObject, UIAdaptivePresentationControllerDelegate { + weak var observer: NavigationObserver? + + init(observer: NavigationObserver) { + self.observer = observer + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + print("🔍 Swipe dismiss detected") + observer?.modalDidDismiss() + } +} + +// MARK: - UIViewController Swizzled Methods + +extension UIViewController { + @objc func swizzled_viewDidAppear(_ animated: Bool) { + // Call original implementation + swizzled_viewDidAppear(animated) + NavigationObserver.shared.screenAppeared(self) + } + + @objc func swizzled_viewWillDisappear(_ animated: Bool) { + // Call original implementation + swizzled_viewWillDisappear(animated) + NavigationObserver.shared.screenDisappearing(self) + } + + @objc func swizzled_present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + // Notify observer before presenting + NavigationObserver.shared.modalWillPresent(from: self, to: viewControllerToPresent) + + // Call original implementation + swizzled_present(viewControllerToPresent, animated: flag, completion: completion) + } + + @objc func swizzled_dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + let className = String(describing: type(of: self)) + let hasPresenting = self.presentingViewController != nil + let hasPresented = self.presentedViewController != nil + print("🔬 dismiss() called on \(className) | presentingVC: \(hasPresenting) | presentedVC: \(hasPresented)") + + // Call original implementation + swizzled_dismiss(animated: flag, completion: { + completion?() + // Fire if this VC was presented (has a presentingViewController) + // OR if this VC was presenting something (has a presentedViewController) + if hasPresenting || hasPresented { + print("🔍 Programmatic dismiss detected") + NavigationObserver.shared.modalDidDismiss() + } + }) + } +} + +// MARK: - Screen Info + +/// Extracts whatever useful info we can from a UIViewController +struct ScreenInfo { + let className: String + let title: String? + let navItemTitle: String? + let swiftUIViewName: String? + let accessibilityLabel: String? + let accessibilityIdentifier: String? + + /// Is this just a container VC that we should ignore? + var isContainerNoise: Bool { + let noiseClasses = [ + "UIKitTabBarController", + "UIKitNavigationController", + "TabHostingController", + "UITrackingElementWindowController", + "PresentationHostingController", + "UIInputWindowController", + "UIEditingOverlayViewController" + ] + + // Direct class name match + if noiseClasses.contains(className) { + return true + } + + // Root hosting controller (not NavigationStack content) + // This is the wrapper around the whole app, not actual screens + if className.hasPrefix("UIHostingController<") && + className.contains("RootModifier") { + return true + } + + return false + } + + /// Best effort at a meaningful screen name + var bestName: String { + // Priority order: + // 1. Navigation item title (set via .navigationTitle() in SwiftUI) + // 2. View controller title + // 3. Accessibility label + // 4. Accessibility identifier + // 5. Extracted SwiftUI view name from generic soup + // 6. Raw class name + if let navTitle = navItemTitle, !navTitle.isEmpty { + return navTitle + } + if let title = title, !title.isEmpty { + return title + } + if let accLabel = accessibilityLabel, !accLabel.isEmpty { + return accLabel + } + if let accId = accessibilityIdentifier, !accId.isEmpty { + return accId + } + if let swiftUIName = swiftUIViewName, swiftUIName != "AnyView" { + return swiftUIName + } + return className + } + + static func extract(from vc: UIViewController) -> ScreenInfo { + let className = String(describing: type(of: vc)) + let swiftUIName = extractSwiftUIViewName(from: className) + + return ScreenInfo( + className: className, + title: vc.title, + navItemTitle: vc.navigationItem.title, + swiftUIViewName: swiftUIName, + accessibilityLabel: vc.view.accessibilityLabel, + accessibilityIdentifier: vc.view.accessibilityIdentifier + ) + } + + /// Try to extract a SwiftUI view name from UIHostingController's generic type + /// e.g. "UIHostingController" -> "ProductDetailView" + /// e.g. "UIHostingController>>" -> "HomeView" + private static func extractSwiftUIViewName(from className: String) -> String? { + // Quick bail if not a hosting controller + guard className.contains("HostingController") || className.contains("Hosting") else { + return nil + } + + // Try to find the first meaningful view name inside the generics + // Look for pattern: SomethingView or Something that looks like a view name + + // First, try to get content between < and > + guard let startIndex = className.firstIndex(of: "<"), + let endIndex = className.lastIndex(of: ">") else { + return nil + } + + let genericContent = String(className[className.index(after: startIndex)..", with: ",") + .replacingOccurrences(of: "<", with: ",") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + + // Look for something that ends in "View" or is a simple PascalCase name + for component in components { + // Skip internal SwiftUI types + if component.hasPrefix("_") || + component.contains("Modifier") || + component.contains("Content") || + component == "Never" || + component == "Text" || + component == "Optional" || + component == "AnyView" { + continue + } + + // If it ends in View, that's probably our target + if component.hasSuffix("View") { + return component + } + + // If it's a simple PascalCase identifier, might be a view + if component.first?.isUppercase == true && + !component.contains(".") && + component.count > 2 { + return component + } + } + + // Fallback to AnyView if that's all we have + if genericContent.contains("AnyView") { + return "AnyView" + } + + return nil + } +} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalNavigation.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalNavigation.swift deleted file mode 100755 index eccfbd1..0000000 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalNavigation.swift +++ /dev/null @@ -1,436 +0,0 @@ -// -// File.swift -// -// -// Created by Brandon Sneed on 2/16/24. -// - -import Foundation -import SwiftUI - -internal class SignalNavCache { - enum NavEntry { - case root(String) - case screen(String) - case none - - var name: String { - switch self { - case .root(let s): - return s - case .screen(let s): - return s - case .none: - return "" - } - } - } - - var screens: [NavEntry] - - init() { - self.screens = [] - push(root: "Root", source: .autoSwiftUI) - } - - func push(root: String, source: SignalSource = .autoSwiftUI) { - var previousScreen: String? = nil - var currentScreen = "" - - if case .root = current { - previousScreen = current.name - } - - if case .none = current { - screens.append(.root(root)) - currentScreen = current.name - } - - let signal = NavigationSignal(currentScreen: currentScreen, previousScreen: previousScreen) - Signals.shared.emit(signal: signal, source: source) - } - - func push(screenName: String) { - let previousScreen = current.name - screens.append(.screen(screenName)) - let currentScreen = current.name - let signal = NavigationSignal(currentScreen: currentScreen, previousScreen: previousScreen) - Signals.shared.emit(signal: signal, source: .autoSwiftUI) - } - - func pop() { - var previousScreen: String? = nil - var currentScreen = "" - - if screens.count == 2 { - previousScreen = current.name - screens.removeLast() - } - - if case .root = current { - currentScreen = current.name - } - - let signal = NavigationSignal(currentScreen: currentScreen, previousScreen: previousScreen) - Signals.shared.emit(signal: signal, source: .autoSwiftUI) - } - - var current: NavEntry { return screens.last ?? .none } - static let shared = SignalNavCache() -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -@MainActor public struct SignalNavigationStack : View where Root : View { - let sui: SwiftUI.NavigationStack - - @MainActor public init(@ViewBuilder root: () -> Root) where Data == NavigationPath { - sui = SwiftUI.NavigationStack(root: root) - let label = "Root" - SignalNavCache.shared.push(root: label) - } - - @MainActor public init(path: Binding, @ViewBuilder root: () -> Root) where Data == NavigationPath { - sui = SwiftUI.NavigationStack(path: path, root: root) - let label = "Root" - SignalNavCache.shared.push(root: label) - } - - @MainActor public init(path: Binding, @ViewBuilder root: () -> Root) where Data : MutableCollection, Data : RandomAccessCollection, Data : RangeReplaceableCollection, Data.Element : Hashable { - sui = SwiftUI.NavigationStack(path: path, root: root) - let label = "Root" - SignalNavCache.shared.push(root: label) - } - - @MainActor public var body: some View { - return sui - } -} - -struct SignalNavContainerView: View { - let content: Content - let label: String - - init(label: String, @ViewBuilder content: () -> Content) { - self.label = label - self.content = content() - } - - var body: some View { - content.onAppear { - SignalNavCache.shared.push(screenName: label) - }.onDisappear { - SignalNavCache.shared.pop() - } - } -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public struct SignalNavigationLink : View where Label : View, Destination : View { - internal let title: String - let destination: Destination? - let type: String? - let label: Label? - - @MainActor public var body: some View { - SwiftUI.NavigationLink( - destination: SignalNavContainerView(label: title) { - destination - }, label: { - label - }) - } - - static func extractScreen(_ label: Label, _ file: String? = nil, _ function: String? = nil, _ line: Int? = nil) -> String { - let s = String(describing: label) - var result: String - - let label = describe(label: s) - if let label { - result = label - } else { - if let file, let function, let line { - result = "Unknown Screen @ \(file), \(function), line \(line)" - } else { - result = "Unknown Screen" - } - } - return result - } - - public init(@ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label, file: String = #file, function: String = #function, line: Int = #line) { - self.title = Self.extractScreen(label(), file, function, line) - self.type = Destination.structName() - self.destination = destination() - self.label = label() - } - - public init(signalLabel: String?, @ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label, file: String = #file, function: String = #function, line: Int = #line) { - self.title = signalLabel ?? Self.extractScreen(label(), file, function, line) - self.type = Destination.structName() - self.destination = destination() - self.label = label() - } - - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Pass a closure as the destination") - public init(destination: Destination, @ViewBuilder label: () -> Label, file: String = #file, function: String = #function, line: Int = #line) { - self.title = Self.extractScreen(label(), file, function, line) - self.type = Destination.structName() - self.destination = destination - self.label = label() - } - - public init(signalLabel: String?, destination: Destination, @ViewBuilder label: () -> Label, file: String = #file, function: String = #function, line: Int = #line) { - self.title = signalLabel ?? Self.extractScreen(label(), file, function, line) - self.type = Destination.structName() - self.destination = destination - self.label = label() - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(isActive: Binding, @ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label) { - self.title = Self.extractScreen(label()) - self.type = Destination.structName() - self.destination = destination() - self.label = label() - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(tag: V, selection: Binding, @ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label) where V : Hashable { - self.title = Self.extractScreen(label()) - self.type = Destination.structName() - self.destination = destination() - self.label = label() - } - - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Pass a closure as the destination") - public init(destination: Destination, @ViewBuilder label: () -> Label) { - self.title = Self.extractScreen(label()) - self.type = Destination.structName() - self.destination = destination - self.label = label() - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(destination: Destination, isActive: Binding, @ViewBuilder label: () -> Label){ - self.title = Self.extractScreen(label()) - self.type = Destination.structName() - self.destination = destination - self.label = label() - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(destination: Destination, tag: V, selection: Binding, @ViewBuilder label: () -> Label) where V : Hashable { - self.title = Self.extractScreen(label()) - self.type = Destination.structName() - self.destination = destination - self.label = label() - } -} - - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension SignalNavigationLink where Destination == Never { - public init

(value: P?, @ViewBuilder label: () -> Label) where P : Hashable { - self.title = Self.extractScreen(label()) - self.type = nil - self.label = label() - self.destination = nil - } - - public init

(_ titleKey: LocalizedStringKey, value: P?) where Label == Text, P : Hashable { - self.title = titleKey.string - self.type = nil - self.destination = nil - self.label = nil - } - - public init(_ title: S, value: P?) where Label == Text, S : StringProtocol, P : Hashable { - self.title = describe(label: title) ?? "" - self.type = nil - self.destination = nil - self.label = nil - } - - public init

(value: P?, @ViewBuilder label: () -> Label) where P : Decodable, P : Encodable, P : Hashable { - self.title = Self.extractScreen(label()) - self.type = nil - self.label = label() - self.destination = nil - } - - public init

(_ titleKey: LocalizedStringKey, value: P?) where Label == Text, P : Decodable, P : Encodable, P : Hashable { - self.title = titleKey.string - self.type = nil - self.label = nil - self.destination = nil - } - - public init(_ title: S, value: P?) where Label == Text, S : StringProtocol, P : Decodable, P : Encodable, P : Hashable { - self.title = describe(label: title) ?? "" - self.type = nil - self.destination = nil - self.label = nil - } -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension SignalNavigationLink where Label == Text { - - public init(_ titleKey: LocalizedStringKey, @ViewBuilder destination: () -> Destination) { - self.title = titleKey.string - self.type = Destination.structName() - self.destination = destination() - self.label = nil - } - - public init(_ title: S, @ViewBuilder destination: () -> Destination) where S : StringProtocol { - self.title = describe(label: title) ?? "" - self.type = nil - self.destination = nil - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ titleKey: LocalizedStringKey, isActive: Binding, @ViewBuilder destination: () -> Destination) { - self.title = titleKey.string - self.type = Destination.structName() - self.destination = destination() - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ title: S, isActive: Binding, @ViewBuilder destination: () -> Destination) where S : StringProtocol { - self.title = describe(label: title) ?? "" - self.type = Destination.structName() - self.destination = destination() - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ titleKey: LocalizedStringKey, tag: V, selection: Binding, @ViewBuilder destination: () -> Destination) where V : Hashable { - self.title = titleKey.string - self.type = Destination.structName() - self.destination = destination() - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ title: S, tag: V, selection: Binding, @ViewBuilder destination: () -> Destination) where S : StringProtocol, V : Hashable { - self.title = describe(label: title) ?? "" - self.type = Destination.structName() - self.destination = destination() - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Pass a closure as the destination") - public init(_ titleKey: LocalizedStringKey, destination: Destination) { - self.title = describe(label: titleKey) ?? "" - self.type = Destination.structName() - self.destination = destination - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Pass a closure as the destination") - @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Pass a closure as the destination") - public init(_ title: S, destination: Destination) where S : StringProtocol { - self.title = describe(label: title) ?? "" - self.type = Destination.structName() - self.destination = destination - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ titleKey: LocalizedStringKey, destination: Destination, isActive: Binding) { - self.title = titleKey.string - self.type = Destination.structName() - self.destination = destination - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ title: S, destination: Destination, isActive: Binding) where S : StringProtocol { - self.title = describe(label: title) ?? "" - self.type = Destination.structName() - self.destination = nil - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ titleKey: LocalizedStringKey, destination: Destination, tag: V, selection: Binding) where V : Hashable { - self.title = titleKey.string - self.type = Destination.structName() - self.destination = destination - self.label = nil - } - - @available(iOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(macOS, introduced: 10.15, deprecated: 13.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(tvOS, introduced: 13.0, deprecated: 16.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(watchOS, introduced: 6.0, deprecated: 9.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - @available(visionOS, introduced: 1.0, deprecated: 1.0, message: "use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView") - public init(_ title: S, destination: Destination, tag: V, selection: Binding) where S : StringProtocol, V : Hashable { - self.title = describe(label: title) ?? "" - self.type = Destination.structName() - self.destination = destination - self.label = nil - } -} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift index 11a2ee7..8d58a5c 100755 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Typealiases.swift @@ -11,12 +11,6 @@ import SwiftUI // These aliases need to go into the containing app. They're included here // simply as a placeholder and are not exported. - -// Navigation -typealias NavigationLink = SignalNavigationLink -@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -typealias NavigationStack = SignalNavigationStack - // Selection & Input Controls typealias Button = SignalButton typealias TextField = SignalTextField From 4787c2000242c6426c57f81cce494143e8aa3e4e Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 12:52:21 -0800 Subject: [PATCH 03/24] more nav rework --- .../SwiftUINavExampleApp.swift | 3 + .../Navigation/NavigationObserver.swift | 184 ++++++++++-------- Sources/AnalyticsLive/Signals/Manual.swift | 5 - Sources/AnalyticsLive/Signals/Signals.swift | 14 +- Sources/AnalyticsLive/Signals/Types.swift | 35 +++- 5 files changed, 146 insertions(+), 95 deletions(-) mode change 100755 => 100644 Sources/AnalyticsLive/Signals/Signals.swift mode change 100755 => 100644 Sources/AnalyticsLive/Signals/Types.swift diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift b/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift index 7b165e6..37da83d 100644 --- a/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift +++ b/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift @@ -42,5 +42,8 @@ struct SwiftUINavExampleApp: App { Signals.shared.useConfiguration(config) Analytics.main.add(plugin: Signals.shared) + + // Enable debug logging for navigation observer + NavigationObserver.shared.debugLogging = true } } diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift index d945f85..aa18f92 100644 --- a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift @@ -1,16 +1,18 @@ // // NavigationObserver.swift -// SignalTestBed +// AnalyticsLive // // Created by Brandon Sneed on 12/30/25. // +#if canImport(UIKit) && !os(watchOS) + import UIKit -/// Dead simple swizzler to see what UIViewController lifecycle events we can catch -/// from SwiftUI navigation. No framework dependencies, just print statements. -class NavigationObserver { - static let shared = NavigationObserver() +/// Observes navigation events via UIViewController lifecycle swizzling. +/// Works with both UIKit and SwiftUI apps - no wrapper types needed. +public class NavigationObserver { + public static let shared = NavigationObserver() private init() {} @@ -20,38 +22,78 @@ class NavigationObserver { /// Track the current visible screen so we know what's being covered private var currentScreen: ScreenInfo? - func start() { + /// Whether swizzling is active + private var isRunning = false + + /// Enable debug logging to console + public var debugLogging = false + + public func start() { + guard !isRunning else { return } + isRunning = true + // Lifecycle swizzles swizzle( cls: UIViewController.self, original: #selector(UIViewController.viewDidAppear(_:)), - swizzled: #selector(UIViewController.swizzled_viewDidAppear(_:)) + swizzled: #selector(UIViewController.signals_viewDidAppear(_:)) ) swizzle( cls: UIViewController.self, original: #selector(UIViewController.viewWillDisappear(_:)), - swizzled: #selector(UIViewController.swizzled_viewWillDisappear(_:)) + swizzled: #selector(UIViewController.signals_viewWillDisappear(_:)) ) // Presentation swizzles swizzle( cls: UIViewController.self, original: #selector(UIViewController.present(_:animated:completion:)), - swizzled: #selector(UIViewController.swizzled_present(_:animated:completion:)) + swizzled: #selector(UIViewController.signals_present(_:animated:completion:)) ) swizzle( cls: UIViewController.self, original: #selector(UIViewController.dismiss(animated:completion:)), - swizzled: #selector(UIViewController.swizzled_dismiss(animated:completion:)) + swizzled: #selector(UIViewController.signals_dismiss(animated:completion:)) ) - print("🔧 NavigationObserver: Swizzling active") + log("🔧 NavigationObserver: Started") + } + + public func stop() { + guard isRunning else { return } + + // Swap back to originals + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_viewDidAppear(_:)), + swizzled: #selector(UIViewController.viewDidAppear(_:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_viewWillDisappear(_:)), + swizzled: #selector(UIViewController.viewWillDisappear(_:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_present(_:animated:completion:)), + swizzled: #selector(UIViewController.present(_:animated:completion:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_dismiss(animated:completion:)), + swizzled: #selector(UIViewController.dismiss(animated:completion:)) + ) + + isRunning = false + coveredScreens.removeAll() + currentScreen = nil + + log("🔧 NavigationObserver: Stopped") } private func swizzle(cls: AnyClass, original: Selector, swizzled: Selector) { guard let originalMethod = class_getInstanceMethod(cls, original), let swizzledMethod = class_getInstanceMethod(cls, swizzled) else { - print("❌ Failed to swizzle \(original)") return } method_exchangeImplementations(originalMethod, swizzledMethod) @@ -65,10 +107,17 @@ class NavigationObserver { // Filter out container noise guard !info.isContainerNoise else { return } + let prev = currentScreen currentScreen = info - print("📱 ENTERED: \(info.bestName)") - printDetails(info) + // Emit navigation signal with full screen data + let signal = NavigationSignal( + currentScreen: info.toScreenData(), + previousScreen: prev?.toScreenData() + ) + Signals.emit(signal: signal, source: .autoSwiftUI) + + log("📱 ENTERED: \(info.bestName)") } func screenDisappearing(_ vc: UIViewController) { @@ -77,18 +126,14 @@ class NavigationObserver { // Filter out container noise guard !info.isContainerNoise else { return } - print("👋 LEFT: \(info.bestName)") - - // Don't try to detect modal dismiss here - too unreliable - // We rely on DismissalDetector for swipe-to-dismiss - // and we'll add dismiss() swizzle for programmatic dismiss + log("👋 LEFT: \(info.bestName)") } func modalWillPresent(from presentingVC: UIViewController, to presentedVC: UIViewController) { // Use currentScreen - we already know what's visible! if let coveredInfo = currentScreen { coveredScreens.append(coveredInfo) - print("📥 COVERED: \(coveredInfo.bestName) (modal presenting)") + log("📥 COVERED: \(coveredInfo.bestName)") } // Set up dismissal detection for swipe-to-dismiss @@ -104,41 +149,26 @@ class NavigationObserver { returnToCoveredScreenIfNeeded() } - private func checkForReturnToCoveredScreen(dismissedVC: UIViewController) { - // Small delay to let the dismissal complete - DispatchQueue.main.async { [weak self] in - self?.returnToCoveredScreenIfNeeded() - } - } - private func returnToCoveredScreenIfNeeded() { guard let returnTo = coveredScreens.popLast() else { return } - print("🔙 RETURNED TO: \(returnTo.bestName)") - printDetails(returnTo) + let prev = currentScreen currentScreen = returnTo + + // Emit navigation signal for returning to covered screen with full data + let signal = NavigationSignal( + currentScreen: returnTo.toScreenData(), + previousScreen: prev?.toScreenData() + ) + Signals.emit(signal: signal, source: .autoSwiftUI) + + log("🔙 RETURNED TO: \(returnTo.bestName)") } - private func findActualContentScreen(from vc: UIViewController) -> ScreenInfo? { - // Walk down to find the actual content, not containers - if let nav = vc as? UINavigationController, - let visible = nav.visibleViewController { - return ScreenInfo.extract(from: visible) + private func log(_ message: String) { + if debugLogging { + print(message) } - if let tab = vc as? UITabBarController, - let selected = tab.selectedViewController { - return findActualContentScreen(from: selected) - } - return ScreenInfo.extract(from: vc) - } - - private func printDetails(_ info: ScreenInfo) { - print(" ├─ class: \(info.className)") - print(" ├─ title: \(info.title ?? "nil")") - print(" ├─ navTitle: \(info.navItemTitle ?? "nil")") - print(" ├─ swiftUIName: \(info.swiftUIViewName ?? "nil")") - print(" ├─ accLabel: \(info.accessibilityLabel ?? "nil")") - print(" └─ accId: \(info.accessibilityIdentifier ?? "nil")") } } @@ -158,7 +188,6 @@ class DismissalDetector: NSObject, UIAdaptivePresentationControllerDelegate { } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - print("🔍 Swipe dismiss detected") observer?.modalDidDismiss() } } @@ -166,39 +195,35 @@ class DismissalDetector: NSObject, UIAdaptivePresentationControllerDelegate { // MARK: - UIViewController Swizzled Methods extension UIViewController { - @objc func swizzled_viewDidAppear(_ animated: Bool) { + @objc func signals_viewDidAppear(_ animated: Bool) { // Call original implementation - swizzled_viewDidAppear(animated) + signals_viewDidAppear(animated) NavigationObserver.shared.screenAppeared(self) } - @objc func swizzled_viewWillDisappear(_ animated: Bool) { + @objc func signals_viewWillDisappear(_ animated: Bool) { // Call original implementation - swizzled_viewWillDisappear(animated) + signals_viewWillDisappear(animated) NavigationObserver.shared.screenDisappearing(self) } - @objc func swizzled_present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + @objc func signals_present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { // Notify observer before presenting NavigationObserver.shared.modalWillPresent(from: self, to: viewControllerToPresent) // Call original implementation - swizzled_present(viewControllerToPresent, animated: flag, completion: completion) + signals_present(viewControllerToPresent, animated: flag, completion: completion) } - @objc func swizzled_dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - let className = String(describing: type(of: self)) + @objc func signals_dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { let hasPresenting = self.presentingViewController != nil let hasPresented = self.presentedViewController != nil - print("🔬 dismiss() called on \(className) | presentingVC: \(hasPresenting) | presentedVC: \(hasPresented)") // Call original implementation - swizzled_dismiss(animated: flag, completion: { + signals_dismiss(animated: flag, completion: { completion?() - // Fire if this VC was presented (has a presentingViewController) - // OR if this VC was presenting something (has a presentedViewController) + // Fire if this VC was presented or was presenting something if hasPresenting || hasPresented { - print("🔍 Programmatic dismiss detected") NavigationObserver.shared.modalDidDismiss() } }) @@ -207,7 +232,7 @@ extension UIViewController { // MARK: - Screen Info -/// Extracts whatever useful info we can from a UIViewController +/// Extracts useful info from a UIViewController for screen identification struct ScreenInfo { let className: String let title: String? @@ -234,7 +259,6 @@ struct ScreenInfo { } // Root hosting controller (not NavigationStack content) - // This is the wrapper around the whole app, not actual screens if className.hasPrefix("UIHostingController<") && className.contains("RootModifier") { return true @@ -279,24 +303,30 @@ struct ScreenInfo { title: vc.title, navItemTitle: vc.navigationItem.title, swiftUIViewName: swiftUIName, - accessibilityLabel: vc.view.accessibilityLabel, - accessibilityIdentifier: vc.view.accessibilityIdentifier + accessibilityLabel: vc.view?.accessibilityLabel, + accessibilityIdentifier: vc.view?.accessibilityIdentifier + ) + } + + /// Convert to NavigationSignal.ScreenData for signal emission + func toScreenData() -> NavigationSignal.ScreenData { + return NavigationSignal.ScreenData( + name: bestName, + className: className, + title: title, + navTitle: navItemTitle, + swiftUIViewName: swiftUIViewName, + accessibilityLabel: accessibilityLabel, + accessibilityIdentifier: accessibilityIdentifier ) } /// Try to extract a SwiftUI view name from UIHostingController's generic type - /// e.g. "UIHostingController" -> "ProductDetailView" - /// e.g. "UIHostingController>>" -> "HomeView" private static func extractSwiftUIViewName(from className: String) -> String? { - // Quick bail if not a hosting controller guard className.contains("HostingController") || className.contains("Hosting") else { return nil } - // Try to find the first meaningful view name inside the generics - // Look for pattern: SomethingView or Something that looks like a view name - - // First, try to get content between < and > guard let startIndex = className.firstIndex(of: "<"), let endIndex = className.lastIndex(of: ">") else { return nil @@ -304,15 +334,12 @@ struct ScreenInfo { let genericContent = String(className[className.index(after: startIndex)..", with: ",") .replacingOccurrences(of: "<", with: ",") .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } - // Look for something that ends in "View" or is a simple PascalCase name for component in components { // Skip internal SwiftUI types if component.hasPrefix("_") || @@ -325,12 +352,10 @@ struct ScreenInfo { continue } - // If it ends in View, that's probably our target if component.hasSuffix("View") { return component } - // If it's a simple PascalCase identifier, might be a view if component.first?.isUppercase == true && !component.contains(".") && component.count > 2 { @@ -338,7 +363,6 @@ struct ScreenInfo { } } - // Fallback to AnyView if that's all we have if genericContent.contains("AnyView") { return "AnyView" } @@ -346,3 +370,5 @@ struct ScreenInfo { return nil } } + +#endif diff --git a/Sources/AnalyticsLive/Signals/Manual.swift b/Sources/AnalyticsLive/Signals/Manual.swift index 6046ae6..b9fabf9 100755 --- a/Sources/AnalyticsLive/Signals/Manual.swift +++ b/Sources/AnalyticsLive/Signals/Manual.swift @@ -28,11 +28,6 @@ extension Signaling { } extension View { - public func signalNavigation(_ screen: String) -> some View { - SignalNavCache.shared.push(root: screen, source: .manual) - return self - } - public func signalInteraction(component: String, title: String, data: [String: Any]? = nil) -> some View { let s = InteractionSignal(component: component, title: title, data: data) Signals.shared.emit(signal: s, source: .manual) diff --git a/Sources/AnalyticsLive/Signals/Signals.swift b/Sources/AnalyticsLive/Signals/Signals.swift old mode 100755 new mode 100644 index 633e4ce..c21ee4e --- a/Sources/AnalyticsLive/Signals/Signals.swift +++ b/Sources/AnalyticsLive/Signals/Signals.swift @@ -235,6 +235,7 @@ extension Signals: LivePluginsDependent { extension Signals { internal func stopAllSwizzlers() { #if canImport(UIKit) && !os(watchOS) + NavigationObserver.shared.stop() TabBarSwizzler.shared.stop() NavigationSwizzler.shared.stop() ModalSwizzler.shared.stop() @@ -250,18 +251,15 @@ extension Signals { } internal func startConfiguredSwizzlers() { + #if canImport(UIKit) && !os(watchOS) if configuration.useSwiftUIAutoSignal { - let _ = SignalNavCache.shared // touch this so it gets set up. - - #if canImport(UIKit) && !os(watchOS) - // needed for SwiftUI TabView's. + // Start the new unified navigation observer + NavigationObserver.shared.start() + + // Also start tab bar swizzler for additional tab tracking TabBarSwizzler.shared.start() - NavigationSwizzler.shared.start() - ModalSwizzler.shared.start() - #endif } - #if canImport(UIKit) && !os(watchOS) if configuration.useUIKitAutoSignal { TabBarSwizzler.shared.start() NavigationSwizzler.shared.start() diff --git a/Sources/AnalyticsLive/Signals/Types.swift b/Sources/AnalyticsLive/Signals/Types.swift old mode 100755 new mode 100644 index cbe1d1d..abffb41 --- a/Sources/AnalyticsLive/Signals/Types.swift +++ b/Sources/AnalyticsLive/Signals/Types.swift @@ -60,9 +60,31 @@ public protocol RawSignal: Codable { // MARK: -- Navigation Signal public struct NavigationSignal: RawSignal { + public struct ScreenData: Codable { + public let name: String + public let className: String? + public let title: String? + public let navTitle: String? + public let swiftUIViewName: String? + public let accessibilityLabel: String? + public let accessibilityIdentifier: String? + + public init(name: String, className: String? = nil, title: String? = nil, + navTitle: String? = nil, swiftUIViewName: String? = nil, + accessibilityLabel: String? = nil, accessibilityIdentifier: String? = nil) { + self.name = name + self.className = className + self.title = title + self.navTitle = navTitle + self.swiftUIViewName = swiftUIViewName + self.accessibilityLabel = accessibilityLabel + self.accessibilityIdentifier = accessibilityIdentifier + } + } + public struct NavigationData: Codable { - let currentScreen: String - let previousScreen: String? + public let currentScreen: ScreenData + public let previousScreen: ScreenData? } public var anonymousId: String = Signals.shared.anonymousId @@ -72,9 +94,16 @@ public struct NavigationSignal: RawSignal { public var context: StaticContext? = nil public var data: NavigationData - public init(currentScreen: String, previousScreen: String? = nil) { + public init(currentScreen: ScreenData, previousScreen: ScreenData? = nil) { self.data = NavigationData(currentScreen: currentScreen, previousScreen: previousScreen) } + + // Convenience initializer for simple string-based usage + public init(currentScreen: String, previousScreen: String? = nil) { + let current = ScreenData(name: currentScreen) + let previous = previousScreen.map { ScreenData(name: $0) } + self.data = NavigationData(currentScreen: current, previousScreen: previous) + } } From b93480fd7832d9f03cf0e864e994cfa87f512c47 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 12:55:03 -0800 Subject: [PATCH 04/24] removed dead file --- .../AutoTracking/SwiftUI/SignalTabView.swift | 184 ------------------ 1 file changed, 184 deletions(-) delete mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalTabView.swift diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalTabView.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalTabView.swift deleted file mode 100644 index 483664e..0000000 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalTabView.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// SignalTabView.swift -// AnalyticsLive -// -// Created by Brandon Sneed on 2/5/25. -// - -import SwiftUI - -extension Binding { - public func onUpdate(_ closure: @escaping (_ newValue: Value, _ oldValue: Value) -> Void) -> Binding { - Binding(get: { - wrappedValue - }, set: { newValue in - let oldValue = wrappedValue - wrappedValue = newValue - closure(newValue, oldValue) - }) - } -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) -public struct SignalTabView : SignalingUI, View - where SelectionValue : Hashable, Content : View -{ - internal let lifecycle: TabViewLifecycle - @State private var selectedTab: Int = 0 - private let id = UUID() - private let userDefinedSelection: Binding? - - private let content: Any - - @available(iOS, deprecated: 100000.0, message: "Use TabContentBuilder-based TabView initializers instead") - @available(macOS, deprecated: 100000.0, message: "Use TabContentBuilder-based TabView initializers instead") - @available(tvOS, deprecated: 100000.0, message: "Use TabContentBuilder-based TabView initializers instead") - @available(watchOS, deprecated: 100000.0, message: "Use TabContentBuilder-based TabView initializers instead") - public init(selection: Binding?, @ViewBuilder content: () -> Content) { - self.lifecycle = TabViewLifecycle(id: id) // this has to come first - self.content = content() - self.userDefinedSelection = selection - } - - #if compiler(>=5.9) - /// Creates a tab view that uses a builder to create and specify - /// selection values for its tabs. - /// - /// - Parameters: - /// - selection: The selection in the TabView. The value of this - /// binding must match the `value` of the tabs in `content`. - /// - content: The ``Tab`` content. - @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) - public init(selection: Binding, @TabContentBuilder content: () -> C) where Content == TabContentBuilder.Content, C : TabContent { - self.lifecycle = TabViewLifecycle(id: id) // this has to come first - self.content = content() as! TabContentBuilder.Content - self.userDefinedSelection = selection - } - #endif - - public var body: some View { - if let userDefinedSelection { - SwiftUI.TabView(selection: userDefinedSelection.onUpdate { newValue, oldValue in - if newValue is Encodable, oldValue is Encodable { - let signal = InteractionSignal( - component: Self.controlType(), - title: nil, - data: [ - "action": "tabSelected", - "previousTab": oldValue, - "selectedTab": newValue, - ] - ) - Signals.emit(signal: signal, source: .autoSwiftUI) - } - }) { - content as! Content - } - } else { - SwiftUI.TabView(selection: $selectedTab.onUpdate { newValue, oldValue in - if let labels = SignalTabCache.getLabels(for: id) { - let signal = InteractionSignal( - component: Self.controlType(), - title: nil, - data: [ - "action": "tabSelected", - "previousTab": labels[oldValue], - "selectedTab": labels[newValue], - ] - ) - Signals.emit(signal: signal, source: .autoSwiftUI) - } - }) { - content as! Content - } - } - } - - @inline(__always) - static public func controlType() -> String { - return "TabView" - } -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) -extension SignalTabView where SelectionValue == Int { - public init(@ViewBuilder content: () -> Content) { - self.lifecycle = TabViewLifecycle(id: id) // this has to come first - self.content = content() - self.userDefinedSelection = nil - } -} - -#if compiler(>=5.9) -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) -extension SignalTabView { - public init(@TabContentBuilder content: () -> C) - where SelectionValue == Never, Content == TabContentBuilder.Content, C : TabContent - { - self.lifecycle = TabViewLifecycle(id: id) // this has to come first - self.content = content() - self.userDefinedSelection = nil - } -} -#endif - -internal final class TabViewLifecycle { - let id: UUID - - init(id: UUID) { - self.id = id - SignalTabCache.push(id) - } - - deinit { - SignalTabCache.pop(id) - } -} - -// Cache using UUID instead of View -internal struct SignalTabCache { - static var tabViews = [UUID: [String]]() - static var ids = [UUID]() - - static func push(_ id: UUID) { - if tabViews[id] != nil { - return - } else { - tabViews[id] = [] - ids.append(id) - } - } - - static func pop(_ id: UUID) { - tabViews.removeValue(forKey: id) - if let index = ids.firstIndex(of: id) { - ids.remove(at: index) - } - } - - static func addLabel(_ label: String) { - if let id = ids.last { - if tabViews[id] == nil { - tabViews[id] = [label] - } else { - tabViews[id]?.append(label) - } - } - } - - static func getLabels(for id: UUID) -> [String]? { - let result = tabViews[id] - if result?.count == 0 { return nil } - return result - } -} - -// MARK: - View Extension -extension View { - @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) - public func signalTabItem(@ViewBuilder _ label: @escaping () -> V) -> some View { - let lbl = SignalTabView.extractLabel(label()) - SignalTabCache.addLabel(lbl) - return self.tabItem(label) - } -} From 7bdde1ff5948fbae3569680861870e533c5ff540 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 12:58:05 -0800 Subject: [PATCH 05/24] make isRunning atomic. --- .../AutoTracking/Navigation/NavigationObserver.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift index aa18f92..5ab7f31 100644 --- a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift @@ -8,6 +8,7 @@ #if canImport(UIKit) && !os(watchOS) import UIKit +import Segment /// Observes navigation events via UIViewController lifecycle swizzling. /// Works with both UIKit and SwiftUI apps - no wrapper types needed. @@ -23,14 +24,14 @@ public class NavigationObserver { private var currentScreen: ScreenInfo? /// Whether swizzling is active - private var isRunning = false + @Atomic private var isRunning = false /// Enable debug logging to console public var debugLogging = false public func start() { guard !isRunning else { return } - isRunning = true + _isRunning.set(true) // Lifecycle swizzles swizzle( @@ -84,7 +85,7 @@ public class NavigationObserver { swizzled: #selector(UIViewController.dismiss(animated:completion:)) ) - isRunning = false + _isRunning.set(false) coveredScreens.removeAll() currentScreen = nil From b2b0488f7c245a3d966faa7d5954dbc07071a475 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 13:05:00 -0800 Subject: [PATCH 06/24] removed unused files --- .../UIKit/SignalUINavController.swift | 135 ------------------ .../UIKit/SignalUIViewController.swift | 117 --------------- Sources/AnalyticsLive/Signals/Signals.swift | 5 +- 3 files changed, 1 insertion(+), 256 deletions(-) delete mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUINavController.swift delete mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUIViewController.swift diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUINavController.swift b/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUINavController.swift deleted file mode 100644 index 81e4279..0000000 --- a/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUINavController.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// SignalUINavController.swift -// AnalyticsLive -// -// Created by Brandon Sneed on 2/7/25. -// - -#if canImport(UIKit) && !os(watchOS) - -import UIKit -import ObjectiveC -import Segment - -internal class NavigationSwizzler { - static let shared = NavigationSwizzler() - private var pushHandle: Swizzler.SwizzleHandle? - private var popHandle: Swizzler.SwizzleHandle? - private var popToVCHandle: Swizzler.SwizzleHandle? - private var popToRootHandle: Swizzler.SwizzleHandle? - @Atomic private var isRunning = false - - func start() { - if isRunning { - return - } - _isRunning.set(true) - - // Push tracking - let pushSelector = #selector(UINavigationController.pushViewController(_:animated:)) - pushHandle = Swizzler.swizzle(originalClass: UINavigationController.self, - originalSelector: pushSelector, - swizzledSelector: #selector(UINavigationController.swizzled_pushViewController(_:animated:))) - - // Pop tracking - let popSelector = #selector(UINavigationController.popViewController(animated:)) - popHandle = Swizzler.swizzle(originalClass: UINavigationController.self, - originalSelector: popSelector, - swizzledSelector: #selector(UINavigationController.swizzled_popViewController(animated:))) - - // Pop to specific VC tracking - let popToVCSelector = #selector(UINavigationController.popToViewController(_:animated:)) - popToVCHandle = Swizzler.swizzle(originalClass: UINavigationController.self, - originalSelector: popToVCSelector, - swizzledSelector: #selector(UINavigationController.swizzled_popToViewController(_:animated:))) - // Pop to root tracking - let popToRootSelector = #selector(UINavigationController.popToRootViewController(animated:)) - popToRootHandle = Swizzler.swizzle(originalClass: UINavigationController.self, - originalSelector: popToRootSelector, - swizzledSelector: #selector(UINavigationController.swizzled_popToRootViewController(animated:))) - } - - func stop() { - if var handle = pushHandle { - handle.restore() - } - if var handle = popHandle { - handle.restore() - } - if var handle = popToRootHandle { - handle.restore() - } - if var handle = popToVCHandle { - handle.restore() - } - _isRunning.set(false) - } -} - -extension UINavigationController { - private func getScreenName(from viewController: UIViewController?) -> String? { - return viewController?.accessibilityLabel ?? viewController?.title ?? viewController?.navigationItem.title - } - - private func emitNavigationSignal(to currentVC: UIViewController?, from previousVC: UIViewController?, fallback: String) { - let navSignal = NavigationSignal( - currentScreen: getScreenName(from: currentVC) ?? fallback, - previousScreen: getScreenName(from: previousVC) - ) - Signals.emit(signal: navSignal, source: .autoUIKit) - } - - @objc dynamic func swizzled_pushViewController(_ viewController: UIViewController, animated: Bool) { - let fromVC = topViewController - self.swizzled_pushViewController(viewController, animated: animated) - emitNavigationSignal(to: viewController, from: fromVC, fallback: "Unknown ") - } - - @objc dynamic func swizzled_popViewController(animated: Bool) -> UIViewController? { - let fromVC = topViewController - let toVC = viewControllers.count > 1 ? viewControllers[viewControllers.count - 2] : nil - let result = self.swizzled_popViewController(animated: animated) - emitNavigationSignal(to: toVC, from: fromVC, fallback: "Unknown ") - return result - } - - @objc dynamic func swizzled_popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { - let fromVC = topViewController - let result = self.swizzled_popToViewController(viewController, animated: animated) - emitNavigationSignal(to: viewController, from: fromVC, fallback: "Unknown ") - return result - } - - @objc dynamic func swizzled_popToRootViewController(animated: Bool) -> [UIViewController]? { - let fromVC = topViewController - let rootVC = viewControllers.first - let result = self.swizzled_popToRootViewController(animated: animated) - emitNavigationSignal(to: rootVC, from: fromVC, fallback: "Unknown ") - return result - } -} - -#else - -import Segment - -internal class NavigationSwizzler { - static let shared = NavigationSwizzler() - @Atomic private var isRunning = false - - func start() { - if isRunning { - return - } - _isRunning.set(true) - - // TODO: Implement AppKit navigation tracking when needed - // Will likely use NSSplitViewController or similar - } - - func stop() { - _isRunning.set(false) - } -} - -#endif diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUIViewController.swift b/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUIViewController.swift deleted file mode 100644 index 5edd15b..0000000 --- a/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUIViewController.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// SignalUIViewController.swift -// AnalyticsLive -// -// Created by Brandon Sneed on 2/7/25. -// - -#if canImport(UIKit) && !os(watchOS) - -import UIKit -import ObjectiveC -import SwiftUI -import Segment - -internal class ModalSwizzler { - static let shared = ModalSwizzler() - private var presentHandle: Swizzler.SwizzleHandle? - private var dismissHandle: Swizzler.SwizzleHandle? - @Atomic private var isRunning = false - internal var currentModalName: String? - - func start() { - if isRunning { - return - } - _isRunning.set(true) - - // Present tracking - let presentSelector = #selector(UIViewController.present(_:animated:completion:)) - presentHandle = Swizzler.swizzle(originalClass: UIViewController.self, - originalSelector: presentSelector, - swizzledSelector: #selector(UIViewController.swizzled_present(_:animated:completion:))) - - // Dismiss tracking - let dismissSelector = #selector(UIViewController.dismiss(animated:completion:)) - dismissHandle = Swizzler.swizzle(originalClass: UIViewController.self, - originalSelector: dismissSelector, - swizzledSelector: #selector(UIViewController.swizzled_dismiss(animated:completion:))) - } - - func stop() { - if var handle = presentHandle { - handle.restore() - } - if var handle = dismissHandle { - handle.restore() - } - _isRunning.set(false) - } -} - -extension UIViewController { - private func extractBasicName(from vc: UIViewController?) -> String? { - return vc?.accessibilityLabel ?? vc?.title ?? vc?.navigationItem.title - } - - private func extractSwiftUIHostingName() -> String? { - guard let hostingController = self as? UIHostingController else { return nil } - - let mirror = Mirror(reflecting: hostingController.rootView) - let typeName = String(describing: mirror.subjectType) - .replacingOccurrences(of: "ModifiedContent<", with: "") - .replacingOccurrences(of: ", RootModifier>", with: "") - .replacingOccurrences(of: ", _TraitWritingModifier<.*>>", with: "", options: .regularExpression) - .replacingOccurrences(of: "AnyView", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - - return typeName.isEmpty ? nil : typeName - } - - var meaningfulName: String { - // Try basic name extraction first - if let name = extractBasicName(from: self) { return name } - - // If we're a nav controller, try the top VC - if let nav = self as? UINavigationController, - let name = extractBasicName(from: nav.topViewController) { return name } - - // If we have a nav controller, try its top VC - if let name = extractBasicName(from: navigationController?.topViewController) { return name } - - // Try SwiftUI hosting controller - if let name = extractSwiftUIHostingName() { return name } - - // Fall back to type name - return String(describing: type(of: self)) - } - - @objc dynamic func swizzled_present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) { - let fromScreen = self.meaningfulName - let modalScreen = viewControllerToPresent.meaningfulName - - self.swizzled_present(viewControllerToPresent, animated: animated, completion: completion) - - let navSignal = NavigationSignal(currentScreen: modalScreen, previousScreen: fromScreen) - Signals.emit(signal: navSignal, source: .autoUIKit) - - ModalSwizzler.shared.currentModalName = modalScreen - } - - @objc dynamic func swizzled_dismiss(animated: Bool, completion: (() -> Void)? = nil) { - let modalScreen = ModalSwizzler.shared.currentModalName ?? self.meaningfulName - let backToScreen = presentingViewController?.meaningfulName - - self.swizzled_dismiss(animated: animated, completion: completion) - - let navSignal = NavigationSignal( - currentScreen: backToScreen ?? "Unknown ", - previousScreen: modalScreen - ) - Signals.emit(signal: navSignal, source: .autoUIKit) - - ModalSwizzler.shared.currentModalName = nil - } -} - -#endif diff --git a/Sources/AnalyticsLive/Signals/Signals.swift b/Sources/AnalyticsLive/Signals/Signals.swift index c21ee4e..561227f 100644 --- a/Sources/AnalyticsLive/Signals/Signals.swift +++ b/Sources/AnalyticsLive/Signals/Signals.swift @@ -237,8 +237,6 @@ extension Signals { #if canImport(UIKit) && !os(watchOS) NavigationObserver.shared.stop() TabBarSwizzler.shared.stop() - NavigationSwizzler.shared.stop() - ModalSwizzler.shared.stop() TapSwizzler.shared.stop() #endif @@ -255,14 +253,13 @@ extension Signals { if configuration.useSwiftUIAutoSignal { // Start the new unified navigation observer NavigationObserver.shared.start() - // Also start tab bar swizzler for additional tab tracking TabBarSwizzler.shared.start() } if configuration.useUIKitAutoSignal { TabBarSwizzler.shared.start() - NavigationSwizzler.shared.start() + NavigationObserver.shared.start() TapSwizzler.shared.start() } #endif From e894894fc8642bdde0d84cbaf53968c8fb6877f8 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 30 Dec 2025 13:23:32 -0800 Subject: [PATCH 07/24] added UIKit nav example. --- .../UIKitNavExample.xcodeproj/project.pbxproj | 367 +++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 42 ++ .../UIKitNavExample/AppDelegate.swift | 55 +++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 ++ .../UIKitNavExample/Info.plist | 23 + .../UIKitNavExample/SceneDelegate.swift | 48 ++ .../UIKitNavExample/ViewControllers.swift | 419 ++++++++++++++++++ .../Navigation/NavigationObserver.swift | 15 +- Sources/AnalyticsLive/Signals/Signals.swift | 7 +- 13 files changed, 1052 insertions(+), 8 deletions(-) create mode 100644 Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj create mode 100644 Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift create mode 100644 Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json create mode 100644 Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard create mode 100644 Examples/UIKitNavExample/UIKitNavExample/Info.plist create mode 100644 Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift create mode 100644 Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift diff --git a/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9065362 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj @@ -0,0 +1,367 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 33DAC8872F0477990019EC55 /* AnalyticsLive in Frameworks */ = {isa = PBXBuildFile; productRef = 33DAC8862F0477990019EC55 /* AnalyticsLive */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 33DAC86B2F0476A60019EC55 /* UIKitNavExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIKitNavExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 33DAC87D2F0476A70019EC55 /* Exceptions for "UIKitNavExample" folder in "UIKitNavExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 33DAC86A2F0476A60019EC55 /* UIKitNavExample */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 33DAC86D2F0476A60019EC55 /* UIKitNavExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 33DAC87D2F0476A70019EC55 /* Exceptions for "UIKitNavExample" folder in "UIKitNavExample" target */, + ); + path = UIKitNavExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33DAC8682F0476A60019EC55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 33DAC8872F0477990019EC55 /* AnalyticsLive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33DAC8622F0476A60019EC55 = { + isa = PBXGroup; + children = ( + 33DAC86D2F0476A60019EC55 /* UIKitNavExample */, + 33DAC86C2F0476A60019EC55 /* Products */, + ); + sourceTree = ""; + }; + 33DAC86C2F0476A60019EC55 /* Products */ = { + isa = PBXGroup; + children = ( + 33DAC86B2F0476A60019EC55 /* UIKitNavExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33DAC86A2F0476A60019EC55 /* UIKitNavExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33DAC87E2F0476A70019EC55 /* Build configuration list for PBXNativeTarget "UIKitNavExample" */; + buildPhases = ( + 33DAC8672F0476A60019EC55 /* Sources */, + 33DAC8682F0476A60019EC55 /* Frameworks */, + 33DAC8692F0476A60019EC55 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 33DAC86D2F0476A60019EC55 /* UIKitNavExample */, + ); + name = UIKitNavExample; + packageProductDependencies = ( + 33DAC8862F0477990019EC55 /* AnalyticsLive */, + ); + productName = UIKitNavExample; + productReference = 33DAC86B2F0476A60019EC55 /* UIKitNavExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33DAC8632F0476A60019EC55 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 33DAC86A2F0476A60019EC55 = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 33DAC8662F0476A60019EC55 /* Build configuration list for PBXProject "UIKitNavExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33DAC8622F0476A60019EC55; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33DAC8852F0477990019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 33DAC86C2F0476A60019EC55 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33DAC86A2F0476A60019EC55 /* UIKitNavExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33DAC8692F0476A60019EC55 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33DAC8672F0476A60019EC55 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 33DAC87F2F0476A70019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = UIKitNavExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.UIKitNavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 33DAC8802F0476A70019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = UIKitNavExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.UIKitNavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 33DAC8812F0476A70019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33DAC8822F0476A70019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33DAC8662F0476A60019EC55 /* Build configuration list for PBXProject "UIKitNavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8812F0476A70019EC55 /* Debug */, + 33DAC8822F0476A70019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33DAC87E2F0476A70019EC55 /* Build configuration list for PBXNativeTarget "UIKitNavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC87F2F0476A70019EC55 /* Debug */, + 33DAC8802F0476A70019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33DAC8852F0477990019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../analytics-swift-live"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 33DAC8862F0477990019EC55 /* AnalyticsLive */ = { + isa = XCSwiftPackageProductDependency; + productName = AnalyticsLive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33DAC8632F0476A60019EC55 /* Project object */; +} diff --git a/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..82dea23 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "226ac2774b9590833966f6d13ad78a8190d097e5ace610e05206de14f85e3485", + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "f604cef21bb7269f76136c68bd664c6c993f8e28", + "version" : "1.9.1" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "substrata-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/substrata-swift.git", + "state" : { + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift b/Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift new file mode 100644 index 0000000..6702eae --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift @@ -0,0 +1,55 @@ +// +// AppDelegate.swift +// UIKitNavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import UIKit +import Segment +import AnalyticsLive + +extension Analytics { + static var main = Analytics(configuration: Configuration(writeKey: "") + .flushAt(3) + .setTrackedApplicationLifecycleEvents(.all)) +} + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // add the Analytics Live plugin to the timeline. + let lp = LivePlugins(fallbackFileURL: nil) + Analytics.main.add(plugin: lp) + + // add destination filters if desired ... + let filters = DestinationFilters() + Analytics.main.add(plugin: filters) + + // configure and add the Signals plugin if in use ... + let config = SignalsConfiguration( + writeKey: "", + //broadcasters: [DebugBroadcaster()], + useUIKitAutoSignal: true, + useNetworkAutoSignal: true) + + Signals.shared.useConfiguration(config) + Analytics.main.add(plugin: Signals.shared) + + // Enable debug logging for navigation observer + NavigationObserver.shared.debugLogging = true + + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard b/Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/UIKitNavExample/UIKitNavExample/Info.plist b/Examples/UIKitNavExample/UIKitNavExample/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift b/Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift new file mode 100644 index 0000000..84efdeb --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift @@ -0,0 +1,48 @@ +// +// SceneDelegate.swift +// UIKitNavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + + // Create tab bar controller + let tabBarController = UITabBarController() + + // Navigation tab + let navigationVC = NavigationTestViewController() + let navNavController = UINavigationController(rootViewController: navigationVC) + navNavController.tabBarItem = UITabBarItem(title: "Navigation", image: UIImage(systemName: "arrow.triangle.turn.up.right.diamond"), tag: 0) + + // Sheets tab + let sheetsVC = SheetTestViewController() + let sheetsNavController = UINavigationController(rootViewController: sheetsVC) + sheetsNavController.tabBarItem = UITabBarItem(title: "Sheets", image: UIImage(systemName: "square.stack"), tag: 1) + + // Settings tab + let settingsVC = SettingsViewController() + let settingsNavController = UINavigationController(rootViewController: settingsVC) + settingsNavController.tabBarItem = UITabBarItem(title: "Settings", image: UIImage(systemName: "gear"), tag: 2) + + tabBarController.viewControllers = [navNavController, sheetsNavController, settingsNavController] + + window?.rootViewController = tabBarController + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) {} + func sceneDidBecomeActive(_ scene: UIScene) {} + func sceneWillResignActive(_ scene: UIScene) {} + func sceneWillEnterForeground(_ scene: UIScene) {} + func sceneDidEnterBackground(_ scene: UIScene) {} +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift b/Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift new file mode 100644 index 0000000..117407f --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift @@ -0,0 +1,419 @@ +// +// ViewControllers.swift +// UIKitNavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import UIKit + +// MARK: - Navigation Test View Controller + +class NavigationTestViewController: UITableViewController { + + private let sections = [ + ("Basic Push", ["Detail View", "Another Detail"]), + ("Value-Based", ["Product: iPhone", "Product: MacBook", "Product: AirPods"]), + ("Programmatic", ["Push 3 screens"]), + ("Nested", ["Go to List View"]) + ] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Navigation Tests" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].1.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].0 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = sections[indexPath.section].1[indexPath.row] + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + switch (indexPath.section, indexPath.row) { + case (0, 0): + let vc = DetailViewController(titleText: "Detail View") + navigationController?.pushViewController(vc, animated: true) + case (0, 1): + let vc = DetailViewController(titleText: "Another Detail") + navigationController?.pushViewController(vc, animated: true) + case (1, 0): + let vc = ProductDetailViewController(product: Product(name: "iPhone", price: 999)) + navigationController?.pushViewController(vc, animated: true) + case (1, 1): + let vc = ProductDetailViewController(product: Product(name: "MacBook", price: 1999)) + navigationController?.pushViewController(vc, animated: true) + case (1, 2): + let vc = ProductDetailViewController(product: Product(name: "AirPods", price: 249)) + navigationController?.pushViewController(vc, animated: true) + case (2, 0): + // Push 3 screens programmatically + let vc1 = ProductDetailViewController(product: Product(name: "First", price: 1)) + let vc2 = ProductDetailViewController(product: Product(name: "Second", price: 2)) + let vc3 = ProductDetailViewController(product: Product(name: "Third", price: 3)) + navigationController?.pushViewController(vc1, animated: false) + navigationController?.pushViewController(vc2, animated: false) + navigationController?.pushViewController(vc3, animated: true) + case (3, 0): + let vc = NestedListViewController() + navigationController?.pushViewController(vc, animated: true) + default: + break + } + } +} + +// MARK: - Detail View Controller + +class DetailViewController: UIViewController { + + private let titleText: String + + init(titleText: String) { + self.titleText = titleText + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = titleText + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "This is \(titleText)" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Product Detail View Controller + +struct Product { + let name: String + let price: Int +} + +class ProductDetailViewController: UIViewController { + + private let product: Product + + init(product: Product) { + self.product = product + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = product.name + view.backgroundColor = .systemBackground + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 20 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + + let nameLabel = UILabel() + nameLabel.text = product.name + nameLabel.font = .systemFont(ofSize: 34, weight: .bold) + + let priceLabel = UILabel() + priceLabel.text = "$\(product.price)" + priceLabel.font = .systemFont(ofSize: 24) + priceLabel.textColor = .systemGreen + + stack.addArrangedSubview(nameLabel) + stack.addArrangedSubview(priceLabel) + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Nested List View Controller + +class NestedListViewController: UITableViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Nested List" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 3 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = "Nested Item \(indexPath.row + 1)" + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let vc = NestedDetailViewController(item: indexPath.row + 1) + navigationController?.pushViewController(vc, animated: true) + } +} + +class NestedDetailViewController: UIViewController { + + private let item: Int + + init(item: Int) { + self.item = item + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Item \(item)" + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "Nested Item \(item) Detail" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Sheet Test View Controller + +class SheetTestViewController: UITableViewController { + + private let options = ["Show Sheet", "Show Full Screen"] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sheet Tests" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return options.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = options[indexPath.row] + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + switch indexPath.row { + case 0: + let vc = SheetContentViewController() + let nav = UINavigationController(rootViewController: vc) + if let sheet = nav.sheetPresentationController { + sheet.detents = [.medium(), .large()] + } + present(nav, animated: true) + case 1: + let vc = FullScreenContentViewController() + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + default: + break + } + } +} + +class SheetContentViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sheet View" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneTapped) + ) + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 20 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = "This is a sheet!" + label.font = .systemFont(ofSize: 17, weight: .semibold) + + let button = UIButton(type: .system) + button.setTitle("Navigate inside sheet", for: .normal) + button.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside) + + stack.addArrangedSubview(label) + stack.addArrangedSubview(button) + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + @objc private func doneTapped() { + dismiss(animated: true) + } + + @objc private func navigateTapped() { + let vc = SheetDetailViewController() + navigationController?.pushViewController(vc, animated: true) + } +} + +class SheetDetailViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sheet Detail" + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "Pushed inside sheet!" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +class FullScreenContentViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Full Screen" + view.backgroundColor = .systemBackground + + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(closeTapped) + ) + + let label = UILabel() + label.text = "Full screen cover!" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} + +// MARK: - Settings View Controller (No title set!) + +class SettingsViewController: UITableViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Intentionally NO title here - let's see what we capture + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 4 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + if indexPath.row < 3 { + cell.textLabel?.text = "Setting \(indexPath.row + 1)" + cell.accessoryType = .none + } else { + cell.textLabel?.text = "About" + cell.accessoryType = .disclosureIndicator + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.row == 3 { + let vc = AboutViewController() + navigationController?.pushViewController(vc, animated: true) + } + } +} + +class AboutViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "About" + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "UIKitNavExample v1.0" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift index 5ab7f31..436822c 100644 --- a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift @@ -112,11 +112,12 @@ public class NavigationObserver { currentScreen = info // Emit navigation signal with full screen data + let source: SignalSource = info.isSwiftUI ? .autoSwiftUI : .autoUIKit let signal = NavigationSignal( currentScreen: info.toScreenData(), previousScreen: prev?.toScreenData() ) - Signals.emit(signal: signal, source: .autoSwiftUI) + Signals.emit(signal: signal, source: source) log("📱 ENTERED: \(info.bestName)") } @@ -157,11 +158,12 @@ public class NavigationObserver { currentScreen = returnTo // Emit navigation signal for returning to covered screen with full data + let source: SignalSource = returnTo.isSwiftUI ? .autoSwiftUI : .autoUIKit let signal = NavigationSignal( currentScreen: returnTo.toScreenData(), previousScreen: prev?.toScreenData() ) - Signals.emit(signal: signal, source: .autoSwiftUI) + Signals.emit(signal: signal, source: source) log("🔙 RETURNED TO: \(returnTo.bestName)") } @@ -241,10 +243,15 @@ struct ScreenInfo { let swiftUIViewName: String? let accessibilityLabel: String? let accessibilityIdentifier: String? + let isSwiftUI: Bool /// Is this just a container VC that we should ignore? var isContainerNoise: Bool { let noiseClasses = [ + // UIKit containers + "UINavigationController", + "UITabBarController", + // SwiftUI internal containers "UIKitTabBarController", "UIKitNavigationController", "TabHostingController", @@ -298,6 +305,7 @@ struct ScreenInfo { static func extract(from vc: UIViewController) -> ScreenInfo { let className = String(describing: type(of: vc)) let swiftUIName = extractSwiftUIViewName(from: className) + let isSwiftUI = className.contains("HostingController") return ScreenInfo( className: className, @@ -305,7 +313,8 @@ struct ScreenInfo { navItemTitle: vc.navigationItem.title, swiftUIViewName: swiftUIName, accessibilityLabel: vc.view?.accessibilityLabel, - accessibilityIdentifier: vc.view?.accessibilityIdentifier + accessibilityIdentifier: vc.view?.accessibilityIdentifier, + isSwiftUI: isSwiftUI ) } diff --git a/Sources/AnalyticsLive/Signals/Signals.swift b/Sources/AnalyticsLive/Signals/Signals.swift index 561227f..d5c83c8 100644 --- a/Sources/AnalyticsLive/Signals/Signals.swift +++ b/Sources/AnalyticsLive/Signals/Signals.swift @@ -250,16 +250,13 @@ extension Signals { internal func startConfiguredSwizzlers() { #if canImport(UIKit) && !os(watchOS) - if configuration.useSwiftUIAutoSignal { - // Start the new unified navigation observer + // NavigationObserver handles both SwiftUI and UIKit navigation + if configuration.useSwiftUIAutoSignal || configuration.useUIKitAutoSignal { NavigationObserver.shared.start() - // Also start tab bar swizzler for additional tab tracking TabBarSwizzler.shared.start() } if configuration.useUIKitAutoSignal { - TabBarSwizzler.shared.start() - NavigationObserver.shared.start() TapSwizzler.shared.start() } #endif From 53af55ef7f8037d1dfd0c9522c6ab46f10a0fe3e Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Thu, 1 Jan 2026 12:23:36 -0800 Subject: [PATCH 08/24] examples and updates --- Examples/SwiftUIControlTest | 1 - .../project.pbxproj | 356 +++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 42 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../Assets.xcassets/Contents.json | 6 + .../SwiftUIControlsExample/ContentView.swift | 163 +++++ .../SwiftUIControlsExampleApp.swift | 50 ++ .../Signals/AutoTracking/.DS_Store | Bin 10244 -> 14340 bytes .../Controls/ControlSignalSource.swift | 53 ++ .../Controls/InteractionSwizzler.swift | 678 ++++++++++++++++++ .../AutoTracking/SwiftUI/SignalToggle.swift | 238 +++++- .../AutoTracking/SwiftUI_Control_Backing.md | 72 ++ .../UIKit/SignalUIApplication.swift | 142 ---- .../UIKit/SignalUITabBarController.swift | 114 --- Sources/AnalyticsLive/Signals/Signals.swift | 13 +- 17 files changed, 1689 insertions(+), 292 deletions(-) delete mode 160000 Examples/SwiftUIControlTest create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift create mode 100644 Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift create mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/Controls/ControlSignalSource.swift create mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift create mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI_Control_Backing.md delete mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUIApplication.swift delete mode 100644 Sources/AnalyticsLive/Signals/AutoTracking/UIKit/SignalUITabBarController.swift diff --git a/Examples/SwiftUIControlTest b/Examples/SwiftUIControlTest deleted file mode 160000 index bbdcc11..0000000 --- a/Examples/SwiftUIControlTest +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bbdcc11da8b531345bec8ac1941d67327429677b diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8b2b651 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj @@ -0,0 +1,356 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 33DAC8A72F047C5D0019EC55 /* AnalyticsLive in Frameworks */ = {isa = PBXBuildFile; productRef = 33DAC8A62F047C5D0019EC55 /* AnalyticsLive */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 33DAC8972F047C480019EC55 /* SwiftUIControlsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIControlsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 33DAC8992F047C480019EC55 /* SwiftUIControlsExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftUIControlsExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33DAC8942F047C480019EC55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 33DAC8A72F047C5D0019EC55 /* AnalyticsLive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33DAC88E2F047C480019EC55 = { + isa = PBXGroup; + children = ( + 33DAC8992F047C480019EC55 /* SwiftUIControlsExample */, + 33DAC8982F047C480019EC55 /* Products */, + ); + sourceTree = ""; + }; + 33DAC8982F047C480019EC55 /* Products */ = { + isa = PBXGroup; + children = ( + 33DAC8972F047C480019EC55 /* SwiftUIControlsExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33DAC8962F047C480019EC55 /* SwiftUIControlsExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33DAC8A22F047C490019EC55 /* Build configuration list for PBXNativeTarget "SwiftUIControlsExample" */; + buildPhases = ( + 33DAC8932F047C480019EC55 /* Sources */, + 33DAC8942F047C480019EC55 /* Frameworks */, + 33DAC8952F047C480019EC55 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 33DAC8992F047C480019EC55 /* SwiftUIControlsExample */, + ); + name = SwiftUIControlsExample; + packageProductDependencies = ( + 33DAC8A62F047C5D0019EC55 /* AnalyticsLive */, + ); + productName = SwiftUIControlsExample; + productReference = 33DAC8972F047C480019EC55 /* SwiftUIControlsExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33DAC88F2F047C480019EC55 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 33DAC8962F047C480019EC55 = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 33DAC8922F047C480019EC55 /* Build configuration list for PBXProject "SwiftUIControlsExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33DAC88E2F047C480019EC55; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33DAC8A52F047C5D0019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 33DAC8982F047C480019EC55 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33DAC8962F047C480019EC55 /* SwiftUIControlsExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33DAC8952F047C480019EC55 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33DAC8932F047C480019EC55 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 33DAC8A02F047C490019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33DAC8A12F047C490019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 33DAC8A32F047C490019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUIControlsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 33DAC8A42F047C490019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUIControlsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33DAC8922F047C480019EC55 /* Build configuration list for PBXProject "SwiftUIControlsExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8A02F047C490019EC55 /* Debug */, + 33DAC8A12F047C490019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33DAC8A22F047C490019EC55 /* Build configuration list for PBXNativeTarget "SwiftUIControlsExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8A32F047C490019EC55 /* Debug */, + 33DAC8A42F047C490019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33DAC8A52F047C5D0019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../analytics-swift-live"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 33DAC8A62F047C5D0019EC55 /* AnalyticsLive */ = { + isa = XCSwiftPackageProductDependency; + productName = AnalyticsLive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33DAC88F2F047C480019EC55 /* Project object */; +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..82dea23 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "226ac2774b9590833966f6d13ad78a8190d097e5ace610e05206de14f85e3485", + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "f604cef21bb7269f76136c68bd664c6c993f8e28", + "version" : "1.9.1" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "substrata-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/substrata-swift.git", + "state" : { + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift new file mode 100644 index 0000000..8123ef3 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift @@ -0,0 +1,163 @@ +// +// ContentView.swift +// SwiftUIControlsExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI +import AnalyticsLive + +struct ContentView: View { + // State for interactive controls + @State private var textFieldValue = "" + @State private var secureFieldValue = "" + @State private var textEditorValue = "Some text" + @State private var toggleValue = false + @State private var sliderValue: Double = 0.5 + @State private var stepperValue: Int = 0 + @State private var pickerValue = 0 + @State private var datePickerValue = Date() + @State private var colorPickerValue = Color.blue + + @State private var hierarchyOutput: String = "Tap 'Inspect Hierarchy' to analyze" + + var body: some View { + NavigationStack { + List { + Section("Controls Under Test") { + // Button + Button("Test Button") { + print("Button tapped") + } + .accessibilityIdentifier("testButton") + + // TextField + TextField("Enter text", text: $textFieldValue) + .accessibilityIdentifier("testTextField") + + // SecureField + SecureField("Password", text: $secureFieldValue) + .accessibilityIdentifier("testSecureField") + + // TextEditor (needs fixed height in List) + TextEditor(text: $textEditorValue) + .frame(height: 80) + .accessibilityIdentifier("testTextEditor") + + // Toggle + SignalToggle("Test Toggle", isOn: $toggleValue) + .accessibilityIdentifier("testToggle") + + // Slider + VStack(alignment: .leading) { + Text("Slider: \(sliderValue, specifier: "%.2f")") + Slider(value: $sliderValue) + .accessibilityIdentifier("testSlider") + } + + // Stepper + Stepper("Value: \(stepperValue)", value: $stepperValue) + .accessibilityIdentifier("testStepper") + + // Picker (default/menu style) + Picker("Option", selection: $pickerValue) { + Text("Option A").tag(0) + Text("Option B").tag(1) + Text("Option C").tag(2) + } + .accessibilityIdentifier("testPicker") + + // Picker (segmented style) + Picker("Segmented", selection: $pickerValue) { + Text("A").tag(0) + Text("B").tag(1) + Text("C").tag(2) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("testSegmentedPicker") + + // DatePicker + DatePicker("Date", selection: $datePickerValue) + .accessibilityIdentifier("testDatePicker") + + // ColorPicker + ColorPicker("Color", selection: $colorPickerValue) + .accessibilityIdentifier("testColorPicker") + + // Menu + Menu("Test Menu") { + Button("Action 1") { } + Button("Action 2") { } + } + .accessibilityIdentifier("testMenu") + + // Link + Link("Test Link", destination: URL(string: "https://segment.com")!) + .accessibilityIdentifier("testLink") + } + + Section("Hierarchy Analysis") { + Button("Inspect Hierarchy") { + hierarchyOutput = inspectViewHierarchy() + } + .buttonStyle(.borderedProminent) + + Text(hierarchyOutput) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .navigationTitle("Control Inspector") + } + } + + func inspectViewHierarchy() -> String { + var output = "" + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + return "Could not access window" + } + + output += "=== UIKit View Hierarchy ===\n\n" + output += inspectView(window, depth: 0) + + return output + } + + func inspectView(_ view: UIView, depth: Int) -> String { + var output = "" + let indent = String(repeating: " ", count: depth) + let viewType = String(describing: type(of: view)) + + // Highlight interesting types + let isUIKit = viewType.hasPrefix("UI") && !viewType.contains("Hosting") + let isHosting = viewType.contains("Hosting") + let isSwiftUI = viewType.hasPrefix("_") || viewType.contains("SwiftUI") + + var marker = "" + if isUIKit && !isHosting { + marker = " 🟡 UIKit" + } + + // Get accessibility identifier if present + var accessId = "" + if let id = view.accessibilityIdentifier, !id.isEmpty { + accessId = " [id: \(id)]" + } + + output += "\(indent)\(viewType)\(accessId)\(marker)\n" + + // Recurse into subviews + for subview in view.subviews { + output += inspectView(subview, depth: depth + 1) + } + + return output + } +} + +#Preview { + ContentView() +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift new file mode 100644 index 0000000..9488b43 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift @@ -0,0 +1,50 @@ +// +// SwiftUIControlsExampleApp.swift +// SwiftUIControlsExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI +import Segment +import AnalyticsLive + +extension Analytics { + static var main = Analytics(configuration: Configuration(writeKey: "") + .flushAt(3) + .setTrackedApplicationLifecycleEvents(.all)) +} + +@main +struct SwiftUIControlsExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } + + init() { + // add the Analytics Live plugin to the timeline. + let lp = LivePlugins(fallbackFileURL: nil) + Analytics.main.add(plugin: lp) + + // add destination filters if desired ... + let filters = DestinationFilters() + Analytics.main.add(plugin: filters) + + // configure and add the Signals plugin if in use ... + let config = SignalsConfiguration( + writeKey: "", // this is your segment writeKey. + relayCount: 1, // lets us see signals quickly ... , the default is 20. + broadcasters: [DebugBroadcaster()], // see console output. Default is just SegmentBroadcaster. + useUIKitAutoSignal: true, // automatically forward UIKit interaction signals to your event generators. + useSwiftUIAutoSignal: true, // automatically forward SwiftUI control interaction signals to your event generators. + useNetworkAutoSignal: true) // automatically forward network req/recv signals to your event generators. + + Signals.shared.useConfiguration(config) + Analytics.main.add(plugin: Signals.shared) + + // Enable debug logging for navigation observer + NavigationObserver.shared.debugLogging = true + } +} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store b/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store index 0da1224e411c5cc7450946e0eb9c991d05377fdb..bcbd5d792a36ef2c834250f672a8363c3f91e41b 100755 GIT binary patch literal 14340 zcmeHNYit}>6+UO;$IRGH#*XdS?0UVn<2)S4Ufb)$PV=z6PHo4|bM5$%wC=8VVh>*L zI6G@Qts8IwMTmwsjc7{|AOUKj0fB%N-~@PHAO!H({p;LHyGA~)Gi(xovk?j{L9^9zE~hW z;Gdy7`>-uwTfnw}Z2{W?wguL20mi*_hy`5QXWIg{1#Al>EWqXmHzg*!;&O#cbLyZI zTf(z;wuCni%2OO**16-dD=t^KG*wB*R3*t(F&<(dSEqV6>rS#OE?2nZ>VRAwFn(u@ zM<{6Co%Gyhcfc+#?XztG+X9&ukf|zb>^(oz;-Avz=95(N!B6qM1{Ww!AAy13k})5T z&R+=5hczSS@w|q@rkvcod@D-Vm;A9nY{BRWM9=wW^rc~44+pJ$Ab3_&mnM}`pB{+? zw1^sIUHn=|r7q`3wMa-`>d_Y>A;Z*-H;9rbO)3@3%PsBRMt5t6_j03qxwF;N==Qel zyL?#^%j%l%J2N>Oo{Q-3<4=$ANdtCY3D)4Q*gbkDQ9Ci!|!dDLuR}7onEo zsX%yYfjX<*;jmwOL^UP?mxk3#F_s^T#WNFz9*y}-oiU9r!x|@*5_YMR^D#}27-!X} zK~j>g7nQr}b~d)`yRYl$!12?oo3|*%vRqm=x~LgiFszOTj43q|(jwC%OIj#4GeHKA z#Iy^V8kMACoEVM-=G4{VZ55Sob2y#6&uty+Dq{;l?c#zKdswLxMSc%wO|2vuO*?0x*qQ*X7}*QZ5o_#(oZHBChL)^Dqtbung~kC*di08lHo% z!Z+dj@MCxlegQY(b@&bZ0saF2L_`@2u?RQgHmtx(bl@&*!B%X;BiN4vcnpW|G)~|- zJdY3J6ozpQBRG$X_)c8G$MD_wIDQPDz-#yr!&b6?m~;-sw{E$PIY0!PV$e1oA#!Y<9KC$|mwH$<+0_ z8f67VUg(ey$Vv`*8804^kH`uz$LHl9SFcN9J|o@@^j?Q=z>9?5pAmY03%`dy!@n^X z3$TpPT8H(x19xKsA+{a&VFz|%5BB0w>?8c1Mn4YYSscZOFi7ZC@dE025u<3}qxcTO z@MHKcLh$?X1NdS52tJL^;3x4@_-PZ%U&b40=p0N%C;liCos0fxB&HY;$P9hJwE zxMn>JmW{Tu`g9foOH7i|^}@2M+8uj5?FWwZ_79y}WlkxV$fcHdq*GHekrm@Ui4&Pp zZu*)dy|;ChR3bC)QSGRa$dgiB*}S`>+EJfIWTwI{M}wpUL{4O8&0e{gc^D@$R^w=u zy`mD5;zVXv?Uy@6Wl*x_5`K$=@?nvjUP??jyt>=rV?LQakC@l#cJ!qZS-f^IE0HmS z9ItjbQi!age4FAp;}~8WkxAJItP*O$3kTs8j6;YPidW$YxCWnv>+p5>7Q9STz^m|c zng(8n-@zZ@Z{|dhk0MG~f?H`qD5nXb5%*#f9zY*W1NYN3FoYvC1&rZ15!f`&;M;MQ zrhrFq886cma0NezAHt6ki9Lg#!_VWh_#EEA@8Eawd-zkliMQ~V_$T~3{zm`-1z9K% ziiFKVjZiD>Ap)b%U+hTG*90$HXQ793ss3V7*Tro9QgwfyJg}kua;uQNcayeUb-VVq z(gH96qPxRd+?C`i&RTc!C?%iaS6pSitKD*yl1sSf#qIS}%p=V6;_im3Dn%eyNxfCxRJVZrcUe3$iJ#;0Lw9;5>9Z-E1z5*}6OYjqd z>Ki6o{R08>XpL2lE_4$-w-aMEp~qZfdGQdfv$~0~d<4@Ic#0V746U^uz)51P06~?n zx0di>g6pFM*em#6;;XB~Sl965_!KeLCrsed%8P?80mWk$2)V=;Q=yo~Q>mTpl5K&t zTY!Ju_t97T2u1Z#l3_45ezmi|jaECJ&i`?>BSPB1>@^SH0aTmP{A>i{fLoIXu5Mv~YpiNiK#ce=H`(cRY4_9hgzmfh*B zW6LuBjDP3C(;55sS@-$p^xlkr?D9oPw&Q<0{=ef!_Bw(pJN{q87oWScUuMVuY}49~ z|JNS<**)LN7Rcp;_ptx}j^6qI|F^P**}ryfN)J(D zeO{u(4$7M SignalSource { + var current: UIView? = view.superview + + while let v = current { + let typeName = String(describing: type(of: v)) + + // These indicate the view is hosted within SwiftUI + if typeName.contains("UIKitPlatformViewHost") || + typeName.contains("PlatformViewRepresentableAdaptor") || + typeName.contains("HostingView") { + return .autoSwiftUI + } + + current = v.superview + } + + return .autoUIKit + } + + /// Convenience method that accepts an optional view + /// + /// - Parameter view: The UIKit view to check (optional) + /// - Returns: `.autoSwiftUI` if the view is hosted by SwiftUI, `.autoUIKit` otherwise + public static func detect(for view: UIView?) -> SignalSource { + guard let view = view else { return .autoUIKit } + return detect(for: view) + } +} + +#endif diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift new file mode 100644 index 0000000..1187e67 --- /dev/null +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift @@ -0,0 +1,678 @@ +// +// InteractionSwizzler.swift +// AnalyticsLive +// +// Created by Brandon Sneed on 12/30/25. +// + +#if canImport(UIKit) && !os(watchOS) + +import UIKit +import Segment + +/// Unified swizzler for all UI interactions. +/// Captures interactions for: +/// - UIControl subclasses: UISwitch, UISlider, UIStepper, UISegmentedControl, +/// UIDatePicker, UIPageControl, UIColorWell, UITextField, UIButton +/// - Cell selection: UITableViewCell, UICollectionViewCell +/// - Tab selection: UITabBarController +/// +/// Does NOT emit signals for SwiftUI-backed controls - those are handled by +/// Signal* wrappers (SignalToggle, SignalSlider, etc.) via onChange. +internal class InteractionSwizzler { + static let shared = InteractionSwizzler() + private var controlHandle: Swizzler.SwizzleHandle? + private var tableViewCellHandle: Swizzler.SwizzleHandle? + private var collectionViewCellHandle: Swizzler.SwizzleHandle? + private var tabBarHandle: Swizzler.SwizzleHandle? + @Atomic private var isRunning = false + + private init() {} + + func start() { + guard !isRunning else { return } + _isRunning.set(true) + + // Swizzle UIControl.sendActions(for:) for all control interactions + controlHandle = Swizzler.swizzle( + originalClass: UIControl.self, + originalSelector: #selector(UIControl.sendActions(for:)), + swizzledSelector: #selector(UIControl.signals_sendActions(for:)) + ) + + // Swizzle UITableViewCell.setSelected(_:animated:) for table cell selection + tableViewCellHandle = Swizzler.swizzle( + originalClass: UITableViewCell.self, + originalSelector: #selector(UITableViewCell.setSelected(_:animated:)), + swizzledSelector: #selector(UITableViewCell.signals_setSelected(_:animated:)) + ) + + // Swizzle UICollectionViewCell.isSelected setter for collection cell selection + collectionViewCellHandle = Swizzler.swizzle( + originalClass: UICollectionViewCell.self, + originalSelector: #selector(setter: UICollectionViewCell.isSelected), + swizzledSelector: #selector(UICollectionViewCell.signals_setIsSelected(_:)) + ) + + // Swizzle UITabBarController for tab selection + let tabSelector = Selector(("_setSelectedViewController:")) + tabBarHandle = Swizzler.swizzle( + originalClass: UITabBarController.self, + originalSelector: tabSelector, + swizzledSelector: #selector(UITabBarController.signals_setSelectedViewController(_:)) + ) + } + + func stop() { + if var handle = controlHandle { + handle.restore() + } + controlHandle = nil + + if var handle = tableViewCellHandle { + handle.restore() + } + tableViewCellHandle = nil + + if var handle = collectionViewCellHandle { + handle.restore() + } + collectionViewCellHandle = nil + + if var handle = tabBarHandle { + handle.restore() + } + tabBarHandle = nil + + _isRunning.set(false) + } +} + +// MARK: - UIControl Swizzled Methods + +extension UIControl { + @objc dynamic func signals_sendActions(for controlEvents: UIControl.Event) { + // Call original implementation first + signals_sendActions(for: controlEvents) + + // If this is SwiftUI-backed, bail out - Signal* wrappers handle it via onChange + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + return + } + + // Dispatch based on control type and event + if controlEvents.contains(.valueChanged) { + handleValueChanged() + } + + if controlEvents.contains(.editingDidEnd) { + handleEditingDidEnd() + } + + if controlEvents.contains(.touchUpInside) { + handleTouchUpInside() + } + } + + // MARK: - Value Changed Handlers + + private func handleValueChanged() { + switch self { + case let toggle as UISwitch: + emitToggleSignal(toggle) + case let slider as UISlider: + emitSliderSignal(slider) + case let stepper as UIStepper: + emitStepperSignal(stepper) + case let segmented as UISegmentedControl: + emitSegmentedControlSignal(segmented) + case let datePicker as UIDatePicker: + emitDatePickerSignal(datePicker) + case let pageControl as UIPageControl: + emitPageControlSignal(pageControl) + default: + // iOS 14+ controls + if #available(iOS 14.0, *) { + if let colorWell = self as? UIColorWell { + emitColorWellSignal(colorWell) + } + } + } + } + + private func handleEditingDidEnd() { + switch self { + case let textField as UITextField: + emitTextFieldSignal(textField) + default: + break + } + } + + private func handleTouchUpInside() { + switch self { + case let button as UIButton: + emitButtonSignal(button) + default: + break + } + } + + // MARK: - Signal Emitters + + private func emitToggleSignal(_ toggle: UISwitch) { + let title = extractTitle() ?? toggle.accessibilityLabel + var data: [String: Any] = [ + "action": "toggled", + "value": toggle.isOn + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Toggle", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitButtonSignal(_ button: UIButton) { + let title = extractTitle() ?? button.accessibilityLabel ?? button.currentTitle + var data: [String: Any] = [ + "action": "tap" + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Button", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitSliderSignal(_ slider: UISlider) { + let title = extractTitle() ?? slider.accessibilityLabel + var data: [String: Any] = [ + "action": "changed", + "value": slider.value, + "minimum": slider.minimumValue, + "maximum": slider.maximumValue + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Slider", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitStepperSignal(_ stepper: UIStepper) { + let title = extractTitle() ?? stepper.accessibilityLabel + var data: [String: Any] = [ + "action": "changed", + "value": stepper.value, + "minimum": stepper.minimumValue, + "maximum": stepper.maximumValue, + "step": stepper.stepValue + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Stepper", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitSegmentedControlSignal(_ segmented: UISegmentedControl) { + let title = extractTitle() ?? segmented.accessibilityLabel + let selectedTitle = segmented.titleForSegment(at: segmented.selectedSegmentIndex) + + var data: [String: Any] = [ + "action": "selected", + "selectedIndex": segmented.selectedSegmentIndex, + "segmentCount": segmented.numberOfSegments + ] + if let selectedTitle = selectedTitle { + data["selectedTitle"] = selectedTitle + } + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "SegmentedControl", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitDatePickerSignal(_ datePicker: UIDatePicker) { + let title = extractTitle() ?? datePicker.accessibilityLabel + let formatter = ISO8601DateFormatter() + + var data: [String: Any] = [ + "action": "changed", + "value": formatter.string(from: datePicker.date), + "mode": datePickerModeString(datePicker.datePickerMode) + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "DatePicker", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitPageControlSignal(_ pageControl: UIPageControl) { + let title = extractTitle() ?? pageControl.accessibilityLabel + var data: [String: Any] = [ + "action": "changed", + "currentPage": pageControl.currentPage, + "numberOfPages": pageControl.numberOfPages + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "PageControl", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + @available(iOS 14.0, *) + private func emitColorWellSignal(_ colorWell: UIColorWell) { + let title = extractTitle() ?? colorWell.accessibilityLabel + var data: [String: Any] = [ + "action": "changed" + ] + + if let color = colorWell.selectedColor { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + data["value"] = [ + "red": red, + "green": green, + "blue": blue, + "alpha": alpha + ] + } + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "ColorPicker", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitTextFieldSignal(_ textField: UITextField) { + let title = extractTitle() ?? textField.accessibilityLabel ?? textField.placeholder + + var data: [String: Any] = [ + "action": "edited", + "secure": textField.isSecureTextEntry + ] + + // Don't include actual text for secure fields - just metadata + if textField.isSecureTextEntry { + data["value"] = "" // Never include secure text + } + data["isEmpty"] = textField.text?.isEmpty ?? true + data["characterCount"] = textField.text?.count ?? 0 + + if let placeholder = textField.placeholder, !placeholder.isEmpty { + data["placeholder"] = placeholder + } + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: textField.isSecureTextEntry ? "SecureField" : "TextField", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + // MARK: - Helpers + + private func datePickerModeString(_ mode: UIDatePicker.Mode) -> String { + switch mode { + case .time: return "time" + case .date: return "date" + case .dateAndTime: return "dateAndTime" + case .countDownTimer: return "countDownTimer" + case .yearAndMonth: return "yearAndMonth" + @unknown default: return "unknown" + } + } + + private func addAccessibilityInfo(to data: inout [String: Any]) { + if let label = accessibilityLabel, !label.isEmpty { + data["accessibilityLabel"] = label + } + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + } + + /// Attempts to extract a meaningful title for the control + fileprivate func extractTitle() -> String? { + // Check accessibility label first + if let label = accessibilityLabel, !label.isEmpty { + return label + } + + // Try accessibility identifier + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + return identifier + } + + // Walk up to find a cell or container, then search for labels + if let title = findLabelInContainer() { + return title + } + + return nil + } + + /// Walks up the hierarchy to find a container, then searches for labels within it + private func findLabelInContainer() -> String? { + var current: UIView? = superview + + while let view = current { + let typeName = String(describing: type(of: view)) + + // Stop at cell boundaries or common containers + if typeName.contains("Cell") || + typeName.contains("TableViewCell") || + typeName.contains("CollectionViewCell") { + return findFirstLabel(in: view, excluding: self) + } + + current = view.superview + } + + return nil + } + + /// Recursively searches a view hierarchy for the first UILabel with text + fileprivate func findFirstLabel(in view: UIView, excluding: UIView) -> String? { + if view === excluding { return nil } + + if let label = view as? UILabel, + let text = label.text, + !text.isEmpty { + return text + } + + for subview in view.subviews { + if let found = findFirstLabel(in: subview, excluding: excluding) { + return found + } + } + + return nil + } +} + +// MARK: - UITableViewCell Swizzled Methods + +extension UITableViewCell { + @objc dynamic func signals_setSelected(_ selected: Bool, animated: Bool) { + // Call original implementation first + signals_setSelected(selected, animated: animated) + + // Only emit on selection, not deselection + guard selected else { return } + + // If this is SwiftUI-backed, bail out + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + return + } + + let title = extractCellTitle() + var data: [String: Any] = [ + "action": "selected", + "isEditing": isEditing, + "showingDeleteConfirmation": showingDeleteConfirmation + ] + + // Try to get index path from parent table view + if let tableView = findParentTableView(), + let indexPath = tableView.indexPath(for: self) { + data["section"] = indexPath.section + data["row"] = indexPath.row + } + + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + + let signal = InteractionSignal( + component: "TableViewCell", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func extractCellTitle() -> String? { + // Check accessibility label first + if let label = accessibilityLabel, !label.isEmpty { + return label + } + + // Try textLabel (standard cell) + if let text = textLabel?.text, !text.isEmpty { + return text + } + + // Find first UILabel in hierarchy + return findFirstLabel(in: contentView) + } + + private func findFirstLabel(in view: UIView) -> String? { + if let label = view as? UILabel, + let text = label.text, + !text.isEmpty { + return text + } + + for subview in view.subviews { + if let found = findFirstLabel(in: subview) { + return found + } + } + + return nil + } + + private func findParentTableView() -> UITableView? { + var current: UIView? = superview + while let view = current { + if let tableView = view as? UITableView { + return tableView + } + current = view.superview + } + return nil + } +} + +// MARK: - UICollectionViewCell Swizzled Methods + +extension UICollectionViewCell { + @objc dynamic func signals_setIsSelected(_ selected: Bool) { + // Call original implementation first + signals_setIsSelected(selected) + + // Only emit on selection, not deselection + guard selected else { return } + + // If this is SwiftUI-backed, bail out + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + return + } + + let title = extractCellTitle() + var data: [String: Any] = [ + "action": "selected" + ] + + // Try to get index path from parent collection view + if let collectionView = findParentCollectionView(), + let indexPath = collectionView.indexPath(for: self) { + data["section"] = indexPath.section + data["item"] = indexPath.item + } + + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + + let signal = InteractionSignal( + component: "CollectionViewCell", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func extractCellTitle() -> String? { + // Check accessibility label first + if let label = accessibilityLabel, !label.isEmpty { + return label + } + + // Find first UILabel in hierarchy + return findFirstLabel(in: contentView) + } + + private func findFirstLabel(in view: UIView) -> String? { + if let label = view as? UILabel, + let text = label.text, + !text.isEmpty { + return text + } + + for subview in view.subviews { + if let found = findFirstLabel(in: subview) { + return found + } + } + + return nil + } + + private func findParentCollectionView() -> UICollectionView? { + var current: UIView? = superview + while let view = current { + if let collectionView = view as? UICollectionView { + return collectionView + } + current = view.superview + } + return nil + } +} + +// MARK: - UITabBarController Swizzled Methods + +extension UITabBarController { + @objc dynamic func signals_setSelectedViewController(_ viewController: UIViewController?) { + // Get index before we call original + var oldIndex = selectedIndex + // On first time through, it's some giant uninitialized value + // If it's over 100, assume we're at initial state + if oldIndex > 100 { oldIndex = 0 } + let newIndex = viewControllers?.firstIndex(of: viewController ?? UIViewController()) ?? NSNotFound + + // Call original implementation + self.signals_setSelectedViewController(viewController) + + // Only emit if we have a valid index change + if newIndex != NSNotFound && newIndex != oldIndex { + var data: [String: Any] = [ + "action": "tabSelected", + "previousTab": oldIndex, + "selectedTab": newIndex + ] + + // Try to get tab names if available + if let items = tabBar.items { + if newIndex < items.count { + let newItem = items[newIndex] + if let title = newItem.accessibilityLabel ?? newItem.title { + data["selectedTabName"] = title + } + } + + if oldIndex < items.count { + let oldItem = items[oldIndex] + if let title = oldItem.accessibilityLabel ?? oldItem.title { + data["previousTabName"] = title + } + } + } + + // Detect if this is SwiftUI TabView or UIKit UITabBarController + let source = detectSource() + + let signal = InteractionSignal( + component: "TabBar", + title: nil, + data: data + ) + Signals.emit(signal: signal, source: source) + } + } + + /// Detects if this UITabBarController is hosted by SwiftUI + private func detectSource() -> SignalSource { + // First check the tabBarController's own view hierarchy + if let source = checkViewHierarchy(view) { + return source + } + + // Also check if any child VCs are SwiftUI hosting controllers + for vc in viewControllers ?? [] { + let className = String(describing: type(of: vc)) + if className.contains("HostingController") { + return .autoSwiftUI + } + } + + return .autoUIKit + } + + private func checkViewHierarchy(_ view: UIView?) -> SignalSource? { + var current: UIView? = view?.superview + + while let v = current { + let typeName = String(describing: type(of: v)) + + if typeName.contains("UIKitPlatformViewHost") || + typeName.contains("PlatformViewRepresentableAdaptor") || + typeName.contains("HostingView") { + return .autoSwiftUI + } + + current = v.superview + } + + return nil + } +} + +#endif diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalToggle.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalToggle.swift index 83bb8c1..19028e5 100644 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalToggle.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalToggle.swift @@ -6,37 +6,48 @@ // import SwiftUI +import Foundation + +// MARK: - LocalizedStringResource Extension +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension LocalizedStringResource { + /// Extract the string from a LocalizedStringResource + var string: String { + String(localized: self) + } +} + +// MARK: - SignalToggle @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public struct SignalToggle