avatar
Published on

Detecting When App Enters Background and Saving State in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

When users switch apps or lock their screen, your app moves to the background. At this moment, you should save important state—unsaved drafts, user progress, scroll positions—so users don't lose work if iOS terminates your app while backgrounded.

SwiftUI provides two approaches for detecting lifecycle changes: the modern scenePhase environment value and traditional UIApplication notifications. Each has its use case, and understanding both gives you complete control over state persistence.

The Problem: Apps Can Be Terminated

iOS may terminate backgrounded apps to free memory. When users return, your app cold-starts. Without state saving, users lose:

  • Unsaved draft content
  • Navigation state (which screen they were on)
  • Form data
  • Scroll positions
  • Selected tabs or filters

This creates a frustrating experience where the app feels "forgetful."

Solution 1: Using ScenePhase (Modern SwiftUI)

iOS 14+ introduced scenePhase, a SwiftUI-native way to track app lifecycle:

import SwiftUI

@main
struct MyApp: App {
    @Environment(\.scenePhase) var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            switch newPhase {
            case .active:
                print("App is active")
                // App is in foreground and receiving events

            case .inactive:
                print("App is inactive")
                // Transitioning between foreground and background
                // Or Siri/Control Center is showing

            case .background:
                print("App is in background")
                // Save important state here
                saveAppState()

            @unknown default:
                break
            }
        }
    }

    func saveAppState() {
        // Save critical data
        print("Saving app state...")
    }
}

Understanding Scene Phases

  • active: App is running in foreground and receiving user events
  • inactive: Temporary state during transitions (app switching, incoming calls, Control Center)
  • background: App is no longer visible, may be terminated by iOS

The inactive phase happens briefly before background, giving you a chance to prepare.

Solution 2: Using UIApplication Notifications

For more granular control, use UIApplication notifications:

import SwiftUI
import Combine

class AppLifecycleObserver: ObservableObject {
    private var cancellables = Set<AnyCancellable>()

    init() {
        // App will resign active (about to lose focus)
        NotificationCenter.default
            .publisher(for: UIApplication.willResignActiveNotification)
            .sink { _ in
                print("App will resign active")
                self.prepareForBackground()
            }
            .store(in: &cancellables)

        // App did enter background
        NotificationCenter.default
            .publisher(for: UIApplication.didEnterBackgroundNotification)
            .sink { _ in
                print("App entered background")
                self.saveState()
            }
            .store(in: &cancellables)

        // App will enter foreground
        NotificationCenter.default
            .publisher(for: UIApplication.willEnterForegroundNotification)
            .sink { _ in
                print("App will enter foreground")
                self.prepareForForeground()
            }
            .store(in: &cancellables)

        // App did become active
        NotificationCenter.default
            .publisher(for: UIApplication.didBecomeActiveNotification)
            .sink { _ in
                print("App became active")
                self.restoreState()
            }
            .store(in: &cancellables)
    }

    func prepareForBackground() {
        // Pause timers, animations
    }

    func saveState() {
        // Save critical data
    }

    func prepareForForeground() {
        // Refresh data that may be stale
    }

    func restoreState() {
        // Resume timers, animations
    }
}

@main
struct MyApp: App {
    @StateObject private var lifecycleObserver = AppLifecycleObserver()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Practical Example: Saving Draft Content

Here's a complete example saving a draft note:

import SwiftUI

class DraftManager: ObservableObject {
    @Published var draftText: String = ""

    private let draftKey = "savedDraft"

    init() {
        // Load draft on launch
        loadDraft()
    }

    func saveDraft() {
        UserDefaults.standard.set(draftText, forKey: draftKey)
        print("Draft saved: \(draftText)")
    }

    func loadDraft() {
        draftText = UserDefaults.standard.string(forKey: draftKey) ?? ""
        print("Draft loaded: \(draftText)")
    }

    func clearDraft() {
        draftText = ""
        UserDefaults.standard.removeObject(forKey: draftKey)
    }
}

struct DraftEditorView: View {
    @StateObject private var draftManager = DraftManager()
    @Environment(\.scenePhase) var scenePhase

    var body: some View {
        VStack {
            Text("Auto-saves when you leave the app")
                .font(.caption)
                .foregroundColor(.secondary)
                .padding(.top)

            TextEditor(text: $draftManager.draftText)
                .border(Color.gray.opacity(0.3), width: 1)
                .padding()

            if !draftManager.draftText.isEmpty {
                Button("Clear Draft") {
                    draftManager.clearDraft()
                }
                .padding(.bottom)
            }
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .background {
                draftManager.saveDraft()
            }
        }
    }
}

Saving Complex State with Codable

For structured data, use Codable:

struct AppState: Codable {
    var selectedTab: Int
    var searchText: String
    var filters: [String]
    var lastViewedItemID: String?
}

class StateManager: ObservableObject {
    @Published var appState: AppState

    private let stateKey = "appState"

    init() {
        if let data = UserDefaults.standard.data(forKey: stateKey),
           let decoded = try? JSONDecoder().decode(AppState.self, from: data) {
            appState = decoded
        } else {
            appState = AppState(
                selectedTab: 0,
                searchText: "",
                filters: [],
                lastViewedItemID: nil
            )
        }
    }

    func save() {
        if let encoded = try? JSONEncoder().encode(appState) {
            UserDefaults.standard.set(encoded, forKey: stateKey)
        }
    }
}

struct MainView: View {
    @StateObject private var stateManager = StateManager()
    @Environment(\.scenePhase) var scenePhase

    var body: some View {
        TabView(selection: $stateManager.appState.selectedTab) {
            HomeView()
                .tag(0)
                .tabItem { Label("Home", systemImage: "house") }

            SearchView(searchText: $stateManager.appState.searchText)
                .tag(1)
                .tabItem { Label("Search", systemImage: "magnifyingglass") }

            SettingsView()
                .tag(2)
                .tabItem { Label("Settings", systemImage: "gear") }
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .background {
                stateManager.save()
            }
        }
    }
}

Background Tasks

If you need to complete a task before iOS suspends your app, request background time:

import UIKit

class BackgroundTaskManager {
    private var backgroundTask: UIBackgroundTaskIdentifier = .invalid

    func beginBackgroundTask() {
        backgroundTask = UIApplication.shared.beginBackgroundTask {
            // Task took too long, clean up
            self.endBackgroundTask()
        }
    }

    func endBackgroundTask() {
        guard backgroundTask != .invalid else { return }
        UIApplication.shared.endBackgroundTask(backgroundTask)
        backgroundTask = .invalid
    }

    func saveImportantData() async {
        beginBackgroundTask()

        defer {
            endBackgroundTask()
        }

        // Perform save operation
        // You have about 30 seconds
        try? await Task.sleep(nanoseconds: 5_000_000_000)
        print("Data saved successfully")
    }
}

struct ContentView: View {
    @Environment(\.scenePhase) var scenePhase
    private let taskManager = BackgroundTaskManager()

    var body: some View {
        Text("Content")
            .onChange(of: scenePhase) { oldPhase, newPhase in
                if newPhase == .background {
                    Task {
                        await taskManager.saveImportantData()
                    }
                }
            }
    }
}

Combining Both Approaches

Use both scenePhase and notifications for different purposes:

@main
struct MyApp: App {
    @Environment(\.scenePhase) var scenePhase
    @StateObject private var lifecycleObserver = AppLifecycleObserver()
    @StateObject private var dataManager = DataManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(dataManager)
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            switch newPhase {
            case .background:
                // Critical state saving
                dataManager.saveAllData()

            case .active:
                // Refresh potentially stale data
                Task {
                    await dataManager.refreshIfNeeded()
                }

            default:
                break
            }
        }
    }
}

Handling Navigation State

Save and restore navigation state:

class NavigationState: ObservableObject {
    @Published var path: NavigationPath

    private let pathKey = "navigationPath"

    init() {
        // Load saved path
        if let data = UserDefaults.standard.data(forKey: pathKey),
           let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data),
           let path = NavigationPath(decoded) {
            self.path = path
        } else {
            self.path = NavigationPath()
        }
    }

    func save() {
        guard let representation = path.codable,
              let encoded = try? JSONEncoder().encode(representation) else { return }
        UserDefaults.standard.set(encoded, forKey: pathKey)
    }
}

struct AppWithNavigation: View {
    @StateObject private var navState = NavigationState()
    @Environment(\.scenePhase) var scenePhase

    var body: some View {
        NavigationStack(path: $navState.path) {
            HomeView()
                .navigationDestination(for: String.self) { item in
                    DetailView(item: item)
                }
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .background {
                navState.save()
            }
        }
    }
}

ScenePhase vs Notifications: When to Use Each

Use scenePhase when:

  • You want SwiftUI-native lifecycle handling
  • You're working at the Scene or Window level
  • You need simple active/background detection
  • You want cleaner, more declarative code

Use UIApplication notifications when:

  • You need more granular lifecycle events
  • You're working with UIKit components
  • You need backward compatibility with older iOS versions
  • You want to observe from anywhere (not just views)

Use both when:

  • You need comprehensive lifecycle coverage
  • Different parts of your app need different approaches
  • You're migrating from UIKit to SwiftUI gradually

Best Practices

Do:

  • Save state in .background phase (scenePhase) or didEnterBackgroundNotification
  • Keep save operations fast (< 5 seconds)
  • Save incremental changes as users work, not just on background
  • Test by backgrounding your app during development
  • Use UserDefaults for small data, files for large data

Don't:

  • Perform long-running operations during background transition
  • Wait until background to save critical data (save continuously)
  • Block the main thread during save
  • Forget to test state restoration on cold launch
  • Save sensitive data without encryption

Testing State Saving

Test thoroughly:

  1. Background the app mid-edit
  2. Terminate from app switcher (swipe up)
  3. Cold launch and verify state restored
  4. Background during navigation, verify position restored
  5. Test with low memory conditions

Understanding app lifecycle and implementing proper state saving makes your SwiftUI app reliable and user-friendly, ensuring users never lose their work.