Skip to content

Instantly share code, notes, and snippets.

@kibotu
Last active October 17, 2025 10:48
Show Gist options
  • Select an option

  • Save kibotu/d6f27fba839ff4fb3edea958f72a597c to your computer and use it in GitHub Desktop.

Select an option

Save kibotu/d6f27fba839ff4fb3edea958f72a597c to your computer and use it in GitHub Desktop.
onWindowsFocusChanged for iOS

View Controller Focus Monitoring Pattern

Overview

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.

Problem Statement

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.

What Doesn't Work

Several approaches were explored but proved inadequate:

  • viewWillAppear / viewWillDisappear: Not called when modals or alerts are presented on top of a view controller
  • UIWindow.didBecomeVisibleNotification: Doesn't fire for modals, alerts, or view controller transitions within the same window
  • UIWindow.didBecomeKeyNotification: Not triggered for modals presented within the same window
  • UIScene notifications: 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.

Solution Architecture

The solution is implemented as a UIViewController extension that uses a combination of:

  1. View lifecycle monitoring - viewDidAppear and viewWillDisappear
  2. App lifecycle monitoring - Background/foreground notifications
  3. Polling mechanism - Periodic checks (60fps) to detect modal/alert presentations
  4. 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.

Key Concepts

1. Focus State Management

The extension uses associated objects to store state without requiring stored properties:

  • hasFocus: Bool - Current focus state
  • currentlyHasFocus: Bool? - Previous focus state for change detection
  • focusCheckTimer: Timer? - Timer for polling when view is visible
  • hasSetupFocusLifecycle: Bool - Ensures notifications are only registered once

2. Public API

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
}

3. Lifecycle Integration

Three methods integrate with the view controller lifecycle:

handleFocusMonitoring_viewDidAppear()    // Call from viewDidAppear
handleFocusMonitoring_viewWillDisappear() // Call from viewWillDisappear
cleanupFocusMonitoring()                  // Call from deinit

4. Focus Detection Logic

The 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.

Implementation Guide

Complete UIViewController Extension

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
    }
}

Using the Extension in Your View Controllers

Option 1: Base View Controller (Recommended)

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")
        }
    }
}

Option 2: Direct Usage Without Base Class

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()
    }
}

Design Decisions & Trade-offs

Why Polling at 60fps?

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

Why Not Use viewDidAppear Alone?

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.

Why Associated Objects for State Storage?

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

Why Check Multiple Presentation Levels?

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.

Why Opt-in via wantsToListenOnFocusEvents?

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

Performance Considerations

Timer Performance

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

Memory Management

  • Timer uses weak reference to avoid retain cycles
  • Associated objects are properly cleaned up in deinit
  • No memory leaks from notification observers (properly removed)

Battery Impact

Testing shows negligible battery impact because:

  • Timer is only active when screen is on
  • Checks are extremely lightweight
  • Automatically stops when view disappears

Testing Considerations

Unit Testing

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
    }
}

Manual Testing Scenarios

Test focus changes with:

  1. ✅ Present a modal view controller
  2. ✅ Show an alert/action sheet
  3. ✅ Navigate to another view controller
  4. ✅ Put app in background
  5. ✅ Pull down notification center
  6. ✅ Open Control Center
  7. ✅ Receive phone call
  8. ✅ iPad split view multitasking

Future Enhancements

Possible improvements to this pattern:

  1. Configurable Polling Rate: Allow subclasses to adjust the timer frequency
  2. Focus Duration Tracking: Track how long a view has had focus
  3. Focus Change Reason: Provide context about why focus changed (modal, background, etc.)
  4. SwiftUI Integration: Adapt this pattern for SwiftUI views
  5. Focus Hierarchy: Support for nested view controllers with their own focus state

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment