This document describes a pragmatic solution for monitoring focus changes in iOS view controllers. The pattern allows view controllers to be notified when they gain or lose user attention due to various system events like modals, alerts, app backgrounding, or navigation changes.
iOS view controllers need to react to focus changes, but the platform doesn't provide a built-in unified API for this. A view controller may lose focus in several ways:
- Another view controller is pushed onto the navigation stack
- A modal is presented over it
- An alert or action sheet is displayed
- The app enters the background
- System overlays appear (Control Center, notification center, etc.)
- Another window becomes key (iPad multitasking, alerts, etc.)
Traditional lifecycle methods (viewDidAppear, viewWillDisappear) don't capture all these scenarios, especially when modals or alerts are presented.
Several approaches were explored but proved inadequate:
viewWillAppear/viewWillDisappear: Not called when modals or alerts are presented on top of a view controllerUIWindow.didBecomeVisibleNotification: Doesn't fire for modals, alerts, or view controller transitions within the same windowUIWindow.didBecomeKeyNotification: Not triggered for modals presented within the same windowUIScenenotifications: Also don't fire for in-window view controller changes (navigation pushes, tab switches, modals)UIAdaptivePresentationControllerDelegate: Requires manual invocation and doesn't work automatically without access to the presenting code- Private API solutions (e.g.,
_viewDelegate): Work but are unreliable and may break in future iOS versions
The core challenge: iOS provides no built-in notification or delegate callback when a view controller becomes visually obscured by another view controller in the same window.
The solution is implemented as a UIViewController extension that uses a combination of:
- View lifecycle monitoring -
viewDidAppearandviewWillDisappear - App lifecycle monitoring - Background/foreground notifications
- Polling mechanism - Periodic checks (60fps) to detect modal/alert presentations
- Associated objects - For storing state without requiring stored properties
This extension-based approach means no base class is required - any UIViewController can adopt focus monitoring by calling a few lifecycle methods.
The extension uses associated objects to store state without requiring stored properties:
hasFocus: Bool- Current focus statecurrentlyHasFocus: Bool?- Previous focus state for change detectionfocusCheckTimer: Timer?- Timer for polling when view is visiblehasSetupFocusLifecycle: Bool- Ensures notifications are only registered once
View controllers override these to participate in focus monitoring:
@objc open var wantsToListenOnFocusEvents: Bool {
return false // Default: opt-in required
}
@objc open func onWindowFocusChanged(hasFocus: Bool) {
// Override to receive focus change notifications
}Three methods integrate with the view controller lifecycle:
handleFocusMonitoring_viewDidAppear() // Call from viewDidAppear
handleFocusMonitoring_viewWillDisappear() // Call from viewWillDisappear
cleanupFocusMonitoring() // Call from deinitThe system determines focus by checking multiple conditions:
- App state: Is the app active or in background?
- View hierarchy: Is the view loaded and in a window?
- Key window: Is our window the key window?
- Presented view controllers: Are there modals/alerts covering us at any level?
This multi-level check ensures comprehensive coverage of all scenarios.
The entire focus monitoring system can be implemented as a UIViewController extension, requiring minimal setup in your view controllers.
import UIKit
// MARK: - Window Focus Management Extension
extension UIViewController {
// MARK: - Associated Object Keys
private static var hasFocusKey: UInt8 = 0
private static var currentlyHasFocusKey: UInt8 = 0
private static var hasRegisteredForFocusEventsKey: UInt8 = 0
private static var focusCheckTimerKey: UInt8 = 0
private static var hasSetupFocusLifecycleKey: UInt8 = 0
// MARK: - Public Properties
var hasFocus: Bool {
get {
return objc_getAssociatedObject(self, &Self.hasFocusKey) as? Bool ?? false
}
set {
objc_setAssociatedObject(self, &Self.hasFocusKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var currentlyHasFocus: Bool? {
get {
return objc_getAssociatedObject(self, &Self.currentlyHasFocusKey) as? Bool
}
set {
objc_setAssociatedObject(self, &Self.currentlyHasFocusKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var hasRegisteredForFocusEvents: Bool {
get {
return objc_getAssociatedObject(self, &Self.hasRegisteredForFocusEventsKey) as? Bool ?? false
}
set {
objc_setAssociatedObject(self, &Self.hasRegisteredForFocusEventsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var focusCheckTimer: Timer? {
get {
return objc_getAssociatedObject(self, &Self.focusCheckTimerKey) as? Timer
}
set {
objc_setAssociatedObject(self, &Self.focusCheckTimerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var hasSetupFocusLifecycle: Bool {
get {
return objc_getAssociatedObject(self, &Self.hasSetupFocusLifecycleKey) as? Bool ?? false
}
set {
objc_setAssociatedObject(self, &Self.hasSetupFocusLifecycleKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
// MARK: - Public API
/// Override this property in subclasses to enable focus monitoring
@objc open var wantsToListenOnFocusEvents: Bool {
return false // Default: opt-in
}
/// Override this method to receive window focus change events
@objc open func onWindowFocusChanged(hasFocus: Bool) { }
// MARK: - Setup
/// Call this from viewDidLoad to enable focus monitoring
func setupFocusMonitoring() {
guard !hasSetupFocusLifecycle else { return }
hasSetupFocusLifecycle = true
// Register for app lifecycle notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(focusMonitoring_appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(focusMonitoring_appWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
/// Call this from viewDidAppear
func handleFocusMonitoring_viewDidAppear() {
setupFocusMonitoring()
let actualHasFocus = isViewControllerCurrentlyFocused()
onWindowFocusChangedInternal(hasFocus: actualHasFocus)
if wantsToListenOnFocusEvents {
startFocusMonitoring()
}
}
/// Call this from viewWillDisappear
func handleFocusMonitoring_viewWillDisappear() {
onWindowFocusChangedInternal(hasFocus: false)
if wantsToListenOnFocusEvents {
stopFocusMonitoring()
}
}
/// Call this from deinit
func cleanupFocusMonitoring() {
focusCheckTimer?.invalidate()
focusCheckTimer = nil
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
}
// MARK: - Private Implementation
private func onWindowFocusChangedInternal(hasFocus: Bool) {
if currentlyHasFocus != hasFocus {
currentlyHasFocus = hasFocus
onWindowFocusChanged(hasFocus: hasFocus)
}
}
@objc private func focusMonitoring_appDidEnterBackground() {
onWindowFocusChangedInternal(hasFocus: false)
}
@objc private func focusMonitoring_appWillEnterForeground() {
if isViewLoaded && view.window != nil {
let hasFocus = isViewControllerCurrentlyFocused()
onWindowFocusChangedInternal(hasFocus: hasFocus)
}
}
private func startFocusMonitoring() {
guard wantsToListenOnFocusEvents else { return }
focusCheckTimer?.invalidate()
focusCheckTimer = Timer.scheduledTimer(
withTimeInterval: 1.0/60.0,
repeats: true
) { [weak self] _ in
self?.checkFocusState()
}
}
private func stopFocusMonitoring() {
focusCheckTimer?.invalidate()
focusCheckTimer = nil
}
private func checkFocusState() {
let currentlyHasFocus = isViewControllerCurrentlyFocused()
onWindowFocusChangedInternal(hasFocus: currentlyHasFocus)
}
private func isViewControllerCurrentlyFocused() -> Bool {
// Check if app is in background or inactive
if UIApplication.shared.applicationState != .active {
return false
}
// Check if we're not in the view hierarchy
guard view.window != nil, isViewLoaded else {
return false
}
// Check if another window is key (overlay, alert, etc.)
if let keyWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow }),
keyWindow != view.window {
return false
}
// Check if we have a presented view controller covering us
if presentedViewController != nil {
return false
}
// Check if we're in a navigation controller and it has a presented view controller
if let navController = navigationController,
navController.presentedViewController != nil {
return false
}
// Check if the tab bar controller (if any) has a presented view controller
if let tabBarController = tabBarController,
tabBarController.presentedViewController != nil {
return false
}
return true
}
}Create a base view controller that handles the setup:
import UIKit
class BaseViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
handleFocusMonitoring_viewDidAppear()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
handleFocusMonitoring_viewWillDisappear()
}
deinit {
cleanupFocusMonitoring()
}
}Then subclass it:
class MyViewController: BaseViewController {
override var wantsToListenOnFocusEvents: Bool {
return true
}
override func onWindowFocusChanged(hasFocus: Bool) {
super.onWindowFocusChanged(hasFocus: hasFocus)
if hasFocus {
print("Gained focus")
} else {
print("Lost focus")
}
}
}If you can't use a base view controller, call the methods directly:
class MyViewController: UIViewController {
override var wantsToListenOnFocusEvents: Bool {
return true
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
handleFocusMonitoring_viewDidAppear()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
handleFocusMonitoring_viewWillDisappear()
}
override func onWindowFocusChanged(hasFocus: Bool) {
if hasFocus {
print("Gained focus")
} else {
print("Lost focus")
}
}
deinit {
cleanupFocusMonitoring()
}
}Decision: Use a timer that fires 60 times per second to check focus state.
Rationale:
- Modal presentation doesn't trigger reliable lifecycle callbacks
- No notification exists for "modal was presented over view controller"
- 60fps matches the screen refresh rate, making focus changes feel instant to users
- Performance impact is minimal - the check is lightweight
Trade-offs:
- Slightly higher CPU usage when view is visible
- Battery impact is negligible due to simple boolean checks
- Alternative approaches (KVO, method swizzling) are more fragile and complex
Problem: viewDidAppear is called when the view appears, but doesn't fire when:
- A modal is presented over the view
- An alert is shown
- The app enters background
Solution: Combine lifecycle methods, app notifications, and polling for complete coverage.
Decision: Use Objective-C associated objects instead of stored properties in the extension.
Rationale:
- Swift extensions cannot add stored properties
- Associated objects allow state storage without requiring a base class
- No memory overhead for view controllers that don't enable focus monitoring
- Compatible with both Swift and Objective-C view controllers
- Allows the entire implementation to live in a single extension file
Trade-offs:
- Slightly more verbose property getter/setter declarations
- Runtime overhead is negligible (simple dictionary lookup)
- Type safety is maintained through property wrappers
Problem: A modal can be presented at different levels:
- Directly on the view controller
- On the navigation controller
- On the tab bar controller
Solution: Check all three levels to ensure comprehensive coverage.
Decision: Require subclasses to explicitly enable focus monitoring.
Rationale:
- Avoids unnecessary timer overhead for views that don't need it
- Makes the intent explicit in code
- Follows the principle of least surprise
The 60fps timer adds minimal overhead:
- Each check is a series of boolean evaluations
- No heavy computation or I/O
- Timer is only active when view is visible
- Invalidated immediately when view disappears
- Timer uses weak reference to avoid retain cycles
- Associated objects are properly cleaned up in
deinit - No memory leaks from notification observers (properly removed)
Testing shows negligible battery impact because:
- Timer is only active when screen is on
- Checks are extremely lightweight
- Automatically stops when view disappears
Test focus changes by simulating the conditions that trigger them:
class FocusTests: XCTestCase {
func testFocusChangesWhenModalPresented() {
let viewController = MyViewController()
// Simulate appearing
viewController.viewDidAppear(false)
// Present a modal
let modal = UIViewController()
viewController.present(modal, animated: false)
// The focus polling will detect the presented view controller
// and call onWindowFocusChanged(hasFocus: false)
}
func testFocusChangesWhenAppBackgrounded() {
let viewController = MyViewController()
viewController.viewDidAppear(false)
// Simulate app backgrounding
NotificationCenter.default.post(
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
// onWindowFocusChanged(hasFocus: false) should be called
}
}Test focus changes with:
- ✅ Present a modal view controller
- ✅ Show an alert/action sheet
- ✅ Navigate to another view controller
- ✅ Put app in background
- ✅ Pull down notification center
- ✅ Open Control Center
- ✅ Receive phone call
- ✅ iPad split view multitasking
Possible improvements to this pattern:
- Configurable Polling Rate: Allow subclasses to adjust the timer frequency
- Focus Duration Tracking: Track how long a view has had focus
- Focus Change Reason: Provide context about why focus changed (modal, background, etc.)
- SwiftUI Integration: Adapt this pattern for SwiftUI views
- Focus Hierarchy: Support for nested view controllers with their own focus state
This focus monitoring pattern provides a robust, pragmatic solution for detecting when view controllers gain or lose user attention. By implementing the entire system as a UIViewController extension, it can be dropped into any project without requiring a specific base class hierarchy.
The pattern is:
- ✅ Easy to use - Override one property and one method
- ✅ Flexible - Works with any UIViewController, no base class required
- ✅ Performant - Minimal overhead with 60fps polling only when needed
- ✅ Reliable - Catches all focus change scenarios (modals, alerts, backgrounding, etc.)
- ✅ Testable - Clear separation of public API and private implementation
- ✅ Maintainable - All logic contained in a single extension file
Use this pattern when your view controllers need to react to focus changes for purposes like pausing animations, stopping network requests, refreshing data, or managing resources efficiently. The extension-based approach means you can add focus monitoring to existing view controllers without refactoring your inheritance hierarchy.