Swift and SwiftUI tutorials for Swift Developers

SwiftUI App Lifecycle

Whether you come from the world of UIKit (AppDelegate and SceneDelegate) or are starting from scratch with SwiftUI, understanding the Application Lifecycle is the most important foundation for building robust apps.

The lifecycle dictates how your application is born, how it lives while the user interacts with it, and how it dies (or gets suspended) when the user leaves it. In SwiftUI, Apple has unified this process, moving away from imperative delegation toward a declarative and reactive approach.

In this tutorial, we will break down every phase of the app lifecycle in SwiftUI in the modern Apple ecosystem, covering the critical differences between iPhone, Mac, and Apple Watch.


1. The Evolution: From Delegation to Declaration

Before SwiftUI 2.0, the entry point for an iOS app was the AppDelegate.swift file. This file was a “catch-all” where we handled initial configuration, push notifications, and background states.

With the arrival of the App protocol in SwiftUI, Apple introduced a new entry point:

@main
struct MySuperApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

What is happening here?

  1. @main: This annotation tells the compiler: “This is where it all begins.” It replaces the old main.swift file or the @UIApplicationMain annotation.
  2. App Protocol: Your struct conforms to the App protocol. Its only requirement is a computed property body that returns a Scene.
  3. Scene and WindowGroup: An application does not contain views directly; it contains Scenes. A scene is a container for the user interface. WindowGroup is the most common scene, capable of handling multiple windows automatically (crucial on iPadOS and macOS).

2. The Vital Signs Monitor: scenePhase

In the old UIKit, we listened for methods like applicationDidEnterBackground. In SwiftUI, the system “injects” the current state into us via an environment variable called scenePhase.

This is the most powerful tool in your arsenal. It allows your app to react to state changes in real-time.

Basic Implementation

To observe the lifecycle, you need to read the environment variable in your App struct:

import SwiftUI

@main
struct LifecycleApp: App {
    // 1. Inject the environment variable
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // 2. Listen for changes
        .onChange(of: scenePhase) { newPhase in
            switch newPhase {
            case .active:
                print("🟢 App is active and responding")
            case .inactive:
                print("🟡 App is visible but not interactive")
            case .background:
                print("🔴 App is in the background")
            @unknown default:
                print("⚪️ Unknown future state")
            }
        }
    }
}

3. Deep Dive into States

Understanding when each state triggers is vital to avoid data loss bugs or excessive battery drain.

A. Active

The “happy” state. Your application is in the foreground, occupies the screen (or a window), and is receiving user events (touches, clicks, keyboard input).

  • What to do here:
    • Resume animations.
    • Start timers.
    • Request location or gyroscope updates.
    • Fetch fresh data from the network if necessary.

B. Inactive

This is a transitional or “limbo” state. The application remains visible on the screen, but it is not receiving touch events.

  • When does this happen?
    • iOS: When you pull down the Notification Center or open the Control Center.
    • iOS: When an incoming call appears or a high-priority system alert pops up (like a FaceID prompt).
    • iPadOS: In multitasking, when the user is resizing windows.
    • macOS: When the window does not have the main focus.
  • What to do here:
    • Pause games or heavy logic.
    • Blur sensitive content (like in banking apps) for visual security.

C. Background

The user has closed the app (swiped up) or switched to another application. The interface is not visible.

Critical Note: At this point, you have very little time (seconds) before the system suspends your code execution to save battery.

  • What to do here (Quickly!):
    • Save data: Persist changes to CoreData, UserDefaults, or files. Never wait for the app to terminate to save.
    • Cancel network tasks: If you are downloading a large image that isn’t vital, cancel it.
    • Clean up resources: Release memory if possible.

4. Platform Differences: The Devil is in the Details

Although SwiftUI tries to be universal, user behavior changes drastically between a watch, a phone, and a computer.

iOS and iPadOS

The behavior is the standard described above. However, remember that on iPadOS (with Stage Manager), a user can have multiple scenes of your app open.

  • Challenge: One window might be .active while another is .backgroundscenePhase tracks the scene, not the entire application.

macOS

