├── .gitignore ├── ImpressionKitExample ├── ImpressionKitExample │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── SwiftUIScrollViewDemoView.swift │ ├── AppDelegate.swift │ ├── SwiftUIListDemoView.swift │ ├── Info.plist │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── ScrollViewDemoViewController.swift │ ├── TableViewDemoViewController.swift │ ├── CollectionViewDemoViewController.swift │ ├── CollectionViewDemo2ViewController.swift │ └── HomeViewController.swift ├── ImpressionKitExample.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ ├── xcshareddata │ │ └── xcschemes │ │ │ └── ImpressionKitExample.xcscheme │ └── project.pbxproj ├── ImpressionKitExample.xcworkspace │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── contents.xcworkspacedata ├── Podfile └── Podfile.lock ├── Podfile ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── ImpressionKit.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ └── ImpressionKit.xcscheme └── project.pbxproj ├── ImpressionKit.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Podfile.lock ├── ImpressionKit ├── ImpressionKit.h ├── Info.plist ├── ImpressionKitDebug.swift ├── ImpressionKit+ViewModifier.swift ├── ImpressionGroup.swift ├── UIViewPropertyExtension.swift └── UIViewFunctionExtension.swift ├── Package.resolved ├── ImpressionKit.podspec ├── LICENSE ├── Package.swift ├── README.zh-Hans.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Pods 3 | xcuserdata 4 | Build 5 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | source 'https://github.com/CocoaPods/Specs.git' 2 | platform :ios, '12.0' 3 | 4 | target 'ImpressionKit' do 5 | use_frameworks! 6 | pod 'EasySwiftHook', "~> 3.5.3" 7 | 8 | end 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ImpressionKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ImpressionKit.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ImpressionKit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ImpressionKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - EasySwiftHook (3.5.3): 3 | - libffi_apple (~> 3.4.7) 4 | - libffi_apple (3.4.7) 5 | 6 | DEPENDENCIES: 7 | - EasySwiftHook (~> 3.5.3) 8 | 9 | SPEC REPOS: 10 | https://github.com/CocoaPods/Specs.git: 11 | - EasySwiftHook 12 | - libffi_apple 13 | 14 | SPEC CHECKSUMS: 15 | EasySwiftHook: 60c36c9b65f7e177db97adb8e634a7ce6753f768 16 | libffi_apple: b124fcff981c2cc81100c416b87473f9544bad8b 17 | 18 | PODFILE CHECKSUM: 8b4db16f36f0e83b9b53ce6262fbd96c3f6a2d1d 19 | 20 | COCOAPODS: 1.16.2 21 | -------------------------------------------------------------------------------- /ImpressionKit/ImpressionKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // ImpressionKit.h 3 | // ImpressionKit 4 | // 5 | // Created by Yanni Wang on 30/5/21. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for ImpressionKit. 11 | FOUNDATION_EXPORT double ImpressionKitVersionNumber; 12 | 13 | //! Project version string for ImpressionKit. 14 | FOUNDATION_EXPORT const unsigned char ImpressionKitVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /ImpressionKitExample/Podfile: -------------------------------------------------------------------------------- 1 | source 'https://github.com/CocoaPods/Specs.git' 2 | platform :ios, '12.0' 3 | 4 | target 'ImpressionKitExample' do 5 | use_frameworks! 6 | 7 | pod 'ImpressionKit', :path=>'../' 8 | pod 'GDPerformanceView-Swift', '~> 2.0.3' 9 | pod 'CHTCollectionViewWaterfallLayout', '~> 0.9.7' 10 | pod 'Eureka' 11 | 12 | end 13 | 14 | post_install do |installer| 15 | installer.generated_projects.each do |project| 16 | project.targets.each do |target| 17 | target.build_configurations.each do |config| 18 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "libffi_iOS", 6 | "repositoryURL": "https://github.com/623637646/libffi.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "c006945112296b8dc4c47bf70cd3ad5fc31488cf", 10 | "version": "3.3.6-iOS" 11 | } 12 | }, 13 | { 14 | "package": "SwiftHook", 15 | "repositoryURL": "https://github.com/623637646/SwiftHook.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "2c03fa626fd042b5244e75fcf5ab42d6937734a2", 19 | "version": "3.3.0" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /ImpressionKit/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /ImpressionKit.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = "ImpressionKit" 4 | s.version = "3.7.7" 5 | s.summary = "A tool to detect impression events for UIView (exposure of UIView) in iOS." 6 | 7 | s.description = <<-DESC 8 | This is a user behavior tracking (UBT) tool to analyze impression events for UIView (exposure of UIView) in iOS. 9 | DESC 10 | 11 | s.homepage = "https://github.com/623637646/ImpressionKit" 12 | 13 | s.license = "MIT" 14 | 15 | s.author = { "Yanni Wang" => "wy19900729@gmail.com" } 16 | 17 | s.platform = :ios, "12.0" 18 | 19 | s.swift_versions = "5" 20 | 21 | s.source = { :git => "https://github.com/623637646/ImpressionKit.git", :tag => "#{s.version}" } 22 | 23 | s.source_files = "ImpressionKit/**/*.{swift}" 24 | 25 | s.dependency "EasySwiftHook", "~> 3.5.3" 26 | 27 | end 28 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/SwiftUIScrollViewDemoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIScrollViewDemoView.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by PangMo5 on 2021/07/22. 6 | // 7 | 8 | import Foundation 9 | import ImpressionKit 10 | import SwiftUI 11 | 12 | @available(iOS 13.0, *) 13 | struct SwiftUIScrollViewDemoView: View { 14 | var body: some View { 15 | ScrollView{ 16 | ForEach(0 ..< 100) { _ in 17 | CellView() 18 | } 19 | } 20 | } 21 | } 22 | 23 | @available(iOS 13.0, *) 24 | extension SwiftUIScrollViewDemoView { 25 | struct CellView: View { 26 | @State var state: UIView.ImpressionState = .unknown 27 | var body: some View { 28 | (state.isImpressed ? Color.green : Color.red) 29 | .frame(height: 44) 30 | .detectImpression { (state) in 31 | self.state = state 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 王氩 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ImpressionKit", 8 | platforms: [.iOS(.v12)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "ImpressionKit", 13 | targets: ["ImpressionKit"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | .package(url: "https://github.com/623637646/SwiftHook.git", "3.5.3"..<"4.0.0") 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | 23 | // Source Code 24 | .target( 25 | name: "ImpressionKit", 26 | dependencies: [.product(name: "SwiftHook", package: "SwiftHook")], 27 | path: "ImpressionKit", 28 | exclude: ["ImpressionKit.h", "Info.plist"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /ImpressionKitExample/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - CHTCollectionViewWaterfallLayout (0.9.10): 3 | - CHTCollectionViewWaterfallLayout/Swift (= 0.9.10) 4 | - CHTCollectionViewWaterfallLayout/Swift (0.9.10) 5 | - EasySwiftHook (3.5.3): 6 | - libffi_apple (~> 3.4.7) 7 | - Eureka (5.5.0) 8 | - GDPerformanceView-Swift (2.0.3) 9 | - ImpressionKit (3.7.7): 10 | - EasySwiftHook (~> 3.5.3) 11 | - libffi_apple (3.4.7) 12 | 13 | DEPENDENCIES: 14 | - CHTCollectionViewWaterfallLayout (~> 0.9.7) 15 | - Eureka 16 | - GDPerformanceView-Swift (~> 2.0.3) 17 | - ImpressionKit (from `../`) 18 | 19 | SPEC REPOS: 20 | https://github.com/CocoaPods/Specs.git: 21 | - CHTCollectionViewWaterfallLayout 22 | - EasySwiftHook 23 | - Eureka 24 | - GDPerformanceView-Swift 25 | - libffi_apple 26 | 27 | EXTERNAL SOURCES: 28 | ImpressionKit: 29 | :path: "../" 30 | 31 | SPEC CHECKSUMS: 32 | CHTCollectionViewWaterfallLayout: b989a459788d7657ac010e03bc0f7eedca95a420 33 | EasySwiftHook: 60c36c9b65f7e177db97adb8e634a7ce6753f768 34 | Eureka: 1c18c7fcd8f772cc2ca42d6be36292dffa77eecb 35 | GDPerformanceView-Swift: ef3d05deb103e472a5494d3f60aeca5a58b88a7d 36 | ImpressionKit: 717c5ecb48b576ffb0c8f0eb743a6925189e8af5 37 | libffi_apple: b124fcff981c2cc81100c416b87473f9544bad8b 38 | 39 | PODFILE CHECKSUM: c89964fe43fcd7b8702d18e00e485d43e7f9bd63 40 | 41 | COCOAPODS: 1.16.2 42 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by Yanni Wang on 30/5/21. 6 | // 7 | 8 | import UIKit 9 | import GDPerformanceView_Swift 10 | import ImpressionKit 11 | 12 | @main 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | 19 | UIView.detectionInterval = HomeViewController.detectionInterval 20 | UIView.durationThreshold = HomeViewController.durationThreshold 21 | UIView.areaRatioThreshold = HomeViewController.areaRatioThreshold 22 | UIView.alphaThreshold = HomeViewController.alphaThreshold 23 | UIView.redetectOptions = HomeViewController.redetectOptions 24 | ImpressionKitDebug.shared.openLogs() 25 | 26 | PerformanceMonitor.shared().start() 27 | 28 | let navigationController = UINavigationController(rootViewController: HomeViewController()) 29 | let window = UIWindow(frame: UIScreen.main.bounds) 30 | window.rootViewController = navigationController 31 | self.window = window 32 | window.makeKeyAndVisible() 33 | 34 | return true 35 | } 36 | 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/SwiftUIListDemoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIListDemoView.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by PangMo5 on 2021/07/22. 6 | // 7 | 8 | import Foundation 9 | import ImpressionKit 10 | import SwiftUI 11 | 12 | @available(iOS 13.0, *) 13 | final class SwiftUIListDemoViewModel: ObservableObject { 14 | lazy var group = ImpressionGroup.init { [weak self] (_, index: Int, _, state) in 15 | if state.isImpressed { 16 | print("impressed index: \(index)") 17 | } 18 | self?.list[index].1 = state 19 | } 20 | 21 | @Published 22 | var list = (0 ..< 100).map { index in (index, UIView.ImpressionState.unknown) } 23 | } 24 | 25 | @available(iOS 13.0, *) 26 | struct SwiftUIListDemoView: View { 27 | @ObservedObject 28 | var viewModel = SwiftUIListDemoViewModel() 29 | 30 | var body: some View { 31 | List(viewModel.list, id: \.0) { index, state in 32 | CellView(index: index) 33 | .frame(height: 100) 34 | .background(state.isImpressed ? Color.green : Color.red) 35 | .detectImpression(group: viewModel.group, index: index) 36 | } 37 | } 38 | } 39 | 40 | @available(iOS 13.0, *) 41 | extension SwiftUIListDemoView { 42 | struct CellView: View { 43 | let index: Int 44 | 45 | var body: some View { 46 | Text(String(index)) 47 | .frame(maxWidth: .infinity, alignment: .center) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSupportsIndirectInputEvents 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ImpressionKit.xcodeproj/xcshareddata/xcschemes/ImpressionKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /ImpressionKit/ImpressionKitDebug.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Debug.swift 3 | // ImpressionKit 4 | // 5 | // Created by Yanni Wang on 31/5/21. 6 | // 7 | 8 | #if DEBUG 9 | import UIKit 10 | #if SWIFT_PACKAGE 11 | import SwiftHook 12 | #else 13 | import EasySwiftHook 14 | #endif 15 | 16 | public class ImpressionKitDebug { 17 | 18 | public static let shared = ImpressionKitDebug() 19 | 20 | internal var timerCount: UInt = 0 21 | internal var loggedTimerCount: UInt? 22 | 23 | private var viewControllerCount: UInt = 0 24 | private var loggedViewControllerCount: UInt? 25 | 26 | private var viewCount: UInt = 0 27 | private var loggedViewCount: UInt? 28 | 29 | private init() {} 30 | private static let dispatchOnce: () = { 31 | ImpressionKitDebug.shared.hook() 32 | ImpressionKitDebug.shared.timer() 33 | }() 34 | 35 | public func openLogs() { 36 | _ = ImpressionKitDebug.dispatchOnce 37 | } 38 | 39 | private func timer() { 40 | let timer = Timer.init(timeInterval: 0.1, repeats: true, block: { (_) in 41 | let timerCount = ImpressionKitDebug.shared.timerCount 42 | if let loggedTimerCount = ImpressionKitDebug.shared.loggedTimerCount, 43 | loggedTimerCount == timerCount {} else { 44 | print("[ImpressionKit] Timer: \(timerCount)") 45 | ImpressionKitDebug.shared.loggedTimerCount = timerCount 46 | } 47 | 48 | let viewControllerCount = ImpressionKitDebug.shared.viewControllerCount 49 | if let loggedViewControllerCount = ImpressionKitDebug.shared.loggedViewControllerCount, 50 | loggedViewControllerCount == viewControllerCount {} else { 51 | print("[ImpressionKit] UIViewController: \(viewControllerCount)") 52 | ImpressionKitDebug.shared.loggedViewControllerCount = viewControllerCount 53 | } 54 | 55 | let viewCount = ImpressionKitDebug.shared.viewCount 56 | if let loggedViewCount = ImpressionKitDebug.shared.loggedViewCount, 57 | loggedViewCount == viewCount {} else { 58 | print("[ImpressionKit] UIView: \(viewCount)") 59 | ImpressionKitDebug.shared.loggedViewCount = viewCount 60 | } 61 | }) 62 | RunLoop.main.add(timer, forMode: .common) 63 | } 64 | 65 | private func hook() { 66 | try! hookAfter(targetClass: UIResponder.self, selector: #selector(UIResponder.init)) { object, selector in 67 | if object is UIViewController { 68 | ImpressionKitDebug.shared.viewControllerCount += 1 69 | } else if object is UIView { 70 | ImpressionKitDebug.shared.viewCount += 1 71 | } 72 | } 73 | 74 | try! hookDeallocBefore(targetClass: UIResponder.self) { object in 75 | if object is UIViewController { 76 | ImpressionKitDebug.shared.viewControllerCount -= 1 77 | } else if object is UIView { 78 | ImpressionKitDebug.shared.viewCount -= 1 79 | } 80 | } 81 | } 82 | 83 | } 84 | 85 | #endif 86 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample.xcodeproj/xcshareddata/xcschemes/ImpressionKitExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ImpressionKit/ImpressionKit+ViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImpressionKit+ViewModifier.swift 3 | // ImpressionKit 4 | // 5 | // Created by PangMo5 on 2021/07/22. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | #if canImport(SwiftUI) 11 | import SwiftUI 12 | #endif 13 | 14 | @available(iOS 13.0, *) 15 | private struct ImpressionView: UIViewRepresentable { 16 | let isForGroup: Bool 17 | let detectionInterval: Float? 18 | let durationThreshold: Float? 19 | let areaRatioThreshold: Float? 20 | let redetectOptions: UIView.Redetect? 21 | let onCreated: ((UIView) -> Void)? 22 | let onChanged: ((UIView.ImpressionState) -> Void)? 23 | 24 | func makeUIView(context: UIViewRepresentableContext) -> UIView { 25 | let view = UIView(frame: .zero) 26 | view.backgroundColor = .clear 27 | view.detectionInterval = detectionInterval 28 | view.durationThreshold = durationThreshold 29 | view.areaRatioThreshold = areaRatioThreshold 30 | view.redetectOptions = redetectOptions 31 | if !isForGroup { 32 | view.detectImpression { _, state in 33 | onChanged?(state) 34 | } 35 | } 36 | onCreated?(view) 37 | return view 38 | } 39 | 40 | func updateUIView(_ uiView: UIView, context: Context) {} 41 | } 42 | 43 | @available(iOS 13.0, *) 44 | private struct ImpressionTrackableModifier: ViewModifier { 45 | let isForGroup: Bool 46 | let detectionInterval: Float? 47 | let durationThreshold: Float? 48 | let areaRatioThreshold: Float? 49 | let redetectOptions: UIView.Redetect? 50 | let onCreated: ((UIView) -> Void)? 51 | let onChanged: ((UIView.ImpressionState) -> Void)? 52 | 53 | func body(content: Content) -> some View { 54 | content 55 | .overlay(ImpressionView(isForGroup: isForGroup, 56 | detectionInterval: detectionInterval, 57 | durationThreshold: durationThreshold, 58 | areaRatioThreshold: areaRatioThreshold, 59 | redetectOptions: redetectOptions, 60 | onCreated: onCreated, 61 | onChanged: onChanged) 62 | .allowsHitTesting(false)) 63 | } 64 | } 65 | 66 | @available(iOS 13.0, *) 67 | public extension View { 68 | func detectImpression(detectionInterval: Float? = nil, 69 | durationThreshold: Float? = nil, 70 | areaRatioThreshold: Float? = nil, 71 | redetectOptions: UIView.Redetect? = nil, 72 | onChanged: @escaping (UIView.ImpressionState) -> Void) -> some View 73 | { 74 | modifier(ImpressionTrackableModifier(isForGroup: false, 75 | detectionInterval: detectionInterval, 76 | durationThreshold: durationThreshold, 77 | areaRatioThreshold: areaRatioThreshold, 78 | redetectOptions: redetectOptions, 79 | onCreated: nil, 80 | onChanged: onChanged)) 81 | } 82 | 83 | func detectImpression(group: ImpressionGroup, 84 | index: T) -> some View 85 | { 86 | modifier(ImpressionTrackableModifier(isForGroup: true, 87 | detectionInterval: group.detectionInterval, 88 | durationThreshold: group.durationThreshold, 89 | areaRatioThreshold: group.areaRatioThreshold, 90 | redetectOptions: group.redetectOptions, 91 | onCreated: { view in 92 | group.bind(view: view, index: index) 93 | }, 94 | onChanged: nil)) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /README.zh-Hans.md: -------------------------------------------------------------------------------- 1 | # ImpressionKit 2 | 3 | 这是一个用户行为追踪(UBT)工具。可以方便地检测 UIView 的曝光事件。 4 | 5 | ![ezgif com-gif-maker](https://user-images.githubusercontent.com/5275802/120922347-30a2d200-c6fb-11eb-8994-f97c2bbc0ff8.gif) 6 | 7 | 原理:用 [SwiftHook](https://github.com/623637646/SwiftHook) Hook UIView的`didMoveToWindow`方法,定时检测此UIView是否在屏幕上。 8 | 9 | # 怎么使用 10 | 11 | ### 主要的 API 12 | 13 | 非常简单. 14 | 15 | ```swift 16 | 17 | // UIKit 18 | 19 | UIView().detectImpression { (view, state) in 20 | if state.isImpressed { 21 | print("This view is impressed to users.") 22 | } 23 | } 24 | 25 | // SwiftUI 26 | 27 | Color.red.detectImpression { state in 28 | if state.isImpressed { 29 | print("This view is impressed to users.") 30 | } 31 | } 32 | ``` 33 | 34 | 如果是在 UICollectionView,UITableView 或者其他可复用的视图中,请使用`ImpressionGroup`。 35 | 36 | ```swift 37 | // UIKit 38 | 39 | var group = ImpressionGroup.init {(_, index: IndexPath, view, state) in 40 | if state.isImpressed { 41 | print("impressed index: \(index.row)") 42 | } 43 | } 44 | 45 | ... 46 | 47 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 48 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell 49 | self.group.bind(view: cell, index: indexPath) 50 | return cell 51 | } 52 | 53 | // SwiftUI 54 | 55 | var group = ImpressionGroup.init { (_, index: Int, _, state) in 56 | if state.isImpressed { 57 | print("impressed index: \(index)") 58 | } 59 | } 60 | 61 | var body: some View { 62 | List(0 ..< 100) { index in 63 | CellView(index: index) 64 | .frame(height: 100) 65 | .detectImpression(group: group, index: index) 66 | } 67 | } 68 | ``` 69 | 70 | ### 更多 API 71 | 72 | 更改检测间隔 (秒). 更小的 `检测间隔` 代表更高的精确性和更高的CPU消耗。 73 | 74 | ```swift 75 | UIView.detectionInterval = 0.1 // 应用于所有 view。 76 | UIView().detectionInterval = 0.1 // 应用于特定 view. 如果为nil,则使用 `UIView.detectionInterval`。 77 | ImpressionGroup().detectionInterval = 0.1 // 应用于特定 group. 如果为nil,则使用 `UIView.detectionInterval`。 78 | ``` 79 | 80 | 更改 view 在屏幕上的持续时间的阈值 (秒)。 如果 view 在屏幕上的持续时间超过此阈值则可能会触发曝光。 81 | 82 | ```swift 83 | UIView.durationThreshold = 2 // 应用于所有 view。 84 | UIView().durationThreshold = 2 // 应用于特定 view. 如果为nil,则使用 `UIView.durationThreshold`。 85 | ImpressionGroup().durationThreshold = 2 // 应用于特定 group. 如果为nil,则使用 `UIView.durationThreshold`。 86 | ``` 87 | 88 | 更改 view 在屏幕上的面积比例的阈值(从 0 到 1)。view 在屏幕上的面积的百分比超过此阈值则可能会触发曝光。 89 | 90 | ```swift 91 | UIView.areaRatioThreshold = 0.4 // 应用于所有 view。 92 | UIView().areaRatioThreshold = 0.4 // 应用于特定 view. 如果为nil,则使用 `UIView.areaRatioThreshold`。 93 | ImpressionGroup().areaRatioThreshold = 0.4 // 应用于特定 group. 如果为nil,则使用 `UIView.areaRatioThreshold` 。 94 | ``` 95 | 96 | 更改 view 透明度的阈值(从 0 到 1)。view 透明度超过此阈值则可能会触发曝光。 97 | 98 | ```swift 99 | UIView.alphaThreshold = 0.4 // 应用于所有 view。 100 | UIView().alphaThreshold = 0.4 // 应用于特定 view. 如果为nil,则使用 `UIView.alphaThreshold`。 101 | ImpressionGroup().alphaThreshold = 0.4 // 应用于特定 group. 如果为nil,则使用 `UIView.alphaThreshold` 。 102 | ``` 103 | 104 | 在某些情况下重新触发曝光事件。 105 | 106 | ```swift 107 | // 当 view 离开屏幕 (页面不变,只是 view 没有显示)时,重新触发曝光。 108 | public static let leftScreen = Redetect(rawValue: 1 << 0) 109 | 110 | // 当 view 所在 UIViewController 消失时,重新触发曝光。 111 | public static let viewControllerDidDisappear = Redetect(rawValue: 1 << 1) 112 | 113 | // 当 App 进入后台时,重新触发曝光。 114 | public static let didEnterBackground = Redetect(rawValue: 1 << 2) 115 | 116 | // 当 App 将要进入非活跃状态时,重新触发曝光。 117 | public static let willResignActive = Redetect(rawValue: 1 << 3) 118 | ``` 119 | 120 | ```swift 121 | UIView.redetectOptions = [.leftScreen, .viewControllerDidDisappear, .didEnterBackground, .willResignActive] // 应用于所有 view。 122 | UIView().redetectOptions = [.leftScreen, .viewControllerDidDisappear, .didEnterBackground, .willResignActive] // 应用于特定 view. 如果为nil,则使用`UIView.redetectOptions`。 123 | ImpressionGroup().redetectOptions = [.leftScreen, .viewControllerDidDisappear, .didEnterBackground, .willResignActive] // 应用于特定 group. 如果为nil,则使用 `UIView.redetectOptions`。 124 | ``` 125 | 126 | 查看Demo获取更多详情。 127 | 128 | # How to integrate ImpressionKit 129 | 130 | [cocoapods](https://cocoapods.org/). 131 | 132 | ``` 133 | pod 'ImpressionKit' 134 | ``` 135 | 136 | 或者使用 Swift Package Manager。 3.1.0 版本之后,SPM被支持。 137 | 138 | # Requirements 139 | 140 | - iOS 12.0+ (UIKit) 141 | - iOS 13.0+ (SwiftUI) 142 | - Xcode 15.1+ 143 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/ScrollViewDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewDemoViewController.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by Yanni Wang on 31/5/21. 6 | // 7 | 8 | import UIKit 9 | import ImpressionKit 10 | 11 | class ScrollViewDemoViewController: UIViewController { 12 | 13 | private static let column = 4 14 | 15 | private let views = { 16 | return [Int](0...99).map { (index) -> CellView in 17 | return CellView.init(index: UInt(index)) 18 | } 19 | }() 20 | 21 | private let scrollView = UIScrollView.init() 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | self.view.backgroundColor = .white 26 | self.title = "UIScrollView" 27 | 28 | self.navigationItem.rightBarButtonItems = [ 29 | UIBarButtonItem.init(title: "push", style: .plain, target: self, action: #selector(pushNextPage)), 30 | UIBarButtonItem.init(title: "present", style: .plain, target: self, action: #selector(presentNextPage)), 31 | UIBarButtonItem.init(title: "redetect", style: .plain, target: self, action: #selector(redetect)), 32 | ] 33 | 34 | self.scrollView.frame = self.view.bounds 35 | self.view.addSubview(self.scrollView) 36 | 37 | var bottoms = [CGFloat].init(repeating: 0, count: ScrollViewDemoViewController.column) 38 | for cell in self.views { 39 | let y = bottoms.min()! 40 | let columnIndex = bottoms.firstIndex(of: y)! 41 | let width = self.scrollView.frame.width / CGFloat(ScrollViewDemoViewController.column) 42 | let height = width + CGFloat.random(in: 0 ..< width) 43 | let x = CGFloat(columnIndex) * width 44 | bottoms[columnIndex] = y + height 45 | cell.frame = CGRect.init(x: x, y: y, width: width, height: height) 46 | self.scrollView.addSubview(cell) 47 | } 48 | self.scrollView.contentSize = CGSize.init(width: self.scrollView.frame.width, height: bottoms.max()!) 49 | } 50 | 51 | @objc private func redetect() { 52 | self.views.forEach { (cell) in 53 | cell.redetect() 54 | } 55 | } 56 | 57 | @objc private func pushNextPage() { 58 | let nextPage = UIViewController() 59 | nextPage.view.backgroundColor = .white 60 | self.navigationController?.pushViewController(nextPage, animated: true) 61 | } 62 | 63 | @objc private func presentNextPage() { 64 | let nextPage = UIViewController() 65 | nextPage.view.backgroundColor = .white 66 | let backButton = UIButton.init(frame: CGRect.init(x: 0, y: 0, width: 100, height: 40)) 67 | backButton.setTitle("back", for: .normal) 68 | backButton.setTitleColor(.black, for: .normal) 69 | backButton.addTarget(self, action: #selector(back), for: .touchUpInside) 70 | backButton.center = CGPoint.init(x: nextPage.view.frame.width / 2, y: nextPage.view.frame.height / 2) 71 | nextPage.view.addSubview(backButton) 72 | self.present(nextPage, animated: true, completion: nil) 73 | } 74 | 75 | @objc func back(){ 76 | self.presentedViewController?.dismiss(animated: true, completion: nil) 77 | } 78 | } 79 | 80 | private class CellView: UIView { 81 | 82 | let label = { () -> UILabel in 83 | let label = UILabel.init() 84 | label.autoresizingMask = [.flexibleWidth, .flexibleHeight] 85 | label.font = UIFont.systemFont(ofSize: 12) 86 | label.textColor = .black 87 | label.textAlignment = .center 88 | label.numberOfLines = 0 89 | return label 90 | }() 91 | 92 | let index: UInt 93 | 94 | init(index: UInt) { 95 | self.index = index 96 | super.init(frame: CGRect.zero) 97 | self.layer.borderColor = UIColor.gray.cgColor 98 | self.layer.borderWidth = 0.5 99 | self.alpha = CGFloat(HomeViewController.alphaInDemo) 100 | 101 | self.label.frame = self.bounds 102 | self.addSubview(self.label) 103 | self.updateUI() 104 | 105 | self.detectImpression { (view, state) in 106 | view.updateUI() 107 | if state.isImpressed { 108 | print("impressed index: \(view.index)") 109 | } 110 | } 111 | } 112 | 113 | required init?(coder: NSCoder) { 114 | fatalError("init(coder:) has not been implemented") 115 | } 116 | 117 | func reset() { 118 | self.redetect() 119 | self.updateUI() 120 | } 121 | 122 | private func updateUI() { 123 | self.layer.removeAllAnimations() 124 | switch impressionState { 125 | case .impressed(_, let areaRatio): 126 | self.label.text = String.init(format: "\(self.index)\n\n%0.1f%%", areaRatio * 100) 127 | self.backgroundColor = .green 128 | case .inScreen(_): 129 | self.backgroundColor = .white 130 | UIView.animate(withDuration: TimeInterval(self.durationThreshold ?? UIView.durationThreshold), delay: 0, options: [.curveLinear, .allowUserInteraction], animations: { 131 | self.backgroundColor = .red 132 | }, completion: nil) 133 | default: 134 | self.label.text = "\(self.index)\n\n" 135 | self.backgroundColor = .white 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/TableViewDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableViewDemoViewController.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by Yanni Wang on 6/6/21. 6 | // 7 | 8 | import UIKit 9 | import ImpressionKit 10 | 11 | class TableViewDemoViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { 12 | 13 | let tableView = { () -> UITableView in 14 | let view = UITableView.init() 15 | view.backgroundColor = .white 16 | view.register(Cell.self, forCellReuseIdentifier: "Cell") 17 | return view 18 | }() 19 | 20 | lazy var group = ImpressionGroup.init {(_, index: IndexPath, view, state) in 21 | if state.isImpressed { 22 | print("impressed index: \(index.row)") 23 | } 24 | if let cell = view.superview as? Cell { 25 | cell.updateUI(state: state) 26 | } 27 | } 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | self.view.backgroundColor = .white 32 | self.title = "UITableView" 33 | 34 | self.navigationItem.rightBarButtonItems = [ 35 | UIBarButtonItem.init(title: "push", style: .plain, target: self, action: #selector(pushNextPage)), 36 | UIBarButtonItem.init(title: "present", style: .plain, target: self, action: #selector(presentNextPage)), 37 | UIBarButtonItem.init(title: "redetect", style: .plain, target: self, action: #selector(redetect)), 38 | ] 39 | 40 | self.tableView.frame = self.view.bounds 41 | self.tableView.dataSource = self 42 | self.tableView.delegate = self 43 | self.view.addSubview(self.tableView) 44 | } 45 | 46 | @objc private func redetect() { 47 | self.group.redetect() 48 | } 49 | 50 | @objc private func pushNextPage() { 51 | let nextPage = UIViewController() 52 | nextPage.view.backgroundColor = .white 53 | self.navigationController?.pushViewController(nextPage, animated: true) 54 | } 55 | 56 | @objc private func presentNextPage() { 57 | let nextPage = UIViewController() 58 | nextPage.view.backgroundColor = .white 59 | let backButton = UIButton.init(frame: CGRect.init(x: 0, y: 0, width: 100, height: 40)) 60 | backButton.setTitle("back", for: .normal) 61 | backButton.setTitleColor(.black, for: .normal) 62 | backButton.addTarget(self, action: #selector(back), for: .touchUpInside) 63 | backButton.center = CGPoint.init(x: nextPage.view.frame.width / 2, y: nextPage.view.frame.height / 2) 64 | nextPage.view.addSubview(backButton) 65 | self.present(nextPage, animated: true, completion: nil) 66 | } 67 | 68 | @objc func back(){ 69 | self.presentedViewController?.dismiss(animated: true, completion: nil) 70 | } 71 | 72 | // UITableViewDataSource 73 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 74 | return 99 75 | } 76 | 77 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 78 | let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! Cell 79 | cell.contentView.alpha = CGFloat(HomeViewController.alphaInDemo) 80 | self.group.bind(view: cell.contentView, index: indexPath) 81 | cell.index = indexPath.row 82 | cell.updateUI(state: self.group.states[indexPath]) 83 | return cell 84 | } 85 | 86 | // UITableViewDelegate 87 | func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 88 | return 44 89 | } 90 | 91 | } 92 | 93 | private class Cell: UITableViewCell { 94 | private var label = { () -> UILabel in 95 | let view = UILabel.init() 96 | view.font = UIFont.systemFont(ofSize: 12) 97 | view.textColor = .black 98 | view.textAlignment = .center 99 | view.numberOfLines = 0 100 | return view 101 | }() 102 | 103 | var index: Int = -1 104 | 105 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 106 | super.init(style: style, reuseIdentifier: reuseIdentifier) 107 | self.contentView.layer.borderColor = UIColor.gray.cgColor 108 | self.contentView.layer.borderWidth = 0.5 109 | self.contentView.addSubview(label) 110 | } 111 | 112 | required init?(coder: NSCoder) { 113 | fatalError("init(coder:) has not been implemented") 114 | } 115 | 116 | override func layoutSubviews() { 117 | super.layoutSubviews() 118 | self.label.frame = self.contentView.bounds 119 | } 120 | 121 | fileprivate func updateUI(state: UIView.ImpressionState?) { 122 | self.layer.removeAllAnimations() 123 | switch state { 124 | case .impressed(_, let areaRatio): 125 | self.label.text = String.init(format: "\(self.index)\n%0.1f%%", areaRatio * 100) 126 | self.contentView.backgroundColor = .green 127 | case .inScreen(_): 128 | self.contentView.backgroundColor = .white 129 | UIView.animate(withDuration: TimeInterval(self.contentView.durationThreshold ?? UIView.durationThreshold), delay: 0, options: [.curveLinear, .allowUserInteraction], animations: { 130 | self.contentView.backgroundColor = .red 131 | }, completion: nil) 132 | default: 133 | self.label.text = "\(self.index)\n" 134 | self.contentView.backgroundColor = .white 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [中文](README.zh-Hans.md) 2 | 3 | # ImpressionKit 4 | 5 | This is a user behavior tracking (UBT) tool to analyze impression events for UIView (exposure of UIView) in iOS. 6 | 7 | ![ezgif com-gif-maker](https://user-images.githubusercontent.com/5275802/120922347-30a2d200-c6fb-11eb-8994-f97c2bbc0ff8.gif) 8 | 9 | How it works: Hook the `didMoveToWindow` method of a UIView by [SwiftHook](https://github.com/623637646/SwiftHook), periodically check the view is on the screen or not. 10 | 11 | # How to use ImpressionKit 12 | 13 | ### Main APIs 14 | 15 | It's quite simple. 16 | 17 | 18 | ```swift 19 | 20 | // UIKit 21 | 22 | UIView().detectImpression { (view, state) in 23 | if state.isImpressed { 24 | print("This view is impressed to users.") 25 | } 26 | } 27 | 28 | // SwiftUI 29 | 30 | Color.red.detectImpression { state in 31 | if state.isImpressed { 32 | print("This view is impressed to users.") 33 | } 34 | } 35 | ``` 36 | 37 | Use `ImpressionGroup` for UICollectionView, UITableView, List or other reusable view cases. 38 | 39 | ```swift 40 | // UIKit 41 | 42 | var group = ImpressionGroup.init {(_, index: IndexPath, view, state) in 43 | if state.isImpressed { 44 | print("impressed index: \(index.row)") 45 | } 46 | } 47 | 48 | ... 49 | 50 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 51 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell 52 | self.group.bind(view: cell, index: indexPath) 53 | return cell 54 | } 55 | 56 | // SwiftUI 57 | 58 | var group = ImpressionGroup.init { (_, index: Int, _, state) in 59 | if state.isImpressed { 60 | print("impressed index: \(index)") 61 | } 62 | } 63 | 64 | var body: some View { 65 | List(0 ..< 100) { index in 66 | CellView(index: index) 67 | .frame(height: 100) 68 | .detectImpression(group: group, index: index) 69 | } 70 | } 71 | ``` 72 | 73 | ### More APIs 74 | 75 | Modify the detection (scan) interval (in seconds). Smaller `detectionInterval` means higher accuracy and higher CPU consumption. 76 | 77 | ```swift 78 | UIView.detectionInterval = 0.1 // apply to all views 79 | UIView().detectionInterval = 0.1 // apply to the specific view. `UIView.detectionInterval` will be used if it's nil. 80 | ImpressionGroup().detectionInterval = 0.1 // apply to the group. `UIView.detectionInterval` will be used if it's nil. 81 | ``` 82 | 83 | Modify the threshold (seconds) for the duration of a view on the screen. If the view's duration on the screen exceeds this threshold, it may trigger an impression. 84 | 85 | ```swift 86 | UIView.durationThreshold = 2 // apply to all views 87 | UIView().durationThreshold = 2 // apply to the specific view. `UIView.durationThreshold` will be used if it's nil. 88 | ImpressionGroup().durationThreshold = 2 // apply to the group. `UIView.durationThreshold` will be used if it's nil. 89 | ``` 90 | 91 | Modify the threshold (from 0 to 1) for the area ratio of the view on the screen. If the percentage of the view's area on the screen exceeds this threshold, it may trigger an impression. 92 | 93 | ```swift 94 | UIView.areaRatioThreshold = 0.4 // apply to all views 95 | UIView().areaRatioThreshold = 0.4 // apply to the specific view. `UIView.areaRatioThreshold` will be used if it's nil. 96 | ImpressionGroup().areaRatioThreshold = 0.4 // apply to the group. `UIView.areaRatioThreshold` will be used if it's nil. 97 | ``` 98 | 99 | Modify the threshold (from 0 to 1) for the view opacity. If the view's opacity exceeds this threshold, it may trigger an impression. 100 | 101 | ```swift 102 | UIView.alphaThreshold = 0.4 // apply to all views 103 | UIView().alphaThreshold = 0.4 // apply to the specific view. `UIView.alphaThreshold` will be used if it's nil. 104 | ImpressionGroup().alphaThreshold = 0.4 // apply to the group. `UIView.alphaThreshold` will be used if it's nil. 105 | ``` 106 | 107 | Retrigger the impression event in some situations. 108 | 109 | ```swift 110 | // When a view left from the screen, mark the view to retrigger the impression event when this view come back to the screen. 111 | // Left screen means the view is out of the screen but the UIViewController is still there. 112 | public static let leftScreen = Redetect(rawValue: 1 << 0) 113 | 114 | // When the UIViewController of the view disappear, mark the view to retrigger the impression event when the UIViewController appear again. 115 | public static let viewControllerDidDisappear = Redetect(rawValue: 1 << 1) 116 | 117 | // When the app enter background, mark the view to retrigger the impression event when the app enter foreground. 118 | public static let didEnterBackground = Redetect(rawValue: 1 << 2) 119 | 120 | // When the app will resign active, mark the view to retrigger the impression event when the app become active. 121 | public static let willResignActive = Redetect(rawValue: 1 << 3) 122 | ``` 123 | 124 | ```swift 125 | UIView.redetectOptions = [.leftScreen, .viewControllerDidDisappear, .didEnterBackground, .willResignActive] // apply to all views 126 | UIView().redetectOptions = [.leftScreen, .viewControllerDidDisappear, .didEnterBackground, .willResignActive] // apply to the specific view. `UIView.redetectOptions` will be used if it's nil. 127 | ImpressionGroup().redetectOptions = [.leftScreen, .viewControllerDidDisappear, .didEnterBackground, .willResignActive] // apply to the group. `UIView.redetectOptions` will be used if it's nil. 128 | ``` 129 | 130 | Refer to the Demo for more details. 131 | 132 | # How to integrate ImpressionKit 133 | 134 | **ImpressionKit** can be integrated by [cocoapods](https://cocoapods.org/). 135 | 136 | ``` 137 | pod 'ImpressionKit' 138 | ``` 139 | 140 | Or use Swift Package Manager. SPM is supported from 3.1.0. 141 | 142 | # Requirements 143 | 144 | - iOS 12.0+ (UIKit) 145 | - iOS 13.0+ (SwiftUI) 146 | - Xcode 15.1+ 147 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/CollectionViewDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionViewDemoViewController.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by Yanni Wang on 31/5/21. 6 | // 7 | 8 | import UIKit 9 | import CHTCollectionViewWaterfallLayout 10 | import ImpressionKit 11 | 12 | class CollectionViewDemoViewController: UIViewController, UICollectionViewDataSource, CHTCollectionViewDelegateWaterfallLayout { 13 | 14 | let collectionView = { () -> UICollectionView in 15 | let layout = CHTCollectionViewWaterfallLayout.init() 16 | layout.columnCount = 4 17 | layout.minimumColumnSpacing = 0 18 | layout.minimumInteritemSpacing = 0 19 | 20 | let view = UICollectionView.init(frame: CGRect.zero, collectionViewLayout: layout) 21 | view.backgroundColor = .white 22 | view.register(Cell.self, forCellWithReuseIdentifier: "Cell") 23 | return view 24 | }() 25 | 26 | lazy var group = ImpressionGroup.init {(_, index: IndexPath, view, state) in 27 | if state.isImpressed { 28 | print("impressed index: \(index.row)") 29 | } 30 | if let cell = view.superview as? Cell { 31 | cell.updateUI(state: state) 32 | } 33 | } 34 | 35 | override func viewDidLoad() { 36 | super.viewDidLoad() 37 | self.view.backgroundColor = .white 38 | self.title = "UICollectionView" 39 | 40 | self.navigationItem.rightBarButtonItems = [ 41 | UIBarButtonItem.init(title: "push", style: .plain, target: self, action: #selector(pushNextPage)), 42 | UIBarButtonItem.init(title: "present", style: .plain, target: self, action: #selector(presentNextPage)), 43 | UIBarButtonItem.init(title: "redetect", style: .plain, target: self, action: #selector(redetect)), 44 | ] 45 | 46 | self.collectionView.frame = self.view.bounds 47 | self.collectionView.dataSource = self 48 | self.collectionView.delegate = self 49 | self.view.addSubview(self.collectionView) 50 | } 51 | 52 | @objc private func redetect() { 53 | self.group.redetect() 54 | } 55 | 56 | @objc private func pushNextPage() { 57 | let nextPage = UIViewController() 58 | nextPage.view.backgroundColor = .white 59 | self.navigationController?.pushViewController(nextPage, animated: true) 60 | } 61 | 62 | @objc private func presentNextPage() { 63 | let nextPage = UIViewController() 64 | nextPage.view.backgroundColor = .white 65 | let backButton = UIButton.init(frame: CGRect.init(x: 0, y: 0, width: 100, height: 40)) 66 | backButton.setTitle("back", for: .normal) 67 | backButton.setTitleColor(.black, for: .normal) 68 | backButton.addTarget(self, action: #selector(back), for: .touchUpInside) 69 | backButton.center = CGPoint.init(x: nextPage.view.frame.width / 2, y: nextPage.view.frame.height / 2) 70 | nextPage.view.addSubview(backButton) 71 | self.present(nextPage, animated: true, completion: nil) 72 | } 73 | 74 | @objc func back(){ 75 | self.presentedViewController?.dismiss(animated: true, completion: nil) 76 | } 77 | 78 | // UICollectionViewDataSource & CHTCollectionViewDelegateWaterfallLayout 79 | 80 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 81 | return 99 82 | } 83 | 84 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 85 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell 86 | cell.contentView.alpha = CGFloat(HomeViewController.alphaInDemo) 87 | self.group.bind(view: cell.contentView, index: indexPath) 88 | cell.index = indexPath.row 89 | cell.updateUI(state: self.group.states[indexPath]) 90 | return cell 91 | } 92 | 93 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 94 | let width: CGFloat = 100 95 | let height = width + CGFloat.random(in: 0 ..< width) 96 | return CGSize.init(width: width, height: height) 97 | } 98 | } 99 | 100 | private class Cell: UICollectionViewCell { 101 | private var label = { () -> UILabel in 102 | let view = UILabel.init() 103 | view.font = UIFont.systemFont(ofSize: 12) 104 | view.textColor = .black 105 | view.textAlignment = .center 106 | view.numberOfLines = 0 107 | return view 108 | }() 109 | 110 | var index: Int = -1 111 | 112 | override init(frame: CGRect) { 113 | super.init(frame: frame) 114 | self.contentView.layer.borderColor = UIColor.gray.cgColor 115 | self.contentView.layer.borderWidth = 0.5 116 | self.contentView.addSubview(label) 117 | } 118 | 119 | required init?(coder: NSCoder) { 120 | fatalError("init(coder:) has not been implemented") 121 | } 122 | 123 | override func layoutSubviews() { 124 | super.layoutSubviews() 125 | self.label.frame = self.contentView.bounds 126 | } 127 | 128 | fileprivate func updateUI(state: UIView.ImpressionState?) { 129 | self.layer.removeAllAnimations() 130 | switch state { 131 | case .impressed(_, let areaRatio): 132 | self.label.text = String.init(format: "\(self.index)\n\n%0.1f%%", areaRatio * 100) 133 | self.contentView.backgroundColor = .green 134 | case .inScreen(_): 135 | self.contentView.backgroundColor = .white 136 | UIView.animate(withDuration: TimeInterval(self.contentView.durationThreshold ?? UIView.durationThreshold), delay: 0, options: [.curveLinear, .allowUserInteraction], animations: { 137 | self.contentView.backgroundColor = .red 138 | }, completion: nil) 139 | default: 140 | self.label.text = "\(self.index)\n\n" 141 | self.contentView.backgroundColor = .white 142 | } 143 | } 144 | } 145 | 146 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/CollectionViewDemo2ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionViewDemo2ViewController.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by Yanni on 21/1/22. 6 | // 7 | 8 | import UIKit 9 | import CHTCollectionViewWaterfallLayout 10 | import ImpressionKit 11 | 12 | class CollectionViewDemo2ViewController: UIViewController, UICollectionViewDataSource, CHTCollectionViewDelegateWaterfallLayout { 13 | 14 | let collectionView = { () -> UICollectionView in 15 | let layout = CHTCollectionViewWaterfallLayout.init() 16 | layout.columnCount = 4 17 | layout.minimumColumnSpacing = 0 18 | layout.minimumInteritemSpacing = 0 19 | 20 | let view = UICollectionView.init(frame: CGRect.zero, collectionViewLayout: layout) 21 | view.backgroundColor = .white 22 | view.register(Cell.self, forCellWithReuseIdentifier: "Cell") 23 | return view 24 | }() 25 | 26 | lazy var group = ImpressionGroup.init {(_, index: IndexPath, view, state) in 27 | if state.isImpressed { 28 | print("impressed index: \(index.section), \(index.row)") 29 | } 30 | if let cell = view.superview as? Cell { 31 | cell.updateUI(state: state) 32 | } 33 | } 34 | 35 | override func viewDidLoad() { 36 | super.viewDidLoad() 37 | self.view.backgroundColor = .white 38 | self.title = "UICollectionView2" 39 | 40 | self.navigationItem.rightBarButtonItems = [ 41 | UIBarButtonItem.init(title: "push", style: .plain, target: self, action: #selector(pushNextPage)), 42 | UIBarButtonItem.init(title: "present", style: .plain, target: self, action: #selector(presentNextPage)), 43 | UIBarButtonItem.init(title: "redetect", style: .plain, target: self, action: #selector(redetect)), 44 | ] 45 | 46 | self.collectionView.frame = self.view.bounds 47 | self.collectionView.dataSource = self 48 | self.collectionView.delegate = self 49 | self.view.addSubview(self.collectionView) 50 | } 51 | 52 | @objc private func redetect() { 53 | self.group.redetect() 54 | } 55 | 56 | @objc private func pushNextPage() { 57 | let nextPage = UIViewController() 58 | nextPage.view.backgroundColor = .white 59 | self.navigationController?.pushViewController(nextPage, animated: true) 60 | } 61 | 62 | @objc private func presentNextPage() { 63 | let nextPage = UIViewController() 64 | nextPage.view.backgroundColor = .white 65 | let backButton = UIButton.init(frame: CGRect.init(x: 0, y: 0, width: 100, height: 40)) 66 | backButton.setTitle("back", for: .normal) 67 | backButton.setTitleColor(.black, for: .normal) 68 | backButton.addTarget(self, action: #selector(back), for: .touchUpInside) 69 | backButton.center = CGPoint.init(x: nextPage.view.frame.width / 2, y: nextPage.view.frame.height / 2) 70 | nextPage.view.addSubview(backButton) 71 | self.present(nextPage, animated: true, completion: nil) 72 | } 73 | 74 | @objc func back(){ 75 | self.presentedViewController?.dismiss(animated: true, completion: nil) 76 | } 77 | 78 | // UICollectionViewDataSource & CHTCollectionViewDelegateWaterfallLayout 79 | 80 | func numberOfSections(in collectionView: UICollectionView) -> Int { 81 | return 3 82 | } 83 | 84 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 85 | return section == 1 ? 30 : 10 86 | } 87 | 88 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 89 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell 90 | cell.contentView.alpha = CGFloat(HomeViewController.alphaInDemo) 91 | self.group.bind(view: cell.contentView, index: indexPath, ignoreDetection: indexPath.section != 1) 92 | cell.index = indexPath.row 93 | cell.updateUI(state: self.group.states[indexPath]) 94 | return cell 95 | } 96 | 97 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 98 | let width: CGFloat = 100 99 | let height = width + CGFloat.random(in: 0 ..< width) 100 | return CGSize.init(width: width, height: height) 101 | } 102 | } 103 | 104 | private class Cell: UICollectionViewCell { 105 | private var label = { () -> UILabel in 106 | let view = UILabel.init() 107 | view.font = UIFont.systemFont(ofSize: 12) 108 | view.textColor = .black 109 | view.textAlignment = .center 110 | view.numberOfLines = 0 111 | return view 112 | }() 113 | 114 | var index: Int = -1 115 | 116 | override init(frame: CGRect) { 117 | super.init(frame: frame) 118 | self.contentView.layer.borderColor = UIColor.gray.cgColor 119 | self.contentView.layer.borderWidth = 0.5 120 | self.contentView.addSubview(label) 121 | } 122 | 123 | required init?(coder: NSCoder) { 124 | fatalError("init(coder:) has not been implemented") 125 | } 126 | 127 | override func layoutSubviews() { 128 | super.layoutSubviews() 129 | self.label.frame = self.contentView.bounds 130 | } 131 | 132 | fileprivate func updateUI(state: UIView.ImpressionState?) { 133 | self.layer.removeAllAnimations() 134 | switch state { 135 | case .impressed(_, let areaRatio): 136 | self.label.text = String.init(format: "\(self.index)\n\n%0.1f%%", areaRatio * 100) 137 | self.contentView.backgroundColor = .green 138 | case .inScreen(_): 139 | self.contentView.backgroundColor = .white 140 | UIView.animate(withDuration: TimeInterval(self.contentView.durationThreshold ?? UIView.durationThreshold), delay: 0, options: [.curveLinear, .allowUserInteraction], animations: { 141 | self.contentView.backgroundColor = .red 142 | }, completion: nil) 143 | default: 144 | self.label.text = "\(self.index)\n\n" 145 | self.contentView.backgroundColor = .white 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /ImpressionKit/ImpressionGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImpressionGroup.swift 3 | // ImpressionKit 4 | // 5 | // Created by Yanni Wang on 2/6/21. 6 | // 7 | 8 | import UIKit 9 | 10 | public class ImpressionGroup { 11 | 12 | // MARK: - config 13 | // Change the detection (scan) interval (in seconds). Smaller detectionInterval means more accuracy and higher CPU consumption. Apply to the group. `UIView.detectionInterval` will be used if it's nil. 14 | public var detectionInterval: Float? 15 | 16 | // Chage the threshold of duration in screen (in seconds). The view will be impressed if it keeps being in screen after this seconds. Apply to the group. `UIView.durationThreshold` will be used if it's nil. 17 | public var durationThreshold: Float? 18 | 19 | // Chage the threshold of area ratio in screen. It's from 0 to 1. The view will be impressed if it's area ratio remains equal to or greater than this value. Apply to the group. `UIView.areaRatioThreshold` will be used if it's nil. 20 | public var areaRatioThreshold: Float? 21 | 22 | // Chage the threshold of alpha. It's from 0 to 1. The view will be impressed if it's alpha is equal to or greater than this value. Apply to the group. `UIView.alphaThreshold` will be used if it's nil. 23 | public var alphaThreshold: Float? 24 | 25 | // Retrigger the impression. Apply to the group. `UIView.redetectOptions` will be used if it's nil. 26 | public var redetectOptions: UIView.Redetect? { 27 | didSet { 28 | readdNotificationObserver() 29 | } 30 | } 31 | 32 | // MARK: - public 33 | public typealias ImpressionGroupCallback = (_ group: ImpressionGroup, _ index: IndexType, _ view: UIView, _ state: UIView.ImpressionState) -> () 34 | 35 | public private(set) var states = [IndexType: UIView.ImpressionState]() 36 | 37 | // MARK: - private 38 | private var views = [IndexType: () -> UIView?]() 39 | 40 | private var notificationTokens = [NSObjectProtocol]() 41 | 42 | private let impressionGroupCallback: ImpressionGroupCallback 43 | 44 | private lazy var impressionBlock: (_ view: UIView, _ state: UIView.ImpressionState) -> () = { [weak self] (view, state) in 45 | guard let self = self, 46 | let index = self.views.first(where: { $1() == view })?.key else { 47 | return 48 | } 49 | if let previousSates = self.states[index] { 50 | guard previousSates != state else { 51 | return 52 | } 53 | } 54 | 55 | // Redetection of didEnterBackground & willResignActive are handled by group 56 | 57 | // redetect viewControllerDidDisappear 58 | if view.isRedetectionOn(.viewControllerDidDisappear), 59 | case .viewControllerDidDisappear = state { 60 | self.resetGroupStateAndRedetect(.viewControllerDidDisappear) 61 | return 62 | } 63 | 64 | // redetect leftScreen 65 | if let previousSates = self.states[index], 66 | previousSates.isImpressed { 67 | guard view.isRedetectionOn(.leftScreen) else { 68 | return 69 | } 70 | } 71 | 72 | self.changeState(index: index, view: view, state: state) 73 | } 74 | 75 | public init(detectionInterval: Float? = nil, 76 | durationThreshold: Float? = nil, 77 | areaRatioThreshold: Float? = nil, 78 | alphaThreshold: Float? = nil, 79 | redetectOptions: UIView.Redetect? = nil, 80 | impressionGroupCallback: @escaping ImpressionGroupCallback) { 81 | self.detectionInterval = detectionInterval 82 | self.durationThreshold = durationThreshold 83 | self.areaRatioThreshold = areaRatioThreshold 84 | self.alphaThreshold = alphaThreshold 85 | self.redetectOptions = redetectOptions 86 | self.impressionGroupCallback = impressionGroupCallback 87 | readdNotificationObserver() 88 | } 89 | 90 | /** 91 | This method must be called every time in 92 | 1. UICollectionView: `func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell` 93 | 2. or UITableView: `func cellForRow(at indexPath: IndexPath) -> UITableViewCell?` 94 | 3. or your customized methods. 95 | Non-calling may cause abnormal impression. 96 | if a index doesn't need to be impressed. pass `ignoreDetection = true` to ignore this index. 97 | */ 98 | public func bind(view: UIView, index: IndexType, ignoreDetection: Bool = false) { 99 | if let previousIndex = views.first(where: { $1() == view })?.key { 100 | views[previousIndex] = nil 101 | } 102 | 103 | if let previousView = views[index]?() { 104 | previousView.detectImpression(nil) 105 | } 106 | 107 | guard ignoreDetection == false else { 108 | view.detectImpression(nil) // Cancel the detection if the view is detecting. 109 | self.changeState(index: index, view: view, state: .unknown) 110 | return 111 | } 112 | 113 | views[index] = { [weak view] in view } 114 | 115 | // setup views 116 | view.detectionInterval = self.detectionInterval 117 | view.durationThreshold = self.durationThreshold 118 | view.areaRatioThreshold = self.areaRatioThreshold 119 | view.alphaThreshold = self.alphaThreshold 120 | // No need to set .willResignActive and .didEnterBackground for views. Group will handle this. 121 | var redetectOptions = self.redetectOptions 122 | redetectOptions?.remove(.willResignActive) 123 | redetectOptions?.remove(.didEnterBackground) 124 | view.redetectOptions = redetectOptions 125 | 126 | guard let currentState = self.states[index], 127 | currentState.isImpressed else { 128 | self.changeState(index: index, view: view, state: .unknown) 129 | view.detectImpression(impressionBlock) 130 | return 131 | } 132 | 133 | // The view is impressed. 134 | guard view.keepDetectionAfterImpressed() else { 135 | view.detectImpression(nil) 136 | return 137 | } 138 | 139 | if view.isRedetectionOn(.leftScreen) { 140 | self.changeState(index: index, view: view, state: .unknown) 141 | } 142 | view.detectImpression(impressionBlock) 143 | } 144 | 145 | public func redetect() { 146 | resetGroupStateAndRedetect(.unknown) 147 | } 148 | 149 | private func resetGroupStateAndRedetect(_ state: UIView.ImpressionState) { 150 | self.views.forEach { (index, closure) in 151 | guard let view = closure() else { 152 | self.views.removeValue(forKey: index) 153 | return 154 | } 155 | self.changeState(index: index, view: view, state: state) 156 | view.detectImpression(impressionBlock) 157 | view.redetect() 158 | } 159 | self.states = self.states.mapValues { _ in state } 160 | } 161 | 162 | private func changeState(index: IndexType, view: UIView, state: UIView.ImpressionState) { 163 | if let previousState = self.states[index] { 164 | guard previousState != state else { 165 | return 166 | } 167 | } 168 | self.states[index] = state 169 | self.impressionGroupCallback(self, index, view, state) 170 | } 171 | 172 | private func readdNotificationObserver() { 173 | self.notificationTokens.forEach { (token) in 174 | NotificationCenter.default.removeObserver(token) 175 | } 176 | self.notificationTokens.removeAll() 177 | let redetectOptions = self.redetectOptions ?? UIView.redetectOptions 178 | if redetectOptions.contains(.willResignActive) { 179 | let token = NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil) { [weak self] _ in 180 | guard let self = self else { 181 | return 182 | } 183 | self.resetGroupStateAndRedetect(.willResignActive) 184 | } 185 | self.notificationTokens.append(token) 186 | } 187 | if redetectOptions.contains(.didEnterBackground) { 188 | let token = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in 189 | guard let self = self else { 190 | return 191 | } 192 | self.resetGroupStateAndRedetect(.didEnterBackground) 193 | } 194 | self.notificationTokens.append(token) 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /ImpressionKit/UIViewPropertyExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewPropertyExtension.swift 3 | // ImpressionKit 4 | // 5 | // Created by Yanni Wang on 30/5/21. 6 | // 7 | 8 | import UIKit 9 | #if SWIFT_PACKAGE 10 | import SwiftHook 11 | #else 12 | import EasySwiftHook 13 | #endif 14 | 15 | private var stateKey = 0 16 | private var detectionIntervalKey = 0 17 | private var durationThresholdKey = 0 18 | private var areaRatioThresholdKey = 0 19 | private var alphaThresholdKey = 0 20 | private var redetectOptionsKey = 0 21 | private var hookingDeallocTokenKey = 0 22 | private var hookingDidMoveToWindowTokenKey = 0 23 | private var hookingViewDidDisappearTokenKey = 0 24 | private var notificationTokensKey = 0 25 | private var timerKey = 0 26 | 27 | extension UIView { 28 | 29 | // MARK: - Main 30 | 31 | public enum ImpressionState: Equatable { 32 | case unknown 33 | case impressed(atDate: Date, areaRatio: Float) 34 | case inScreen(fromDate: Date) 35 | case outOfScreen 36 | case noWindow 37 | case viewControllerDidDisappear 38 | case didEnterBackground 39 | case willResignActive 40 | 41 | public var isImpressed: Bool { 42 | if case .impressed = self { 43 | return true 44 | } else { 45 | return false 46 | } 47 | } 48 | } 49 | 50 | // Is triggered the impression event. 51 | public internal(set) var impressionState: ImpressionState { 52 | set { 53 | let old = self.impressionState 54 | objc_setAssociatedObject(self, &stateKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 55 | if old != newValue { 56 | guard let getCallback = self.getCallback() else { 57 | assert(false) 58 | return 59 | } 60 | getCallback(self, newValue) 61 | } 62 | } 63 | 64 | get { 65 | return (objc_getAssociatedObject(self, &stateKey) as? ImpressionState ?? .unknown) 66 | } 67 | } 68 | 69 | // MARK: - Config 70 | 71 | // Change the detection (scan) interval (in seconds). Smaller detectionInterval means more accuracy and higher CPU consumption. Apply to all views 72 | public static var detectionInterval: Float = 0.2 73 | 74 | // Change the detection (scan) interval (in seconds). Smaller detectionInterval means more accuracy and higher CPU consumption. Apply to the specific view. `UIView.detectionInterval` will be used if it's nil. 75 | public var detectionInterval: Float? { 76 | set { 77 | objc_setAssociatedObject(self, &detectionIntervalKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 78 | } 79 | 80 | get { 81 | return objc_getAssociatedObject(self, &detectionIntervalKey) as? Float 82 | } 83 | } 84 | 85 | // Chage the threshold of duration in screen (in seconds). The view will be impressed if it keeps being in screen after this seconds. Apply to all views 86 | public static var durationThreshold: Float = 1 87 | 88 | // Chage the threshold of duration in screen (in seconds). The view will be impressed if it keeps being in screen after this seconds. Apply to the specific view. `UIView.durationThreshold` will be used if it's nil. 89 | public var durationThreshold: Float? { 90 | set { 91 | objc_setAssociatedObject(self, &durationThresholdKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 92 | } 93 | 94 | get { 95 | return objc_getAssociatedObject(self, &durationThresholdKey) as? Float 96 | } 97 | } 98 | 99 | // Chage the threshold of area ratio in screen. It's from 0 to 1. The view will be impressed if it's area ratio remains equal to or greater than this value. Apply to all views 100 | public static var areaRatioThreshold: Float = 0.5 101 | 102 | // Chage the threshold of area ratio in screen. It's from 0 to 1. The view will be impressed if it's area ratio remains equal to or greater than this value. Apply to the specific view. `UIView.areaRatioThreshold` will be used if it's nil. 103 | public var areaRatioThreshold: Float? { 104 | set { 105 | objc_setAssociatedObject(self, &areaRatioThresholdKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 106 | } 107 | 108 | get { 109 | return objc_getAssociatedObject(self, &areaRatioThresholdKey) as? Float 110 | } 111 | } 112 | 113 | // Chage the threshold of alpha. It's from 0 to 1. The view will be impressed if it's alpha is equal to or greater than this value. Apply to all views 114 | public static var alphaThreshold: Float = 0.1 115 | 116 | // Chage the threshold of alpha. It's from 0 to 1. The view will be impressed if it's alpha is equal to or greater than this value. Apply to the specific view. `UIView.alphaThreshold` will be used if it's nil. 117 | public var alphaThreshold: Float? { 118 | set { 119 | objc_setAssociatedObject(self, &alphaThresholdKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 120 | } 121 | 122 | get { 123 | return objc_getAssociatedObject(self, &alphaThresholdKey) as? Float 124 | } 125 | } 126 | 127 | public struct Redetect: OptionSet { 128 | 129 | public let rawValue: Int 130 | public init(rawValue: Int) { 131 | self.rawValue = rawValue 132 | } 133 | 134 | // When a view left from the screen, mark the view to retrigger the impression event when this view come back to the screen. 135 | // Left screen means the view is out of the screen but the UIViewController is still there. 136 | public static let leftScreen = Redetect(rawValue: 1 << 0) 137 | 138 | // When the UIViewController of the view disappear, mark the view to retrigger the impression event when the UIViewController appear again. 139 | public static let viewControllerDidDisappear = Redetect(rawValue: 1 << 1) 140 | 141 | // When the app enter background, mark the view to retrigger the impression event when the app enter foreground. 142 | public static let didEnterBackground = Redetect(rawValue: 1 << 2) 143 | 144 | // When the app will resign active, mark the view to retrigger the impression event when the app become active. 145 | public static let willResignActive = Redetect(rawValue: 1 << 3) 146 | } 147 | 148 | // Retrigger the impression. Apply to all views. 149 | public static var redetectOptions: Redetect = [] 150 | 151 | // Retrigger the impression. Apply to the specific view. `UIView.redetectOptions` will be used if it's nil. 152 | public var redetectOptions: Redetect? { 153 | set { 154 | objc_setAssociatedObject(self, &redetectOptionsKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 155 | } 156 | 157 | get { 158 | return objc_getAssociatedObject(self, &redetectOptionsKey) as? Redetect 159 | } 160 | } 161 | 162 | // MARK: - internal 163 | 164 | var hookingDeallocToken: Token? { 165 | set { 166 | let closure = { return newValue } 167 | objc_setAssociatedObject(self, &hookingDeallocTokenKey, closure, .OBJC_ASSOCIATION_COPY_NONATOMIC) 168 | } 169 | 170 | get { 171 | guard let closure = objc_getAssociatedObject(self, &hookingDeallocTokenKey) as? () -> Token? else { return nil } 172 | return closure() 173 | } 174 | } 175 | 176 | var hookingDidMoveToWindowToken: Token? { 177 | set { 178 | let closure = { return newValue } 179 | objc_setAssociatedObject(self, &hookingDidMoveToWindowTokenKey, closure, .OBJC_ASSOCIATION_COPY_NONATOMIC) 180 | } 181 | 182 | get { 183 | guard let closure = objc_getAssociatedObject(self, &hookingDidMoveToWindowTokenKey) as? () -> Token? else { return nil } 184 | return closure() 185 | } 186 | } 187 | 188 | var hookingViewDidDisappearToken: Token? { 189 | set { 190 | let closure = { return newValue } 191 | objc_setAssociatedObject(self, &hookingViewDidDisappearTokenKey, closure, .OBJC_ASSOCIATION_COPY_NONATOMIC) 192 | } 193 | 194 | get { 195 | guard let closure = objc_getAssociatedObject(self, &hookingViewDidDisappearTokenKey) as? () -> Token? else { return nil } 196 | return closure() 197 | } 198 | } 199 | 200 | var notificationTokens: [NSObjectProtocol] { 201 | set { 202 | objc_setAssociatedObject(self, ¬ificationTokensKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) 203 | } 204 | 205 | get { 206 | return objc_getAssociatedObject(self, ¬ificationTokensKey) as? [NSObjectProtocol] ?? [] 207 | } 208 | } 209 | 210 | var timer: Timer? { 211 | set { 212 | objc_setAssociatedObject(self, &timerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 213 | } 214 | get { 215 | return objc_getAssociatedObject(self, &timerKey) as? Timer 216 | } 217 | } 218 | 219 | } 220 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample/HomeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewController.swift 3 | // ImpressionKitExample 4 | // 5 | // Created by Yanni Wang on 31/5/21. 6 | // 7 | 8 | import Foundation 9 | import Eureka 10 | import SwiftUI 11 | 12 | class HomeViewController: FormViewController { 13 | 14 | private static let detectionIntervalKey = "detectionIntervalKey" 15 | private static let durationThresholdKey = "durationThresholdKey" 16 | private static let areaRatioThresholdKey = "areaRatioThresholdKey" 17 | private static let alphaThresholdKey = "alphaThresholdKey" 18 | private static let alphaInDemoKey = "alphaInDemoKey" 19 | private static let redetectOptionsKey = "redetectOptionsKey" 20 | 21 | static var detectionInterval: Float { 22 | get { 23 | guard UserDefaults.standard.object(forKey: detectionIntervalKey) != nil else { 24 | return UIView.detectionInterval 25 | } 26 | return UserDefaults.standard.float(forKey: detectionIntervalKey) 27 | } 28 | set { 29 | UIView.detectionInterval = newValue 30 | UserDefaults.standard.set(newValue, forKey: detectionIntervalKey) 31 | } 32 | } 33 | 34 | static var durationThreshold: Float { 35 | get { 36 | guard UserDefaults.standard.object(forKey: durationThresholdKey) != nil else { 37 | return UIView.durationThreshold 38 | } 39 | return UserDefaults.standard.float(forKey: durationThresholdKey) 40 | } 41 | set { 42 | UIView.durationThreshold = newValue 43 | UserDefaults.standard.set(newValue, forKey: durationThresholdKey) 44 | } 45 | } 46 | static var areaRatioThreshold: Float { 47 | get { 48 | guard UserDefaults.standard.object(forKey: areaRatioThresholdKey) != nil else { 49 | return UIView.areaRatioThreshold 50 | } 51 | return UserDefaults.standard.float(forKey: areaRatioThresholdKey) 52 | } 53 | set { 54 | UIView.areaRatioThreshold = newValue 55 | UserDefaults.standard.set(newValue, forKey: areaRatioThresholdKey) 56 | } 57 | } 58 | static var alphaThreshold: Float { 59 | get { 60 | guard UserDefaults.standard.object(forKey: alphaThresholdKey) != nil else { 61 | return UIView.alphaThreshold 62 | } 63 | return UserDefaults.standard.float(forKey: alphaThresholdKey) 64 | } 65 | set { 66 | UIView.alphaThreshold = newValue 67 | UserDefaults.standard.set(newValue, forKey: alphaThresholdKey) 68 | } 69 | } 70 | static var alphaInDemo: Float { 71 | get { 72 | guard UserDefaults.standard.object(forKey: alphaInDemoKey) != nil else { 73 | return 1 74 | } 75 | return UserDefaults.standard.float(forKey: alphaInDemoKey) 76 | } 77 | set { 78 | UserDefaults.standard.set(newValue, forKey: alphaInDemoKey) 79 | } 80 | } 81 | static var redetectOptions: UIView.Redetect { 82 | get { 83 | guard UserDefaults.standard.object(forKey: redetectOptionsKey) != nil else { 84 | return UIView.redetectOptions 85 | } 86 | let value = UserDefaults.standard.integer(forKey: redetectOptionsKey) 87 | return UIView.Redetect.init(rawValue: value) 88 | } 89 | set { 90 | UIView.redetectOptions = newValue 91 | UserDefaults.standard.set(newValue.rawValue, forKey: redetectOptionsKey) 92 | } 93 | } 94 | 95 | override func viewDidLoad() { 96 | super.viewDidLoad() 97 | self.title = "ImpressionKit Demo"; 98 | self.setUpForm() 99 | } 100 | 101 | func setUpForm() { 102 | CATransaction.begin() 103 | CATransaction.setDisableActions(true) 104 | form.removeAll() 105 | form +++ Section("Demos") 106 | <<< ButtonRow("UIScrollView") { row in 107 | row.title = row.tag 108 | row.presentationMode = .show(controllerProvider: ControllerProvider.callback(builder: { () -> UIViewController in 109 | return ScrollViewDemoViewController() 110 | }), onDismiss: nil) 111 | } 112 | <<< ButtonRow("UICollectionView (reusable views)") { row in 113 | row.title = row.tag 114 | row.presentationMode = .show(controllerProvider: ControllerProvider.callback(builder: { () -> UIViewController in 115 | return CollectionViewDemoViewController() 116 | }), onDismiss: nil) 117 | } 118 | <<< ButtonRow("UICollectionView (only detect 2nd section)") { row in 119 | row.title = row.tag 120 | row.presentationMode = .show(controllerProvider: ControllerProvider.callback(builder: { () -> UIViewController in 121 | return CollectionViewDemo2ViewController() 122 | }), onDismiss: nil) 123 | } 124 | <<< ButtonRow("UITableView (reusable views)") { row in 125 | row.title = row.tag 126 | row.presentationMode = .show(controllerProvider: ControllerProvider.callback(builder: { () -> UIViewController in 127 | return TableViewDemoViewController() 128 | }), onDismiss: nil) 129 | } 130 | <<< ButtonRow("SwiftUI ScrollView") { row in 131 | row.title = row.tag 132 | row.presentationMode = .show(controllerProvider: ControllerProvider.callback(builder: { () -> UIViewController in 133 | if #available(iOS 13.0, *) { 134 | return UIHostingController(rootView: SwiftUIScrollViewDemoView()) 135 | } else { 136 | fatalError() 137 | } 138 | }), onDismiss: nil) 139 | } 140 | <<< ButtonRow("SwiftUI List (reusable views)") { row in 141 | row.title = row.tag 142 | row.presentationMode = .show(controllerProvider: ControllerProvider.callback(builder: { () -> UIViewController in 143 | if #available(iOS 13.0, *) { 144 | return UIHostingController(rootView: SwiftUIListDemoView()) 145 | } else { 146 | fatalError() 147 | } 148 | }), onDismiss: nil) 149 | } 150 | +++ Section("SETTINGS") 151 | <<< DecimalRow() { 152 | $0.title = "Detection Interval" 153 | $0.value = Double(HomeViewController.detectionInterval) 154 | $0.formatter = DecimalFormatter() 155 | $0.useFormatterDuringInput = true 156 | //$0.useFormatterOnDidBeginEditing = true 157 | }.cellSetup { cell, _ in 158 | cell.textField.keyboardType = .numberPad 159 | }.onChange({ (row) in 160 | HomeViewController.detectionInterval = Float(row.value ?? 0) 161 | }) 162 | <<< DecimalRow() { 163 | $0.title = "Duration Threshold" 164 | $0.value = Double(HomeViewController.durationThreshold) 165 | $0.formatter = DecimalFormatter() 166 | $0.useFormatterDuringInput = true 167 | //$0.useFormatterOnDidBeginEditing = true 168 | }.cellSetup { cell, _ in 169 | cell.textField.keyboardType = .numberPad 170 | }.onChange({ (row) in 171 | HomeViewController.durationThreshold = Float(row.value ?? 0) 172 | }) 173 | <<< SliderRow() { 174 | $0.title = "Area Ratio Threshold" 175 | $0.value = Float(Int(HomeViewController.areaRatioThreshold * 100)) 176 | $0.cell.slider.minimumValue = 1 177 | $0.cell.slider.maximumValue = 100 178 | $0.displayValueFor = { 179 | return "\(Int($0 ?? 0))%" 180 | } 181 | }.onChange({ (row) in 182 | HomeViewController.areaRatioThreshold = Float((row.value ?? 0) / 100) 183 | }) 184 | <<< SliderRow() { 185 | $0.title = "Alpha Threshold" 186 | $0.value = Float(Int(HomeViewController.alphaThreshold * 100)) 187 | $0.cell.slider.minimumValue = 1 188 | $0.cell.slider.maximumValue = 100 189 | $0.displayValueFor = { 190 | return "\(Int($0 ?? 0))%" 191 | } 192 | }.onChange({ (row) in 193 | HomeViewController.alphaThreshold = Float((row.value ?? 0) / 100) 194 | }) 195 | <<< SliderRow() { 196 | $0.title = "Alpha of views in Demo" 197 | $0.value = Float(Int(HomeViewController.alphaInDemo * 100)) 198 | $0.cell.slider.minimumValue = 1 199 | $0.cell.slider.maximumValue = 100 200 | $0.displayValueFor = { 201 | return "\(Int($0 ?? 0))%" 202 | } 203 | }.onChange({ (row) in 204 | HomeViewController.alphaInDemo = Float((row.value ?? 0) / 100) 205 | }) 206 | <<< SwitchRow() { 207 | $0.title = "Redetect When Left Screen" 208 | $0.value = HomeViewController.redetectOptions.contains(.leftScreen) 209 | }.onChange({ (row) in 210 | if row.value ?? false { 211 | HomeViewController.redetectOptions.insert(.leftScreen) 212 | } else { 213 | HomeViewController.redetectOptions.remove(.leftScreen) 214 | } 215 | }) 216 | <<< SwitchRow() { 217 | $0.title = "Redetect When DidDisappear" 218 | $0.value = HomeViewController.redetectOptions.contains(.viewControllerDidDisappear) 219 | }.onChange({ (row) in 220 | if row.value ?? false { 221 | HomeViewController.redetectOptions.insert(.viewControllerDidDisappear) 222 | } else { 223 | HomeViewController.redetectOptions.remove(.viewControllerDidDisappear) 224 | } 225 | }) 226 | <<< SwitchRow() { 227 | $0.title = "Redetect When didEnterBackground" 228 | $0.value = HomeViewController.redetectOptions.contains(.didEnterBackground) 229 | }.onChange({ (row) in 230 | if row.value ?? false { 231 | HomeViewController.redetectOptions.insert(.didEnterBackground) 232 | } else { 233 | HomeViewController.redetectOptions.remove(.didEnterBackground) 234 | } 235 | }) 236 | <<< SwitchRow() { 237 | $0.title = "Redetect When willResignActive" 238 | $0.value = HomeViewController.redetectOptions.contains(.willResignActive) 239 | }.onChange({ (row) in 240 | if row.value ?? false { 241 | HomeViewController.redetectOptions.insert(.willResignActive) 242 | } else { 243 | HomeViewController.redetectOptions.remove(.willResignActive) 244 | } 245 | }) 246 | <<< ButtonRow(){ 247 | $0.title = "Reset" 248 | $0.cell.tintColor = .red 249 | }.onCellSelection { [weak self] _,_ in 250 | HomeViewController.detectionInterval = 0.2 251 | HomeViewController.durationThreshold = 1 252 | HomeViewController.areaRatioThreshold = 0.5 253 | HomeViewController.alphaThreshold = 0.1 254 | HomeViewController.alphaInDemo = 1 255 | HomeViewController.redetectOptions = [] 256 | self?.setUpForm() 257 | } 258 | CATransaction.commit() 259 | } 260 | } 261 | 262 | -------------------------------------------------------------------------------- /ImpressionKit/UIViewFunctionExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImpressionKit.swift 3 | // ImpressionKit 4 | // 5 | // Created by Yanni Wang on 30/5/21. 6 | // 7 | 8 | import UIKit 9 | #if SWIFT_PACKAGE 10 | import SwiftHook 11 | #else 12 | import EasySwiftHook 13 | #endif 14 | 15 | private var impressionCallbackKey = 0 16 | 17 | public protocol ImpressionProtocol: UIView {} 18 | 19 | extension UIView: ImpressionProtocol {} 20 | 21 | public extension ImpressionProtocol { 22 | 23 | typealias ImpressionCallback = (_ view: ViewType, _ state: ImpressionState) -> () 24 | 25 | // The callback will be triggered when impression happens. nil value means cancellation of detection 26 | func detectImpression(_ block: ImpressionCallback?) { 27 | assert(Thread.isMainThread); 28 | self.impressionState = .unknown 29 | if let block = block { 30 | // set 31 | let value = { view, state in 32 | guard let view = view as? Self else { 33 | return 34 | } 35 | block(view, state) 36 | } as ImpressionCallback 37 | objc_setAssociatedObject(self, &impressionCallbackKey, value, .OBJC_ASSOCIATION_COPY_NONATOMIC) 38 | // hook & notification 39 | self.hookDeallocIfNeeded() 40 | self.hookDidMoveToWindowIfNeeded() 41 | self.rehookViewDidDisappearIfNeeded() 42 | self.readdNotificationObserver() 43 | // timer 44 | self.startTimerIfNeeded() 45 | } else { 46 | // timer 47 | self.stopTimer() 48 | // cancel hook & notification 49 | self.removeNotificationObserverIfNeeded() 50 | self.cancelHookingViewDidDisappearIfNeeded() 51 | self.cancelHookingDidMoveToWindowIfNeeded() 52 | self.cancelHookingDeallocIfNeeded() 53 | // set 54 | objc_setAssociatedObject(self, &impressionCallbackKey, nil, .OBJC_ASSOCIATION_COPY_NONATOMIC) 55 | } 56 | } 57 | 58 | var isDetectionOn: Bool { 59 | return self.getCallback() != nil 60 | } 61 | 62 | internal func getCallback() -> ImpressionCallback? { 63 | return objc_getAssociatedObject(self, &impressionCallbackKey) as? ImpressionCallback 64 | } 65 | } 66 | 67 | extension UIView { 68 | 69 | // redetect impression for the UIView. 70 | public func redetect() { 71 | self.impressionState = .unknown 72 | self.startTimerIfNeeded() 73 | } 74 | 75 | func isRedetectionOn(_ option: Redetect) -> Bool { 76 | let redetectOptions = self.redetectOptions ?? UIView.redetectOptions 77 | return redetectOptions.contains(option) 78 | } 79 | 80 | func keepDetectionAfterImpressed() -> Bool { 81 | return self.isRedetectionOn(.leftScreen) || self.isRedetectionOn(.viewControllerDidDisappear) 82 | } 83 | 84 | // MARK: - Hook Dealloc 85 | 86 | fileprivate func hookDeallocIfNeeded() { 87 | guard self.hookingDeallocToken == nil else { 88 | return 89 | } 90 | self.hookingDeallocToken = try? hookDeallocBefore(object: self) { obj in 91 | obj.removeNotificationObserverIfNeeded() 92 | } 93 | } 94 | 95 | fileprivate func cancelHookingDeallocIfNeeded() { 96 | guard let token = self.hookingDeallocToken else { 97 | return 98 | } 99 | token.cancelHook() 100 | self.hookingDeallocToken = nil 101 | } 102 | 103 | // MARK: - Hook DidMoveToWindow 104 | 105 | fileprivate func hookDidMoveToWindowIfNeeded() { 106 | guard self.hookingDidMoveToWindowToken == nil else { 107 | return 108 | } 109 | self.hookingDidMoveToWindowToken = try? hookAfter(object: self, selector: #selector(UIView.didMoveToWindow), closure: { view, _ in 110 | if view.window != nil { 111 | if !view.impressionState.isImpressed { 112 | view.impressionState = .unknown 113 | } 114 | view.rehookViewDidDisappearIfNeeded() 115 | view.startTimerIfNeeded() 116 | } else { 117 | if !view.impressionState.isImpressed { 118 | view.impressionState = .noWindow 119 | } 120 | view.stopTimer() 121 | } 122 | } as @convention(block) (UIView, Selector) -> Void) 123 | } 124 | 125 | fileprivate func cancelHookingDidMoveToWindowIfNeeded() { 126 | guard let token = self.hookingDidMoveToWindowToken else { 127 | return 128 | } 129 | token.cancelHook() 130 | self.hookingDidMoveToWindowToken = nil 131 | } 132 | 133 | // MARK: - Hook ViewDidDisappear 134 | 135 | fileprivate func rehookViewDidDisappearIfNeeded() { 136 | self.cancelHookingViewDidDisappearIfNeeded() 137 | guard self.isRedetectionOn(.viewControllerDidDisappear) else { 138 | return 139 | } 140 | 141 | guard let vc = self.parentViewController else { 142 | return 143 | } 144 | 145 | var hookingViewDidDisappearToken: Token? 146 | hookingViewDidDisappearToken = try? hookAfter(object: vc, selector: #selector(UIViewController.viewDidDisappear(_:))) { [weak self] in 147 | guard let self = self, 148 | self.isRedetectionOn(.viewControllerDidDisappear) else { 149 | hookingViewDidDisappearToken?.cancelHook() 150 | return 151 | } 152 | self.impressionState = .viewControllerDidDisappear 153 | } 154 | self.hookingViewDidDisappearToken = hookingViewDidDisappearToken 155 | } 156 | 157 | fileprivate func cancelHookingViewDidDisappearIfNeeded() { 158 | guard let token = self.hookingViewDidDisappearToken else { 159 | return 160 | } 161 | token.cancelHook() 162 | self.hookingViewDidDisappearToken = nil 163 | } 164 | 165 | // MARK: - observe notifications 166 | 167 | fileprivate func readdNotificationObserver() { 168 | self.removeNotificationObserverIfNeeded() 169 | var names = [Notification.Name]() 170 | if self.isRedetectionOn(.didEnterBackground) { 171 | names.append(UIApplication.didEnterBackgroundNotification) 172 | } 173 | if self.isRedetectionOn(.willResignActive) { 174 | names.append(UIApplication.willResignActiveNotification) 175 | } 176 | guard names.count > 0 else { 177 | return 178 | } 179 | var tokens = [NSObjectProtocol]() 180 | for name in names { 181 | let token = NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil, using: {[weak self] notification in 182 | guard let self = self else { 183 | return 184 | } 185 | if notification.name == UIApplication.didEnterBackgroundNotification { 186 | self.impressionState = .didEnterBackground 187 | } else if notification.name == UIApplication.willResignActiveNotification { 188 | self.impressionState = .willResignActive 189 | } else { 190 | assert(false) 191 | return 192 | } 193 | self.startTimerIfNeeded() 194 | }) 195 | tokens.append(token) 196 | } 197 | self.notificationTokens = tokens 198 | } 199 | 200 | fileprivate func removeNotificationObserverIfNeeded() { 201 | self.notificationTokens.forEach { (token) in 202 | NotificationCenter.default.removeObserver(token) 203 | } 204 | self.notificationTokens.removeAll() 205 | } 206 | 207 | // MARK: - Algorithm 208 | 209 | private func areaRatio() -> Float { 210 | let alphaThreshold = CGFloat(self.alphaThreshold ?? UIView.alphaThreshold) 211 | guard self.isHidden == false && self.alpha >= alphaThreshold else { 212 | return 0 213 | } 214 | if let window = self as? UIWindow, 215 | self.superview == nil { 216 | // It's a root window 217 | let intersection = self.frame.intersection( window.screen.bounds) 218 | let ratio = (intersection.width * intersection.height) / (self.frame.width * self.frame.height) 219 | return self.fixRatioPrecision(number: Float(ratio)) 220 | } else { 221 | // It's normal view 222 | guard let window = self.window, 223 | window.isHidden == false && window.alpha >= alphaThreshold else { 224 | return 0 225 | } 226 | // If super view hidden or alpha < alphaThreshold, self can't show 227 | var aView = self 228 | var frameInSuperView = self.bounds 229 | while let superView = aView.superview { 230 | guard superView.isHidden == false && superView.alpha >= alphaThreshold else { 231 | return 0 232 | } 233 | frameInSuperView = aView.convert(frameInSuperView, to: superView) 234 | if aView.clipsToBounds { 235 | frameInSuperView = frameInSuperView.intersection(aView.frame) 236 | } 237 | guard !frameInSuperView.isEmpty else { 238 | return 0 239 | } 240 | aView = superView 241 | } 242 | let frameInWindow = frameInSuperView 243 | let frameInScreen = CGRect.init(x: frameInWindow.origin.x + window.frame.origin.x, 244 | y: frameInWindow.origin.y + window.frame.origin.y, 245 | width: frameInWindow.width, 246 | height: frameInWindow.height) 247 | let intersection = frameInScreen.intersection(window.screen.bounds) 248 | let ratio = (intersection.width * intersection.height) / (self.frame.width * self.frame.height) 249 | return self.fixRatioPrecision(number: Float(ratio)) 250 | } 251 | } 252 | 253 | private static let ratioPrecisionOffset: Float = 0.0001 254 | private func fixRatioPrecision(number: Float) -> Float { 255 | // As long as the different ratios on screen is within 0.01% (0.0001), then we can consider two ratios as equal. It's sufficient for this case. 256 | guard number > UIView.ratioPrecisionOffset else { 257 | return 0 258 | } 259 | guard number < 1 - UIView.ratioPrecisionOffset else { 260 | return 1 261 | } 262 | return number 263 | } 264 | 265 | private func detect() { 266 | if self.impressionState.isImpressed { 267 | guard self.keepDetectionAfterImpressed() else { 268 | // The impression has already been triggered. Don't need to retrigger. 269 | self.stopTimer() 270 | return 271 | } 272 | } 273 | 274 | // background 275 | if UIApplication.shared.applicationState == .background { 276 | guard !self.isRedetectionOn(.didEnterBackground) else { 277 | self.impressionState = .didEnterBackground 278 | return 279 | } 280 | } 281 | 282 | // inactive 283 | if UIApplication.shared.applicationState == .inactive { 284 | guard !self.isRedetectionOn(.willResignActive) else { 285 | self.impressionState = .willResignActive 286 | return 287 | } 288 | } 289 | 290 | // presented 291 | // If current view controller presented (non-full screen) a UIViewController, the viewDidDisappear of it will not be called. We need this logic to udpate the state. 292 | if let vc = self.parentViewController, 293 | vc.presentedViewController != nil { 294 | guard !self.isRedetectionOn(.viewControllerDidDisappear) else { 295 | self.impressionState = .viewControllerDidDisappear 296 | return 297 | } 298 | if !self.impressionState.isImpressed { 299 | self.impressionState = .viewControllerDidDisappear 300 | } 301 | return 302 | } 303 | 304 | // leftScreen 305 | if self.impressionState.isImpressed { 306 | guard self.isRedetectionOn(.leftScreen) else { 307 | return 308 | } 309 | } 310 | 311 | // calculate 312 | let areaRatio = self.areaRatio() 313 | let areaRatioThreshold = self.areaRatioThreshold ?? UIView.areaRatioThreshold 314 | 315 | if case .inScreen(let fromDate) = self.impressionState { 316 | if areaRatio >= areaRatioThreshold { 317 | // keep appearance 318 | let interval = Date().timeIntervalSince(fromDate) 319 | let durationThreshold = self.durationThreshold ?? UIView.durationThreshold 320 | if Float(interval) >= durationThreshold { 321 | // trigger impression 322 | self.impressionState = .impressed(atDate: Date(), areaRatio: areaRatio) 323 | 324 | if !self.keepDetectionAfterImpressed() { 325 | self.stopTimer() 326 | } 327 | } 328 | } else { 329 | // from appearance to disappearance 330 | self.impressionState = .outOfScreen 331 | } 332 | } else if areaRatio >= areaRatioThreshold { 333 | // appearance 334 | if !self.impressionState.isImpressed { 335 | self.impressionState = .inScreen(fromDate: Date()) 336 | } 337 | } else { 338 | // disappearance 339 | self.impressionState = .outOfScreen 340 | } 341 | } 342 | 343 | // MARK: - timer 344 | 345 | fileprivate func startTimerIfNeeded() { 346 | if self.impressionState.isImpressed { 347 | guard self.keepDetectionAfterImpressed() else { 348 | return 349 | } 350 | } 351 | guard self.timer == nil, 352 | self.isDetectionOn, 353 | self.window != nil else { 354 | return 355 | } 356 | self.startTimer() 357 | } 358 | 359 | private func startTimer() { 360 | let timeInterval = TimeInterval(self.detectionInterval ?? UIView.detectionInterval) 361 | let timer = Timer.init(timeInterval: timeInterval, repeats: true, block: { [weak self] (timer) in 362 | // check self 363 | guard let self = self else { 364 | timer.invalidate() 365 | #if DEBUG 366 | ImpressionKitDebug.shared.timerCount -= 1 367 | #endif 368 | return 369 | } 370 | // check timer 371 | let currentTimeInterval = TimeInterval(self.detectionInterval ?? UIView.detectionInterval) 372 | guard currentTimeInterval.isEqual(to: timeInterval) else { 373 | self.stopTimer() 374 | self.startTimerIfNeeded() 375 | return 376 | } 377 | // detect 378 | self.detect() 379 | }) 380 | self.timer = timer 381 | RunLoop.main.add(timer, forMode: .common) 382 | #if DEBUG 383 | ImpressionKitDebug.shared.timerCount += 1 384 | #endif 385 | } 386 | 387 | func stopTimer() { 388 | if let timer = self.timer { 389 | timer.invalidate() 390 | self.timer = nil 391 | #if DEBUG 392 | ImpressionKitDebug.shared.timerCount -= 1 393 | #endif 394 | } 395 | } 396 | 397 | // MARK: - others 398 | // https://stackoverflow.com/a/24590678/9315497 399 | private var parentViewController: UIViewController? { 400 | var parentResponder: UIResponder? = self 401 | while parentResponder != nil { 402 | parentResponder = parentResponder?.next 403 | if let viewController = parentResponder as? UIViewController { 404 | return viewController 405 | } 406 | } 407 | return nil 408 | } 409 | 410 | } 411 | -------------------------------------------------------------------------------- /ImpressionKit.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 0F39B44B86894197DADB06D8 /* Pods_ImpressionKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 111BA5A553F376B7148F841D /* Pods_ImpressionKit.framework */; }; 11 | 5C1ED03A26637C7900C68451 /* ImpressionKit.h in Headers */ = {isa = PBXBuildFile; fileRef = 5C1ED03826637C7900C68451 /* ImpressionKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; 12 | 5C1ED06B26637D5A00C68451 /* UIViewFunctionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1ED06A26637D5A00C68451 /* UIViewFunctionExtension.swift */; }; 13 | 5C1ED0812663DD7B00C68451 /* UIViewPropertyExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1ED0802663DD7B00C68451 /* UIViewPropertyExtension.swift */; }; 14 | 5CDD55322667587B00FBF47E /* ImpressionGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDD55312667587B00FBF47E /* ImpressionGroup.swift */; }; 15 | 62F7F59326A9708D003DF049 /* ImpressionKit+ViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F7F59226A9708D003DF049 /* ImpressionKit+ViewModifier.swift */; }; 16 | FC73BCDA27C99E92008B9765 /* ImpressionKitDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC73BCD927C99E92008B9765 /* ImpressionKitDebug.swift */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | 111BA5A553F376B7148F841D /* Pods_ImpressionKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ImpressionKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | 2C89A5CA1D58550E28745199 /* Pods-ImpressionKit.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImpressionKit.release.xcconfig"; path = "Target Support Files/Pods-ImpressionKit/Pods-ImpressionKit.release.xcconfig"; sourceTree = ""; }; 22 | 344B6F369D34D6690A7FED8E /* Pods-ImpressionKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImpressionKit.debug.xcconfig"; path = "Target Support Files/Pods-ImpressionKit/Pods-ImpressionKit.debug.xcconfig"; sourceTree = ""; }; 23 | 5C1ED03526637C7800C68451 /* ImpressionKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ImpressionKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 24 | 5C1ED03826637C7900C68451 /* ImpressionKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ImpressionKit.h; sourceTree = ""; }; 25 | 5C1ED03926637C7900C68451 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 26 | 5C1ED06A26637D5A00C68451 /* UIViewFunctionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewFunctionExtension.swift; sourceTree = ""; }; 27 | 5C1ED0802663DD7B00C68451 /* UIViewPropertyExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewPropertyExtension.swift; sourceTree = ""; }; 28 | 5CDD55312667587B00FBF47E /* ImpressionGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpressionGroup.swift; sourceTree = ""; }; 29 | 62F7F59226A9708D003DF049 /* ImpressionKit+ViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImpressionKit+ViewModifier.swift"; sourceTree = ""; }; 30 | FC73BCD927C99E92008B9765 /* ImpressionKitDebug.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImpressionKitDebug.swift; sourceTree = ""; }; 31 | /* End PBXFileReference section */ 32 | 33 | /* Begin PBXFrameworksBuildPhase section */ 34 | 5C1ED03226637C7800C68451 /* Frameworks */ = { 35 | isa = PBXFrameworksBuildPhase; 36 | buildActionMask = 2147483647; 37 | files = ( 38 | 0F39B44B86894197DADB06D8 /* Pods_ImpressionKit.framework in Frameworks */, 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | 5C1ED02B26637C7800C68451 = { 46 | isa = PBXGroup; 47 | children = ( 48 | 5C1ED03726637C7900C68451 /* ImpressionKit */, 49 | 5C1ED03626637C7800C68451 /* Products */, 50 | E4CF830392E091FC5199230D /* Pods */, 51 | F58AD94348A70BD8D3A51D7E /* Frameworks */, 52 | ); 53 | sourceTree = ""; 54 | }; 55 | 5C1ED03626637C7800C68451 /* Products */ = { 56 | isa = PBXGroup; 57 | children = ( 58 | 5C1ED03526637C7800C68451 /* ImpressionKit.framework */, 59 | ); 60 | name = Products; 61 | sourceTree = ""; 62 | }; 63 | 5C1ED03726637C7900C68451 /* ImpressionKit */ = { 64 | isa = PBXGroup; 65 | children = ( 66 | FC73BCD927C99E92008B9765 /* ImpressionKitDebug.swift */, 67 | 5C1ED03826637C7900C68451 /* ImpressionKit.h */, 68 | 5C1ED0802663DD7B00C68451 /* UIViewPropertyExtension.swift */, 69 | 5C1ED06A26637D5A00C68451 /* UIViewFunctionExtension.swift */, 70 | 5CDD55312667587B00FBF47E /* ImpressionGroup.swift */, 71 | 5C1ED03926637C7900C68451 /* Info.plist */, 72 | 62F7F59226A9708D003DF049 /* ImpressionKit+ViewModifier.swift */, 73 | ); 74 | path = ImpressionKit; 75 | sourceTree = ""; 76 | }; 77 | E4CF830392E091FC5199230D /* Pods */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | 344B6F369D34D6690A7FED8E /* Pods-ImpressionKit.debug.xcconfig */, 81 | 2C89A5CA1D58550E28745199 /* Pods-ImpressionKit.release.xcconfig */, 82 | ); 83 | path = Pods; 84 | sourceTree = ""; 85 | }; 86 | F58AD94348A70BD8D3A51D7E /* Frameworks */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | 111BA5A553F376B7148F841D /* Pods_ImpressionKit.framework */, 90 | ); 91 | name = Frameworks; 92 | sourceTree = ""; 93 | }; 94 | /* End PBXGroup section */ 95 | 96 | /* Begin PBXHeadersBuildPhase section */ 97 | 5C1ED03026637C7800C68451 /* Headers */ = { 98 | isa = PBXHeadersBuildPhase; 99 | buildActionMask = 2147483647; 100 | files = ( 101 | 5C1ED03A26637C7900C68451 /* ImpressionKit.h in Headers */, 102 | ); 103 | runOnlyForDeploymentPostprocessing = 0; 104 | }; 105 | /* End PBXHeadersBuildPhase section */ 106 | 107 | /* Begin PBXNativeTarget section */ 108 | 5C1ED03426637C7800C68451 /* ImpressionKit */ = { 109 | isa = PBXNativeTarget; 110 | buildConfigurationList = 5C1ED03D26637C7900C68451 /* Build configuration list for PBXNativeTarget "ImpressionKit" */; 111 | buildPhases = ( 112 | 4324530236DA21B687DCA6F4 /* [CP] Check Pods Manifest.lock */, 113 | 5C1ED03026637C7800C68451 /* Headers */, 114 | 5C1ED03126637C7800C68451 /* Sources */, 115 | 5C1ED03226637C7800C68451 /* Frameworks */, 116 | 5C1ED03326637C7800C68451 /* Resources */, 117 | ); 118 | buildRules = ( 119 | ); 120 | dependencies = ( 121 | ); 122 | name = ImpressionKit; 123 | productName = ImpressionKit; 124 | productReference = 5C1ED03526637C7800C68451 /* ImpressionKit.framework */; 125 | productType = "com.apple.product-type.framework"; 126 | }; 127 | /* End PBXNativeTarget section */ 128 | 129 | /* Begin PBXProject section */ 130 | 5C1ED02C26637C7800C68451 /* Project object */ = { 131 | isa = PBXProject; 132 | attributes = { 133 | BuildIndependentTargetsInParallel = YES; 134 | LastUpgradeCheck = 1630; 135 | TargetAttributes = { 136 | 5C1ED03426637C7800C68451 = { 137 | CreatedOnToolsVersion = 12.2; 138 | LastSwiftMigration = 1220; 139 | }; 140 | }; 141 | }; 142 | buildConfigurationList = 5C1ED02F26637C7800C68451 /* Build configuration list for PBXProject "ImpressionKit" */; 143 | compatibilityVersion = "Xcode 9.3"; 144 | developmentRegion = en; 145 | hasScannedForEncodings = 0; 146 | knownRegions = ( 147 | en, 148 | Base, 149 | ); 150 | mainGroup = 5C1ED02B26637C7800C68451; 151 | productRefGroup = 5C1ED03626637C7800C68451 /* Products */; 152 | projectDirPath = ""; 153 | projectRoot = ""; 154 | targets = ( 155 | 5C1ED03426637C7800C68451 /* ImpressionKit */, 156 | ); 157 | }; 158 | /* End PBXProject section */ 159 | 160 | /* Begin PBXResourcesBuildPhase section */ 161 | 5C1ED03326637C7800C68451 /* Resources */ = { 162 | isa = PBXResourcesBuildPhase; 163 | buildActionMask = 2147483647; 164 | files = ( 165 | ); 166 | runOnlyForDeploymentPostprocessing = 0; 167 | }; 168 | /* End PBXResourcesBuildPhase section */ 169 | 170 | /* Begin PBXShellScriptBuildPhase section */ 171 | 4324530236DA21B687DCA6F4 /* [CP] Check Pods Manifest.lock */ = { 172 | isa = PBXShellScriptBuildPhase; 173 | buildActionMask = 2147483647; 174 | files = ( 175 | ); 176 | inputFileListPaths = ( 177 | ); 178 | inputPaths = ( 179 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 180 | "${PODS_ROOT}/Manifest.lock", 181 | ); 182 | name = "[CP] Check Pods Manifest.lock"; 183 | outputFileListPaths = ( 184 | ); 185 | outputPaths = ( 186 | "$(DERIVED_FILE_DIR)/Pods-ImpressionKit-checkManifestLockResult.txt", 187 | ); 188 | runOnlyForDeploymentPostprocessing = 0; 189 | shellPath = /bin/sh; 190 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 191 | showEnvVarsInLog = 0; 192 | }; 193 | /* End PBXShellScriptBuildPhase section */ 194 | 195 | /* Begin PBXSourcesBuildPhase section */ 196 | 5C1ED03126637C7800C68451 /* Sources */ = { 197 | isa = PBXSourcesBuildPhase; 198 | buildActionMask = 2147483647; 199 | files = ( 200 | 5C1ED06B26637D5A00C68451 /* UIViewFunctionExtension.swift in Sources */, 201 | FC73BCDA27C99E92008B9765 /* ImpressionKitDebug.swift in Sources */, 202 | 62F7F59326A9708D003DF049 /* ImpressionKit+ViewModifier.swift in Sources */, 203 | 5CDD55322667587B00FBF47E /* ImpressionGroup.swift in Sources */, 204 | 5C1ED0812663DD7B00C68451 /* UIViewPropertyExtension.swift in Sources */, 205 | ); 206 | runOnlyForDeploymentPostprocessing = 0; 207 | }; 208 | /* End PBXSourcesBuildPhase section */ 209 | 210 | /* Begin XCBuildConfiguration section */ 211 | 5C1ED03B26637C7900C68451 /* Debug */ = { 212 | isa = XCBuildConfiguration; 213 | buildSettings = { 214 | ALWAYS_SEARCH_USER_PATHS = NO; 215 | CLANG_ANALYZER_NONNULL = YES; 216 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 217 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 218 | CLANG_CXX_LIBRARY = "libc++"; 219 | CLANG_ENABLE_MODULES = YES; 220 | CLANG_ENABLE_OBJC_ARC = YES; 221 | CLANG_ENABLE_OBJC_WEAK = YES; 222 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 223 | CLANG_WARN_BOOL_CONVERSION = YES; 224 | CLANG_WARN_COMMA = YES; 225 | CLANG_WARN_CONSTANT_CONVERSION = YES; 226 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 227 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 228 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 229 | CLANG_WARN_EMPTY_BODY = YES; 230 | CLANG_WARN_ENUM_CONVERSION = YES; 231 | CLANG_WARN_INFINITE_RECURSION = YES; 232 | CLANG_WARN_INT_CONVERSION = YES; 233 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 234 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 235 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 236 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 237 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 238 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 239 | CLANG_WARN_STRICT_PROTOTYPES = YES; 240 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 241 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 242 | CLANG_WARN_UNREACHABLE_CODE = YES; 243 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 244 | COPY_PHASE_STRIP = NO; 245 | CURRENT_PROJECT_VERSION = 1; 246 | DEBUG_INFORMATION_FORMAT = dwarf; 247 | DEVELOPMENT_TEAM = ZQ7TZ3Q272; 248 | ENABLE_STRICT_OBJC_MSGSEND = YES; 249 | ENABLE_TESTABILITY = YES; 250 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 251 | GCC_C_LANGUAGE_STANDARD = gnu11; 252 | GCC_DYNAMIC_NO_PIC = NO; 253 | GCC_NO_COMMON_BLOCKS = YES; 254 | GCC_OPTIMIZATION_LEVEL = 0; 255 | GCC_PREPROCESSOR_DEFINITIONS = ( 256 | "DEBUG=1", 257 | "$(inherited)", 258 | ); 259 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 260 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 261 | GCC_WARN_UNDECLARED_SELECTOR = YES; 262 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 263 | GCC_WARN_UNUSED_FUNCTION = YES; 264 | GCC_WARN_UNUSED_VARIABLE = YES; 265 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 266 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 267 | MTL_FAST_MATH = YES; 268 | ONLY_ACTIVE_ARCH = YES; 269 | SDKROOT = iphoneos; 270 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 271 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 272 | VERSIONING_SYSTEM = "apple-generic"; 273 | VERSION_INFO_PREFIX = ""; 274 | }; 275 | name = Debug; 276 | }; 277 | 5C1ED03C26637C7900C68451 /* Release */ = { 278 | isa = XCBuildConfiguration; 279 | buildSettings = { 280 | ALWAYS_SEARCH_USER_PATHS = NO; 281 | CLANG_ANALYZER_NONNULL = YES; 282 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 283 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 284 | CLANG_CXX_LIBRARY = "libc++"; 285 | CLANG_ENABLE_MODULES = YES; 286 | CLANG_ENABLE_OBJC_ARC = YES; 287 | CLANG_ENABLE_OBJC_WEAK = YES; 288 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 289 | CLANG_WARN_BOOL_CONVERSION = YES; 290 | CLANG_WARN_COMMA = YES; 291 | CLANG_WARN_CONSTANT_CONVERSION = YES; 292 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 293 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 294 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 295 | CLANG_WARN_EMPTY_BODY = YES; 296 | CLANG_WARN_ENUM_CONVERSION = YES; 297 | CLANG_WARN_INFINITE_RECURSION = YES; 298 | CLANG_WARN_INT_CONVERSION = YES; 299 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 300 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 301 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 302 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 303 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 304 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 305 | CLANG_WARN_STRICT_PROTOTYPES = YES; 306 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 307 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 308 | CLANG_WARN_UNREACHABLE_CODE = YES; 309 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 310 | COPY_PHASE_STRIP = NO; 311 | CURRENT_PROJECT_VERSION = 1; 312 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 313 | DEVELOPMENT_TEAM = ZQ7TZ3Q272; 314 | ENABLE_NS_ASSERTIONS = NO; 315 | ENABLE_STRICT_OBJC_MSGSEND = YES; 316 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 317 | GCC_C_LANGUAGE_STANDARD = gnu11; 318 | GCC_NO_COMMON_BLOCKS = YES; 319 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 320 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 321 | GCC_WARN_UNDECLARED_SELECTOR = YES; 322 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 323 | GCC_WARN_UNUSED_FUNCTION = YES; 324 | GCC_WARN_UNUSED_VARIABLE = YES; 325 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 326 | MTL_ENABLE_DEBUG_INFO = NO; 327 | MTL_FAST_MATH = YES; 328 | SDKROOT = iphoneos; 329 | SWIFT_COMPILATION_MODE = wholemodule; 330 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 331 | VALIDATE_PRODUCT = YES; 332 | VERSIONING_SYSTEM = "apple-generic"; 333 | VERSION_INFO_PREFIX = ""; 334 | }; 335 | name = Release; 336 | }; 337 | 5C1ED03E26637C7900C68451 /* Debug */ = { 338 | isa = XCBuildConfiguration; 339 | baseConfigurationReference = 344B6F369D34D6690A7FED8E /* Pods-ImpressionKit.debug.xcconfig */; 340 | buildSettings = { 341 | CLANG_ENABLE_MODULES = YES; 342 | CODE_SIGN_IDENTITY = ""; 343 | CODE_SIGN_STYLE = Automatic; 344 | DEFINES_MODULE = YES; 345 | DYLIB_COMPATIBILITY_VERSION = 1; 346 | DYLIB_CURRENT_VERSION = 1; 347 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 348 | ENABLE_MODULE_VERIFIER = YES; 349 | INFOPLIST_FILE = ImpressionKit/Info.plist; 350 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 351 | LD_RUNPATH_SEARCH_PATHS = ( 352 | "$(inherited)", 353 | "@executable_path/Frameworks", 354 | "@loader_path/Frameworks", 355 | ); 356 | MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; 357 | PRODUCT_BUNDLE_IDENTIFIER = com.yanni.ImpressionKit; 358 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 359 | SKIP_INSTALL = YES; 360 | SUPPORTS_MACCATALYST = NO; 361 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 362 | SWIFT_VERSION = 5.0; 363 | TARGETED_DEVICE_FAMILY = "1,2"; 364 | }; 365 | name = Debug; 366 | }; 367 | 5C1ED03F26637C7900C68451 /* Release */ = { 368 | isa = XCBuildConfiguration; 369 | baseConfigurationReference = 2C89A5CA1D58550E28745199 /* Pods-ImpressionKit.release.xcconfig */; 370 | buildSettings = { 371 | CLANG_ENABLE_MODULES = YES; 372 | CODE_SIGN_IDENTITY = ""; 373 | CODE_SIGN_STYLE = Automatic; 374 | DEFINES_MODULE = YES; 375 | DYLIB_COMPATIBILITY_VERSION = 1; 376 | DYLIB_CURRENT_VERSION = 1; 377 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 378 | ENABLE_MODULE_VERIFIER = YES; 379 | INFOPLIST_FILE = ImpressionKit/Info.plist; 380 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 381 | LD_RUNPATH_SEARCH_PATHS = ( 382 | "$(inherited)", 383 | "@executable_path/Frameworks", 384 | "@loader_path/Frameworks", 385 | ); 386 | MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; 387 | PRODUCT_BUNDLE_IDENTIFIER = com.yanni.ImpressionKit; 388 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 389 | SKIP_INSTALL = YES; 390 | SUPPORTS_MACCATALYST = NO; 391 | SWIFT_VERSION = 5.0; 392 | TARGETED_DEVICE_FAMILY = "1,2"; 393 | }; 394 | name = Release; 395 | }; 396 | /* End XCBuildConfiguration section */ 397 | 398 | /* Begin XCConfigurationList section */ 399 | 5C1ED02F26637C7800C68451 /* Build configuration list for PBXProject "ImpressionKit" */ = { 400 | isa = XCConfigurationList; 401 | buildConfigurations = ( 402 | 5C1ED03B26637C7900C68451 /* Debug */, 403 | 5C1ED03C26637C7900C68451 /* Release */, 404 | ); 405 | defaultConfigurationIsVisible = 0; 406 | defaultConfigurationName = Release; 407 | }; 408 | 5C1ED03D26637C7900C68451 /* Build configuration list for PBXNativeTarget "ImpressionKit" */ = { 409 | isa = XCConfigurationList; 410 | buildConfigurations = ( 411 | 5C1ED03E26637C7900C68451 /* Debug */, 412 | 5C1ED03F26637C7900C68451 /* Release */, 413 | ); 414 | defaultConfigurationIsVisible = 0; 415 | defaultConfigurationName = Release; 416 | }; 417 | /* End XCConfigurationList section */ 418 | }; 419 | rootObject = 5C1ED02C26637C7800C68451 /* Project object */; 420 | } 421 | -------------------------------------------------------------------------------- /ImpressionKitExample/ImpressionKitExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 2AA9DA45279A9E7200A9C06A /* CollectionViewDemo2ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AA9DA44279A9E7200A9C06A /* CollectionViewDemo2ViewController.swift */; }; 11 | 5C1ED04E26637CB000C68451 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1ED04D26637CB000C68451 /* AppDelegate.swift */; }; 12 | 5C1ED05726637CB000C68451 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5C1ED05626637CB000C68451 /* Assets.xcassets */; }; 13 | 5C1ED05A26637CB000C68451 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5C1ED05826637CB000C68451 /* LaunchScreen.storyboard */; }; 14 | 5C4B30D4266D026900B61135 /* TableViewDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B30D3266D026900B61135 /* TableViewDemoViewController.swift */; }; 15 | 5C87C65C2664DB48006E824E /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C87C65B2664DB48006E824E /* HomeViewController.swift */; }; 16 | 5C87C6612664F2B5006E824E /* ScrollViewDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C87C6602664F2B5006E824E /* ScrollViewDemoViewController.swift */; }; 17 | 5C87C6662664F327006E824E /* CollectionViewDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C87C6652664F327006E824E /* CollectionViewDemoViewController.swift */; }; 18 | 62F7F59526A973A9003DF049 /* SwiftUIListDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F7F59426A973A9003DF049 /* SwiftUIListDemoView.swift */; }; 19 | 634294AE6E2BB68D33D5D7F6 /* Pods_ImpressionKitExample.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E14EEC2186D571924183418 /* Pods_ImpressionKitExample.framework */; }; 20 | 9CB13B7526AD634900A510F9 /* SwiftUIScrollViewDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB13B7426AD634900A510F9 /* SwiftUIScrollViewDemoView.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | 2AA9DA44279A9E7200A9C06A /* CollectionViewDemo2ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewDemo2ViewController.swift; sourceTree = ""; }; 25 | 5C1ED04A26637CB000C68451 /* ImpressionKitExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ImpressionKitExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 5C1ED04D26637CB000C68451 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 27 | 5C1ED05626637CB000C68451 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 28 | 5C1ED05926637CB000C68451 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 29 | 5C1ED05B26637CB000C68451 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 30 | 5C4B30D3266D026900B61135 /* TableViewDemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewDemoViewController.swift; sourceTree = ""; }; 31 | 5C87C65B2664DB48006E824E /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; 32 | 5C87C6602664F2B5006E824E /* ScrollViewDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDemoViewController.swift; sourceTree = ""; }; 33 | 5C87C6652664F327006E824E /* CollectionViewDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewDemoViewController.swift; sourceTree = ""; }; 34 | 62F7F59426A973A9003DF049 /* SwiftUIListDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIListDemoView.swift; sourceTree = ""; }; 35 | 6E14EEC2186D571924183418 /* Pods_ImpressionKitExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ImpressionKitExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 36 | 9CB13B7426AD634900A510F9 /* SwiftUIScrollViewDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIScrollViewDemoView.swift; sourceTree = ""; }; 37 | D6FDACE8F7FE7BF61899FD4A /* Pods-ImpressionKitExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImpressionKitExample.debug.xcconfig"; path = "Target Support Files/Pods-ImpressionKitExample/Pods-ImpressionKitExample.debug.xcconfig"; sourceTree = ""; }; 38 | E93E5965889DA252ED3ECF35 /* Pods-ImpressionKitExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImpressionKitExample.release.xcconfig"; path = "Target Support Files/Pods-ImpressionKitExample/Pods-ImpressionKitExample.release.xcconfig"; sourceTree = ""; }; 39 | /* End PBXFileReference section */ 40 | 41 | /* Begin PBXFrameworksBuildPhase section */ 42 | 5C1ED04726637CB000C68451 /* Frameworks */ = { 43 | isa = PBXFrameworksBuildPhase; 44 | buildActionMask = 2147483647; 45 | files = ( 46 | 634294AE6E2BB68D33D5D7F6 /* Pods_ImpressionKitExample.framework in Frameworks */, 47 | ); 48 | runOnlyForDeploymentPostprocessing = 0; 49 | }; 50 | /* End PBXFrameworksBuildPhase section */ 51 | 52 | /* Begin PBXGroup section */ 53 | 0AADC43F91C62FB71E41F54E /* Frameworks */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | 6E14EEC2186D571924183418 /* Pods_ImpressionKitExample.framework */, 57 | ); 58 | name = Frameworks; 59 | sourceTree = ""; 60 | }; 61 | 5C1ED04126637CB000C68451 = { 62 | isa = PBXGroup; 63 | children = ( 64 | 5C1ED04C26637CB000C68451 /* ImpressionKitExample */, 65 | 5C1ED04B26637CB000C68451 /* Products */, 66 | 647CED7184DD762C73417487 /* Pods */, 67 | 0AADC43F91C62FB71E41F54E /* Frameworks */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | 5C1ED04B26637CB000C68451 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | 5C1ED04A26637CB000C68451 /* ImpressionKitExample.app */, 75 | ); 76 | name = Products; 77 | sourceTree = ""; 78 | }; 79 | 5C1ED04C26637CB000C68451 /* ImpressionKitExample */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | 5C1ED04D26637CB000C68451 /* AppDelegate.swift */, 83 | 5C87C65B2664DB48006E824E /* HomeViewController.swift */, 84 | 5C87C6602664F2B5006E824E /* ScrollViewDemoViewController.swift */, 85 | 5C87C6652664F327006E824E /* CollectionViewDemoViewController.swift */, 86 | 2AA9DA44279A9E7200A9C06A /* CollectionViewDemo2ViewController.swift */, 87 | 5C4B30D3266D026900B61135 /* TableViewDemoViewController.swift */, 88 | 9CB13B7426AD634900A510F9 /* SwiftUIScrollViewDemoView.swift */, 89 | 62F7F59426A973A9003DF049 /* SwiftUIListDemoView.swift */, 90 | 5C1ED05626637CB000C68451 /* Assets.xcassets */, 91 | 5C1ED05826637CB000C68451 /* LaunchScreen.storyboard */, 92 | 5C1ED05B26637CB000C68451 /* Info.plist */, 93 | ); 94 | path = ImpressionKitExample; 95 | sourceTree = ""; 96 | }; 97 | 647CED7184DD762C73417487 /* Pods */ = { 98 | isa = PBXGroup; 99 | children = ( 100 | D6FDACE8F7FE7BF61899FD4A /* Pods-ImpressionKitExample.debug.xcconfig */, 101 | E93E5965889DA252ED3ECF35 /* Pods-ImpressionKitExample.release.xcconfig */, 102 | ); 103 | path = Pods; 104 | sourceTree = ""; 105 | }; 106 | /* End PBXGroup section */ 107 | 108 | /* Begin PBXNativeTarget section */ 109 | 5C1ED04926637CB000C68451 /* ImpressionKitExample */ = { 110 | isa = PBXNativeTarget; 111 | buildConfigurationList = 5C1ED05E26637CB000C68451 /* Build configuration list for PBXNativeTarget "ImpressionKitExample" */; 112 | buildPhases = ( 113 | D18904B0EE4D0A65025D86B4 /* [CP] Check Pods Manifest.lock */, 114 | 5C1ED04626637CB000C68451 /* Sources */, 115 | 5C1ED04726637CB000C68451 /* Frameworks */, 116 | 5C1ED04826637CB000C68451 /* Resources */, 117 | BADCF342DB72A74F92A19C9D /* [CP] Embed Pods Frameworks */, 118 | ); 119 | buildRules = ( 120 | ); 121 | dependencies = ( 122 | ); 123 | name = ImpressionKitExample; 124 | productName = ImpressionKitExample; 125 | productReference = 5C1ED04A26637CB000C68451 /* ImpressionKitExample.app */; 126 | productType = "com.apple.product-type.application"; 127 | }; 128 | /* End PBXNativeTarget section */ 129 | 130 | /* Begin PBXProject section */ 131 | 5C1ED04226637CB000C68451 /* Project object */ = { 132 | isa = PBXProject; 133 | attributes = { 134 | LastSwiftUpdateCheck = 1220; 135 | LastUpgradeCheck = 1220; 136 | TargetAttributes = { 137 | 5C1ED04926637CB000C68451 = { 138 | CreatedOnToolsVersion = 12.2; 139 | }; 140 | }; 141 | }; 142 | buildConfigurationList = 5C1ED04526637CB000C68451 /* Build configuration list for PBXProject "ImpressionKitExample" */; 143 | compatibilityVersion = "Xcode 9.3"; 144 | developmentRegion = en; 145 | hasScannedForEncodings = 0; 146 | knownRegions = ( 147 | en, 148 | Base, 149 | ); 150 | mainGroup = 5C1ED04126637CB000C68451; 151 | productRefGroup = 5C1ED04B26637CB000C68451 /* Products */; 152 | projectDirPath = ""; 153 | projectRoot = ""; 154 | targets = ( 155 | 5C1ED04926637CB000C68451 /* ImpressionKitExample */, 156 | ); 157 | }; 158 | /* End PBXProject section */ 159 | 160 | /* Begin PBXResourcesBuildPhase section */ 161 | 5C1ED04826637CB000C68451 /* Resources */ = { 162 | isa = PBXResourcesBuildPhase; 163 | buildActionMask = 2147483647; 164 | files = ( 165 | 5C1ED05A26637CB000C68451 /* LaunchScreen.storyboard in Resources */, 166 | 5C1ED05726637CB000C68451 /* Assets.xcassets in Resources */, 167 | ); 168 | runOnlyForDeploymentPostprocessing = 0; 169 | }; 170 | /* End PBXResourcesBuildPhase section */ 171 | 172 | /* Begin PBXShellScriptBuildPhase section */ 173 | BADCF342DB72A74F92A19C9D /* [CP] Embed Pods Frameworks */ = { 174 | isa = PBXShellScriptBuildPhase; 175 | buildActionMask = 2147483647; 176 | files = ( 177 | ); 178 | inputFileListPaths = ( 179 | "${PODS_ROOT}/Target Support Files/Pods-ImpressionKitExample/Pods-ImpressionKitExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", 180 | ); 181 | name = "[CP] Embed Pods Frameworks"; 182 | outputFileListPaths = ( 183 | "${PODS_ROOT}/Target Support Files/Pods-ImpressionKitExample/Pods-ImpressionKitExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", 184 | ); 185 | runOnlyForDeploymentPostprocessing = 0; 186 | shellPath = /bin/sh; 187 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ImpressionKitExample/Pods-ImpressionKitExample-frameworks.sh\"\n"; 188 | showEnvVarsInLog = 0; 189 | }; 190 | D18904B0EE4D0A65025D86B4 /* [CP] Check Pods Manifest.lock */ = { 191 | isa = PBXShellScriptBuildPhase; 192 | buildActionMask = 2147483647; 193 | files = ( 194 | ); 195 | inputFileListPaths = ( 196 | ); 197 | inputPaths = ( 198 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 199 | "${PODS_ROOT}/Manifest.lock", 200 | ); 201 | name = "[CP] Check Pods Manifest.lock"; 202 | outputFileListPaths = ( 203 | ); 204 | outputPaths = ( 205 | "$(DERIVED_FILE_DIR)/Pods-ImpressionKitExample-checkManifestLockResult.txt", 206 | ); 207 | runOnlyForDeploymentPostprocessing = 0; 208 | shellPath = /bin/sh; 209 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 210 | showEnvVarsInLog = 0; 211 | }; 212 | /* End PBXShellScriptBuildPhase section */ 213 | 214 | /* Begin PBXSourcesBuildPhase section */ 215 | 5C1ED04626637CB000C68451 /* Sources */ = { 216 | isa = PBXSourcesBuildPhase; 217 | buildActionMask = 2147483647; 218 | files = ( 219 | 5C87C6662664F327006E824E /* CollectionViewDemoViewController.swift in Sources */, 220 | 62F7F59526A973A9003DF049 /* SwiftUIListDemoView.swift in Sources */, 221 | 5C87C6612664F2B5006E824E /* ScrollViewDemoViewController.swift in Sources */, 222 | 9CB13B7526AD634900A510F9 /* SwiftUIScrollViewDemoView.swift in Sources */, 223 | 5C87C65C2664DB48006E824E /* HomeViewController.swift in Sources */, 224 | 5C4B30D4266D026900B61135 /* TableViewDemoViewController.swift in Sources */, 225 | 5C1ED04E26637CB000C68451 /* AppDelegate.swift in Sources */, 226 | 2AA9DA45279A9E7200A9C06A /* CollectionViewDemo2ViewController.swift in Sources */, 227 | ); 228 | runOnlyForDeploymentPostprocessing = 0; 229 | }; 230 | /* End PBXSourcesBuildPhase section */ 231 | 232 | /* Begin PBXVariantGroup section */ 233 | 5C1ED05826637CB000C68451 /* LaunchScreen.storyboard */ = { 234 | isa = PBXVariantGroup; 235 | children = ( 236 | 5C1ED05926637CB000C68451 /* Base */, 237 | ); 238 | name = LaunchScreen.storyboard; 239 | sourceTree = ""; 240 | }; 241 | /* End PBXVariantGroup section */ 242 | 243 | /* Begin XCBuildConfiguration section */ 244 | 5C1ED05C26637CB000C68451 /* Debug */ = { 245 | isa = XCBuildConfiguration; 246 | buildSettings = { 247 | ALWAYS_SEARCH_USER_PATHS = NO; 248 | CLANG_ANALYZER_NONNULL = YES; 249 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 250 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 251 | CLANG_CXX_LIBRARY = "libc++"; 252 | CLANG_ENABLE_MODULES = YES; 253 | CLANG_ENABLE_OBJC_ARC = YES; 254 | CLANG_ENABLE_OBJC_WEAK = YES; 255 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 256 | CLANG_WARN_BOOL_CONVERSION = YES; 257 | CLANG_WARN_COMMA = YES; 258 | CLANG_WARN_CONSTANT_CONVERSION = YES; 259 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 260 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 261 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 262 | CLANG_WARN_EMPTY_BODY = YES; 263 | CLANG_WARN_ENUM_CONVERSION = YES; 264 | CLANG_WARN_INFINITE_RECURSION = YES; 265 | CLANG_WARN_INT_CONVERSION = YES; 266 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 267 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 268 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 269 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 270 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 271 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 272 | CLANG_WARN_STRICT_PROTOTYPES = YES; 273 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 274 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 275 | CLANG_WARN_UNREACHABLE_CODE = YES; 276 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 277 | COPY_PHASE_STRIP = NO; 278 | DEBUG_INFORMATION_FORMAT = dwarf; 279 | ENABLE_STRICT_OBJC_MSGSEND = YES; 280 | ENABLE_TESTABILITY = YES; 281 | GCC_C_LANGUAGE_STANDARD = gnu11; 282 | GCC_DYNAMIC_NO_PIC = NO; 283 | GCC_NO_COMMON_BLOCKS = YES; 284 | GCC_OPTIMIZATION_LEVEL = 0; 285 | GCC_PREPROCESSOR_DEFINITIONS = ( 286 | "DEBUG=1", 287 | "$(inherited)", 288 | ); 289 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 290 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 291 | GCC_WARN_UNDECLARED_SELECTOR = YES; 292 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 293 | GCC_WARN_UNUSED_FUNCTION = YES; 294 | GCC_WARN_UNUSED_VARIABLE = YES; 295 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 296 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 297 | MTL_FAST_MATH = YES; 298 | ONLY_ACTIVE_ARCH = YES; 299 | SDKROOT = iphoneos; 300 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 301 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 302 | }; 303 | name = Debug; 304 | }; 305 | 5C1ED05D26637CB000C68451 /* Release */ = { 306 | isa = XCBuildConfiguration; 307 | buildSettings = { 308 | ALWAYS_SEARCH_USER_PATHS = NO; 309 | CLANG_ANALYZER_NONNULL = YES; 310 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 311 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 312 | CLANG_CXX_LIBRARY = "libc++"; 313 | CLANG_ENABLE_MODULES = YES; 314 | CLANG_ENABLE_OBJC_ARC = YES; 315 | CLANG_ENABLE_OBJC_WEAK = YES; 316 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 317 | CLANG_WARN_BOOL_CONVERSION = YES; 318 | CLANG_WARN_COMMA = YES; 319 | CLANG_WARN_CONSTANT_CONVERSION = YES; 320 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 321 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 322 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 323 | CLANG_WARN_EMPTY_BODY = YES; 324 | CLANG_WARN_ENUM_CONVERSION = YES; 325 | CLANG_WARN_INFINITE_RECURSION = YES; 326 | CLANG_WARN_INT_CONVERSION = YES; 327 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 328 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 329 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 330 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 331 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 332 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 333 | CLANG_WARN_STRICT_PROTOTYPES = YES; 334 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 335 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 336 | CLANG_WARN_UNREACHABLE_CODE = YES; 337 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 338 | COPY_PHASE_STRIP = NO; 339 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 340 | ENABLE_NS_ASSERTIONS = NO; 341 | ENABLE_STRICT_OBJC_MSGSEND = YES; 342 | GCC_C_LANGUAGE_STANDARD = gnu11; 343 | GCC_NO_COMMON_BLOCKS = YES; 344 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 345 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 346 | GCC_WARN_UNDECLARED_SELECTOR = YES; 347 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 348 | GCC_WARN_UNUSED_FUNCTION = YES; 349 | GCC_WARN_UNUSED_VARIABLE = YES; 350 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 351 | MTL_ENABLE_DEBUG_INFO = NO; 352 | MTL_FAST_MATH = YES; 353 | SDKROOT = iphoneos; 354 | SWIFT_COMPILATION_MODE = wholemodule; 355 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 356 | VALIDATE_PRODUCT = YES; 357 | }; 358 | name = Release; 359 | }; 360 | 5C1ED05F26637CB000C68451 /* Debug */ = { 361 | isa = XCBuildConfiguration; 362 | baseConfigurationReference = D6FDACE8F7FE7BF61899FD4A /* Pods-ImpressionKitExample.debug.xcconfig */; 363 | buildSettings = { 364 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 365 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 366 | CODE_SIGN_IDENTITY = "Apple Development"; 367 | CODE_SIGN_STYLE = Automatic; 368 | DEVELOPMENT_TEAM = 2X48A7XP4X; 369 | INFOPLIST_FILE = ImpressionKitExample/Info.plist; 370 | LD_RUNPATH_SEARCH_PATHS = ( 371 | "$(inherited)", 372 | "@executable_path/Frameworks", 373 | ); 374 | PRODUCT_BUNDLE_IDENTIFIER = com.yanni.demo; 375 | PRODUCT_NAME = "$(TARGET_NAME)"; 376 | PROVISIONING_PROFILE_SPECIFIER = ""; 377 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 378 | SUPPORTS_MACCATALYST = NO; 379 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 380 | SWIFT_VERSION = 5.0; 381 | TARGETED_DEVICE_FAMILY = 1; 382 | }; 383 | name = Debug; 384 | }; 385 | 5C1ED06026637CB000C68451 /* Release */ = { 386 | isa = XCBuildConfiguration; 387 | baseConfigurationReference = E93E5965889DA252ED3ECF35 /* Pods-ImpressionKitExample.release.xcconfig */; 388 | buildSettings = { 389 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 390 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 391 | CODE_SIGN_STYLE = Automatic; 392 | DEVELOPMENT_TEAM = Y79YM93K8M; 393 | INFOPLIST_FILE = ImpressionKitExample/Info.plist; 394 | LD_RUNPATH_SEARCH_PATHS = ( 395 | "$(inherited)", 396 | "@executable_path/Frameworks", 397 | ); 398 | PRODUCT_BUNDLE_IDENTIFIER = com.yanni.test; 399 | PRODUCT_NAME = "$(TARGET_NAME)"; 400 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 401 | SUPPORTS_MACCATALYST = NO; 402 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 403 | SWIFT_VERSION = 5.0; 404 | TARGETED_DEVICE_FAMILY = 1; 405 | }; 406 | name = Release; 407 | }; 408 | /* End XCBuildConfiguration section */ 409 | 410 | /* Begin XCConfigurationList section */ 411 | 5C1ED04526637CB000C68451 /* Build configuration list for PBXProject "ImpressionKitExample" */ = { 412 | isa = XCConfigurationList; 413 | buildConfigurations = ( 414 | 5C1ED05C26637CB000C68451 /* Debug */, 415 | 5C1ED05D26637CB000C68451 /* Release */, 416 | ); 417 | defaultConfigurationIsVisible = 0; 418 | defaultConfigurationName = Release; 419 | }; 420 | 5C1ED05E26637CB000C68451 /* Build configuration list for PBXNativeTarget "ImpressionKitExample" */ = { 421 | isa = XCConfigurationList; 422 | buildConfigurations = ( 423 | 5C1ED05F26637CB000C68451 /* Debug */, 424 | 5C1ED06026637CB000C68451 /* Release */, 425 | ); 426 | defaultConfigurationIsVisible = 0; 427 | defaultConfigurationName = Release; 428 | }; 429 | /* End XCConfigurationList section */ 430 | }; 431 | rootObject = 5C1ED04226637CB000C68451 /* Project object */; 432 | } 433 | --------------------------------------------------------------------------------