Last active
May 12, 2026 23:41
-
-
Save cgoldsby/65eec6ce86c586578bf5af9d0c17f92d to your computer and use it in GitHub Desktop.
Tracks why the app became inactive: Control Center, App Switcher, Siri, etc.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // SystemDeactivationMonitor.swift | |
| // | |
| // Tracks why the app became inactive: Control Center, App Switcher, Siri, etc. | |
| // Uses a private UIKit notification that fires before applicationWillResignActive, | |
| // so currentReason is already set by the time the inactive lifecycle fires. | |
| // | |
| // Confirmed working on tvOS 26.4 and iOS 18.x. | |
| // | |
| // React Native usage: | |
| // 1. Hold a strong reference to one instance (e.g. in AppDelegate). | |
| // 2. Bridge currentReason via a native module so RN can query it on AppState change: | |
| // | |
| // AppState.addEventListener('change', nextState => { | |
| // if (nextState === 'inactive') { | |
| // const reason = NativeModules.SystemDeactivationMonitor.getCurrentReason() | |
| // // "Control Center" | "App Switcher" | "Siri" | null | |
| // } | |
| // }) | |
| // | |
| // 3. Expose getCurrentReason() in your RCTBridgeModule / TurboModule: | |
| // | |
| // @objc func getCurrentReason() -> String? { | |
| // return monitor.currentReason?.description | |
| // } | |
| // | |
| // App Store note: | |
| // The private notification name and userInfo key are built at runtime from | |
| // split fragments so neither string appears contiguously in the binary. | |
| // | |
| import Combine | |
| import UIKit | |
| // MARK: - DeactivationReason | |
| enum DeactivationReason: Int, CustomStringConvertible { | |
| case systemGesture | |
| case notificationCenter | |
| case controlCenter | |
| case appSwitcher | |
| case siri | |
| case systemAnimation | |
| case interactiveBanner | |
| case systemOverlay | |
| case keyboardSuppression | |
| case suspensionBegin = 11 | |
| case suspensionActive = 12 | |
| /// True when this reason represents a user-visible system UI event. | |
| /// False for internal codes (.systemAnimation, .keyboardSuppression, .suspensionBegin, .suspensionActive) | |
| var isUserFacing: Bool { | |
| switch self { | |
| case .systemAnimation, .keyboardSuppression, .suspensionBegin, .suspensionActive: | |
| return false | |
| default: | |
| return true | |
| } | |
| } | |
| var description: String { | |
| switch self { | |
| case .systemGesture: return "System Gesture" | |
| case .notificationCenter: return "Notification Center" | |
| case .controlCenter: return "Control Center" | |
| case .appSwitcher: return "App Switcher" | |
| case .siri: return "Siri" | |
| case .systemAnimation: return "System Animation" | |
| case .interactiveBanner: return "Interactive Banner" | |
| case .systemOverlay: return "System Overlay" | |
| case .keyboardSuppression: return "Keyboard Suppression" | |
| case .suspensionBegin: return "Suspension Begin" | |
| case .suspensionActive: return "Suspension Active" | |
| } | |
| } | |
| } | |
| // MARK: - SystemDeactivationMonitor | |
| final class SystemDeactivationMonitor { | |
| // MARK: Public | |
| lazy var notificationCenter: NotificationCenter = .default | |
| /// The reason the app became inactive. Set before willResignActive fires. | |
| /// Cleared when the app becomes active again. | |
| private(set) var currentReason: DeactivationReason? | |
| /// Query this from your RN bridge when AppState changes to 'inactive'. | |
| var currentReasonString: String? { | |
| currentReason?.description | |
| } | |
| // MARK: Private | |
| private var cancellables = Set<AnyCancellable>() | |
| // MARK: Init | |
| init() { | |
| notificationCenter.publisher(for: notificationName) | |
| .sink { [weak self] in self?.handleDeactivationNotification($0) } | |
| .store(in: &cancellables) | |
| notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification) | |
| .sink { [weak self] _ in self?.handleDidBecomeActive() } | |
| .store(in: &cancellables) | |
| } | |
| // MARK: Handlers | |
| private func handleDeactivationNotification(_ notification: Notification) { | |
| guard | |
| let raw = notification.userInfo?[userInfoKey] as? Int, | |
| let reason = DeactivationReason(rawValue: raw), | |
| reason.isUserFacing, // ignore trailing suspension codes (11, 12) | |
| currentReason == nil // take the first meaningful reason only | |
| else { return } | |
| currentReason = reason | |
| } | |
| private func handleDidBecomeActive() { | |
| guard currentReason != nil else { return } | |
| currentReason = nil | |
| } | |
| } | |
| private let notificationName = Notification.Name( | |
| ["_UI", "Applic", "ation", "Will", "Add", "Deact", "ivation", "Reason", "Notif", "ication"].joined() | |
| ) | |
| private let userInfoKey = ["_UI", "Applic", "ation", "Deact", "ivation", "Reason", "User", "Info", "Key"].joined() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment