davextreme
davextreme

Reputation: 1165

Not receiving scenePhase changes

I'm trying to execute some code I'd have previously put in my app delegate, such as saving my managed object context when entering the background. I put the call in the .onChange for the scenePhase, but I'm not getting anything.

Here's a sample project:

import SwiftUI

@main
struct PhaseApp: App {
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some Scene {
        WindowGroup {
            Text("Hello, world.")
        }
        .onChange(of: scenePhase) { phase in
            switch phase {
            case .active:
                print("Active")
            case .background:
                print("Background")
            case .inactive:
                print("Inactive")
            @unknown default: break
            }
        }
    }
}

I'd expect to get a print output in the Simulator or on my test device whenever I press Home or tap the app, but nothing happens.

Upvotes: 43

Views: 14668

Answers (9)

malhal
malhal

Reputation: 30746

I found a workaround in Track model changes with SwiftData history from WWDC 2024 in the Code tab of the Developer app.

        .onChange(of: scenePhase) { _, newValue in
            ...
        }
        #if os(macOS)
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
            ...
        }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
            ...
        }
        #endif

Upvotes: 0

soundflix
soundflix

Reputation: 2793

I experimented with the environment values scenePhase, controlActiveState, with onAppear, onDisappear and @Parth Mehrotra's NotificationCenter approach. The thing is, nothing does reliably publish the activity state of the app, i.e. so that it reflects NSApp.isActive. It should also work when the app has no or many windows, which lead me to the conclusion that attaching the code to a View is not the way to do it.

Considering it is also not recommended to conform AppDelegate to ObservableObject, I made this observable class:

#if os(iOS)
import UIKit
#else
import AppKit
#endif

class AppState: NSObject, ObservableObject {
    @Published var activity: Activity = .inactive
    private var appMonitor: [NSObjectProtocol] = []
    
    enum Activity {
        case active
        case inactive
    }
    
    override init() {
        super.init()
#if os(iOS)
        appMonitor.append(NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil) { notification in
            self.activity = .inactive
        })
        appMonitor.append(NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { notification in
            self.activity = .active
        })
#else
        appMonitor.append(NotificationCenter.default.addObserver(forName: NSApplication.willResignActiveNotification, object: nil, queue: nil) { notification in
            self.activity = .inactive
        })
        appMonitor.append(NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil) { notification in
            self.activity = .active
        })
#endif
    }
}

It can be used like this:

import SwiftUI

@main
struct ActiveStateApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @StateObject var app = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        } 
        .onChange(of: app.activity) { state in
            print("AppState: \(state), NSApp.isActive: \(NSApp.isActive)")
        }
    }
}

Upvotes: 1

lorem ipsum
lorem ipsum

Reputation: 29613

the modifiers available with the new Concurrency are much stable than onChange

    .task(id: scenePhase) { 
        switch scenePhase {
        case .active:
            print("Active")
        case .background:
            print("Background")
        case .inactive:
            print("Inactive")
        @unknown default: break
        }
    }

Upvotes: 6

Paul Lehn
Paul Lehn

Reputation: 3342

Just a heads up if you are trying to detect scene changes in a veiw presented in with a .sheet modifier. In this case you have to pass the environment of the presenting view down to the sheet view in order to get scenePhase updates:

struct YourView: View {
    @Environment (\.scenePhase) private var scenePhase
    var body: some View {
        Text("Some Text")
            .sheet(isPresented: .constant(true)) {
                YourSheetView()
                  .environment(\.scenePhase, scenePhase)
            }
    }
}

Hope this helps someone!

Upvotes: 1

Asperi
Asperi

Reputation: 258365

Use inside scene root view (usually ContentView)

Tested with Xcode 12 / iOS 14 as worked.

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
    var body: some View {
        TestView()
            .onChange(of: scenePhase) { phase in
                switch phase {
                    case .active:
                        print(">> your code is here on scene become active")
                    case .inactive:
                        print(">> your code is here on scene become inactive")
                    case .background:
                        print(">> your code is here on scene go background")
                    default:
                        print(">> do something else in future")
                }
            }
    }
}

Upvotes: 17

Parth Mehrotra
Parth Mehrotra

Reputation: 3040

I acknowledge this question is specifically about schenePhase changes, however, on macOS I am not able to receive any .background notifications when a user switches to a different app. The older NotificationCenter strategy works as I expected, on both platforms. I'll add this to the mix for anyone who is just trying to execute some code, onForeground / onBackground on iOS and macOS.

On any view, you can attach:

.onReceive(NotificationCenter.default.publisher(for: .willResignActiveNotification)) { _ in
    doBackgroundThing()
}

The events you may care about are:

You can find all NotificationCenter Names here.

I use will* variants for background because I assume they'll be called early in the process, and I use did* variants for foreground, because they are called regardless of whether the app is launched for the first time, or it's coming out of background.

I use this extension so I don't have to think about the platform differences:

