Files
Cable/Pods/PostHog/PostHog/ApplicationViewLayoutPublisher.swift
Stefan Lange-Hegermann ced06f9eb6 ads tracking
2025-11-05 11:13:54 +01:00

160 lines
5.2 KiB
Swift

//
// ApplicationViewLayoutPublisher.swift
// PostHog
//
// Created by Ioannis Josephides on 19/03/2025.
//
#if os(iOS) || os(tvOS)
import UIKit
typealias ApplicationViewLayoutHandler = () -> Void
protocol ViewLayoutPublishing: AnyObject {
/// Registers a callback for getting notified when a UIView is laid out.
/// Note: callback guaranteed to be called on main thread
func onViewLayout(throttle: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken
}
final class ApplicationViewLayoutPublisher: BaseApplicationViewLayoutPublisher {
static let shared = ApplicationViewLayoutPublisher()
private var hasSwizzled: Bool = false
func start() {
swizzleLayoutSubviews()
}
func stop() {
unswizzleLayoutSubviews()
}
func swizzleLayoutSubviews() {
guard !hasSwizzled else { return }
hasSwizzled = true
swizzle(
forClass: UIView.self,
original: #selector(UIView.layoutSublayers(of:)),
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
)
}
func unswizzleLayoutSubviews() {
guard hasSwizzled else { return }
hasSwizzled = false
// swizzling twice will exchange implementations back to original
swizzle(
forClass: UIView.self,
original: #selector(UIView.layoutSublayers(of:)),
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
)
}
override func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self.onViewLayoutCallbacks[id] = ThrottledHandler(handler: callback, interval: interval)
}
// start on first callback registration
if !hasSwizzled {
start()
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
let handlerCount = self.registrationLock.withLock {
self.onViewLayoutCallbacks[id] = nil
return self.onViewLayoutCallbacks.values.count
}
// stop when there are no more callbacks
if handlerCount <= 0 {
self.stop()
}
}
}
// Called from swizzled `UIView.layoutSubviews`
fileprivate func layoutSubviews() {
notifyHandlers()
}
#if TESTING
func simulateLayoutSubviews() {
layoutSubviews()
}
#endif
}
class BaseApplicationViewLayoutPublisher: ViewLayoutPublishing {
fileprivate let registrationLock = NSLock()
var onViewLayoutCallbacks: [UUID: ThrottledHandler] = [:]
final class ThrottledHandler {
static let throttleQueue = DispatchQueue(label: "com.posthog.ThrottledHandler",
target: .global(qos: .utility))
let interval: TimeInterval
let handler: ApplicationViewLayoutHandler
private var lastFired: Date = .distantPast
init(handler: @escaping ApplicationViewLayoutHandler, interval: TimeInterval) {
self.handler = handler
self.interval = interval
}
func throttleHandler() {
let now = now()
let timeSinceLastFired = now.timeIntervalSince(lastFired)
if timeSinceLastFired >= interval {
lastFired = now
// notify on main
DispatchQueue.main.async(execute: handler)
}
}
}
func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self.onViewLayoutCallbacks[id] = ThrottledHandler(
handler: callback,
interval: interval
)
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
self.registrationLock.withLock {
self.onViewLayoutCallbacks[id] = nil
}
}
}
func notifyHandlers() {
ThrottledHandler.throttleQueue.async {
// Don't lock on main
let handlers = self.registrationLock.withLock { self.onViewLayoutCallbacks.values }
for handler in handlers {
handler.throttleHandler()
}
}
}
}
extension UIView {
@objc func ph_swizzled_layoutSublayers(of layer: CALayer) {
ph_swizzled_layoutSublayers(of: layer) // call original, not altering execution logic
ApplicationViewLayoutPublisher.shared.layoutSubviews()
}
}
#endif