The lifecycle on Mac is more complex due to the nature of windows and the menu bar.

  1. Window Close vs. App Close: On Mac, closing the last window (red X button) does not always close the application. The app keeps running in the Dock.
  2. NSApplicationDelegateAdaptor: You will often need this to handle top menus or specific Dock behaviors.

To force the app to die when closing the last window (common in simple tools), you don’t use scenePhase, but rather a method in the delegate or window logic, though SwiftUI is improving this.

watchOS

The Apple Watch is aggressive with battery.

  1. Frontmost App: Apps on watchOS switch to inactive very quickly when the user lowers their wrist.
  2. Always On Display: If your app supports “Always On,” the lifecycle has nuances. The app might appear active but be in a low refresh mode (1Hz).
  3. Snapshotting: The system takes a “snapshot” of your UI before sending it to the background to use in the Dock. Ensure the UI looks correct before transitioning to background.

5. The Bridge to the Past: AppDelegate in SwiftUI

Sometimes, the pure SwiftUI lifecycle isn’t enough. Common use cases:

  • Firebase configuration at startup.
  • Handling Push Notifications (token registration).
  • Handling complex Deep Links.

For this, SwiftUI offers adaptors to inject the old delegate.

In iOS (UIApplicationDelegateAdaptor)

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        print("🚀 AppDelegate: App has launched (legacy code/libs)")
        // Configure Firebase here
        return true
    }
    
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        // Handle push tokens
    }
}

@main
struct MyHybridApp: App {
    // Connect the delegate
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Warning: SwiftUI manages the creation of the Window, so do not attempt to create a UIWindow manually inside the AppDelegate unless you know exactly why you are doing it.


6. Architecture: Where do I put the logic?

A common beginner mistake is placing save logic directly inside the struct App or inside the View. This breaks the principle of separation of concerns.

The MVVM Approach (Model-View-ViewModel)

Ideally, your ViewModel or a Service should handle the reaction to state changes.

Example of Clean Architecture:

class DataManager: ObservableObject {
    func saveData() {
        print("💾 Saving data to disk...")
    }
    
    func refreshData() {
        print("🔄 Refreshing data from API...")
    }
}

struct ContentView: View {
    @Environment(\.scenePhase) var scenePhase
    @StateObject var dataManager = DataManager()
    
    var body: some View {
        TaskListView(manager: dataManager)
            .onChange(of: scenePhase) { newPhase in
                switch newPhase {
                case .active:
                    // On return, refresh in case of cloud changes
                    dataManager.refreshData()
                case .background:
                    // On exit, save immediately
                    dataManager.saveData()
                default: break
                }
            }
    }
}

Why in the View and not in App?

Sometimes it is better to put the .onChange in the root view (ContentView) instead of the App file. This is because @StateObject lives within the view hierarchy. If your data manager is a StateObject instantiated in the view, it is easier to access it from there.


7. Advanced Use Cases and Gotchas

A. Background Tasks

If you need more time than the few seconds the background state gives you (for example, to finish uploading a file), you need to use the BackgroundTasks framework. The SwiftUI lifecycle does not keep you alive indefinitely on its own.

B. Initialization (init)

The init() of your App struct runs before almost anything else.

  • Good: Configuring light dependency injection.
  • Bad: Heavy or asynchronous tasks. The UI does not exist yet.

C. Scene Persistence (SceneStorage)

SwiftUI offers @SceneStorage, which is like UserDefaults but specific to each window. If you have two windows of the same app open on iPad, and you kill the app, upon restoration @SceneStorage will remember which tab you had open in each window individually. This is an integral part of the state restoration lifecycle.


Conclusion

The lifecycle in SwiftUI has drastically simplified how we think about our application states. We no longer have to worry about scattered delegate methods; we now have a reactive and centralized state flow.

Survival Summary:

  1. Use @main and the App protocol.
  2. Watch @Environment(\.scenePhase) to detect changes.
  3. Use .active to start engines and .background to brake and save.
  4. Don’t be afraid to use UIApplicationDelegateAdaptor if you need legacy libraries.

Mastering scenePhase is the difference between an app that drains battery and loses data, and an app that feels native, snappy, and reliable on any Apple platform.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

SwiftData vs Core Data : Which should you choose?

Next Article

How to navigate between views in SwiftUI

Related Posts