extension View {
    #if os(iOS)
    func onBackground(_ f: @escaping () -> Void) -> some View {
        self.onReceive(
            NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification),
            perform: { _ in f() }
        )
    }
    
    func onForeground(_ f: @escaping () -> Void) -> some View {
        self.onReceive(
            NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification),
            perform: { _ in f() }
        )
    }
    #else
    func onBackground(_ f: @escaping () -> Void) -> some View {
        self.onReceive(
            NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification),
            perform: { _ in f() }
        )
    }
    
    func onForeground(_ f: @escaping () -> Void) -> some View {
        self.onReceive(
            NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification),
            perform: { _ in f() }
        )
    }
    #endif
}

As expected, I use it as such:

AppView()
   .onBackground {
       print("my background")
   }
   .onForeground {
       print("my foreground")
   }

Upvotes: 39

Andrew
Andrew

Reputation: 11427

You can use the following extension:

public extension View {
    func onScenePhaseChange(phase: ScenePhase, action: @escaping () -> ()) -> some View {
        self.modifier(OnScenePhaseChangeModifier(phase: phase, action: action))
    }
}

public struct OnScenePhaseChangeModifier: ViewModifier {
    @Environment(\.scenePhase) private var scenePhase
    
    public let phase: ScenePhase
    
    public let action: () -> ()
    
    public func body(content: Content) -> some View {
        content
            .onChange(of: scenePhase) { phase in
                if (self.phase == phase) {
                    action()
                }
            }
    }
}

Final usage:

ContentView()
     .onScenePhaseChange(phase: .active)     { print("scene activated!") }
     .onScenePhaseChange(phase: .background) { print("scene backgrounded!") }
     .onScenePhaseChange(phase: .inactive)   { print("scene inactive!") }

Upvotes: 2

damo
damo

Reputation: 939

In my case, I put "@Environment(.scenePhase) private var scenePhase" in ContentView. Then the onChange works in the child views.

Upvotes: 3

Timothy Sanders
Timothy Sanders

Reputation: 316

I've been testing with Xcode 12 beta 3 and iOS/iPadOS 14 beta 3 and here's what I'm finding. Note that a lot of this involves supporting multiple windows, but the "SwiftUI lifecycle" projects default to turning that on, so I suspect you have it active already. In my original case I was porting an existing SwiftUI app from a SceneDelegate to using the new App struct, so I had multiple window support already active.

Here's the test View I'm using in a new testing app:

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        Text("Hello, world!").padding()
            .onChange(of: scenePhase) { phase in
                switch phase {
                case .background:
                    print("PHASECHANGE: View entered background")
                case .active:
                    print("PHASECHANGE: View entered active")
                case .inactive:
                    print("PHASECHANGE: View entered inactive")
                @unknown default:
                    print("PHASECHANGE: View entered unknown phase.")
                }
            }
    }
}

(I have identical code in the App & Scene but they never print anything.)

  1. The ScenePhase documentation claims that you can declare onChange inside the App, a Scene or a View. I don't see the App or Scene level versions ever execute, under any circumstance I can engineer, and the View level versions don't seem to execute completely correctly.

  2. On hardware that doesn't support multiple windows (I use a 7th generation iPod touch) the View level closure executes every time. (Full disclosure, this iPod Touch is still running beta 2, but I don't think it's going to matter. Once I update it to b3 I'll mention it here if it matters.) EDIT (It did matter.) On hardware running beta 2 that doesn't support multiple windows (a 7th generation iPod Touch) I see the app go into the background, back into the foreground, and so forth. On every app launch I'll see "View entered active" print.

  3. On hardware that does support multiple windows (I use an older iPad Pro with the Lightning connector) I don't see the initial scene creation happen. (The first run does not trigger a "View entered active" message.) I do see subsequent background/foreground transitions. If I create a new scene from the iPad multi-tasking UI the second scene will trigger a "View entered active" log. Unfortunately I hadn't run this test on the iPad against beta 2, so I can't say if the behavior changed with b3 or not.

  4. On my iPod Touch running iOS 14 beta 3 I see the same behavior as the iPad: the first launch doesn't print any phase change messages from the view, but does report subsequent background/foreground changes.

  5. On the simulator it always behaves like the iPad hardware, even when I'm simulating an iPod Touch. I suspect this is because the simulator is running under the hood on the Mac and gets multiple window "support" this way. But I do see messages when I put the app in the background while running in the simulator, I'm just missing the initial "View entered active" message that I get from the actual hardware.

One final note: when I return an app from the foreground I first see "View entered inactive" and then I see "View entered active". When I background the app I see "View entered inactive", followed by "View entered background". I think this is expected behavior, but since other parts seem broken I wanted to mention it.


TL;DR:

I think you should be able to see most ScenePhase changes from a View, but you'll miss the initial app launch on iPads or in the simulator. And hopefully they will show up as expected for App and Scene objects in a later beta?

Upvotes: 7

Related Questions