SwiftUI app goes back to first screen when app is put into the background

Viewed 1228

I have built an app in SwiftUI and have encountered an annoying issue.

When the app is presented the app shows a custom launch screen while the data of the app loads asynchronously. Once finished, the app's main view is presented to the user. Once the user reaches this point, as soon as they drag the app back into he background the navigation stack pops back to the first launch screen for no reason.

Here are the relevant code snippets:

MyApp.swift

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

    init() {
        //initial setup goes here
    }
    
    var body: some Scene {
        WindowGroup {
                LaunchView()
        }
    }
    
}

LaunchView.swift

struct LaunchView: View {
    
    @ObservedObject var control: LaunchViewControl = LaunchViewControl()
    
    var body: some View {
        NavigationView(content: {
            ZStack {
                //some view hierarchy                 
                NavigationLink(destination: RootView(),
                               isActive: $control.isReady,
                               label: {EmptyView()})

            }
            .onAppear(perform: {
                control.loadData()
            })
            .navigationBarTitle("")
            .navigationBarHidden(true)
            .statusBar(hidden: true)
        }).navigationViewStyle(StackNavigationViewStyle())
    }
    
}

LaunchViewControl.swift

final class LaunchViewControl: ObservableObject {
    
    @Published var isReady: Bool = false
    private var executing: Bool = false
    private let dataService = DataService.shared
    
    func loadData() {
        //runs all initial app setup needed before the app starts (asynchronously)
        guard !executing else {
            return
        }
        executing = true
        self.setupCoreData()
    }
    
    private func setupCoreData() {
        //Initialises CoreData with CloudKit (asynchronously)
        CloudCoreDataService.shared.setup { [weak self] in
            guard let self = self else { return }
            self.checkRequiresInitialData()
        }
    }
    
    private func checkRequiresInitialData() {
        self.dataService.requiresInitialDataSetup(onCompletion: { [weak self] (isRequired) in
            guard let self = self else { return }
            if isRequired {
                self.setupInitialData()
            } else {
                self.isReady = true
            }
        })
    }
    
    private func setupInitialData() {
        //Generates initial database if needed
        self.dataService.setupInitialData { [weak self] (_) in
            guard let self = self else { return }
            self.isReady = true
        }
    }
    
}

RootView.swift

struct RootView: View {
    
    @ObservedObject var control = RootViewControl()
    
    var body: some View {
        HStack(spacing: 0) {
            //some view hierarchy
        }
        .navigationBarTitle("")
        .navigationBarHidden(true)
        .statusBar(hidden: true)
    }
    
}

Has anyone else encountered this issue with Xcode 12.4?

1 Answers

There are two solutions (that came to my mind, and do not cause a lot of changes) that you can apply:

1. Injection

When you are initializing the 'LaunchViewControl' class here in the view:

@ObservedObject var control: LaunchViewControl = LaunchViewControl()

SwiftUI initializes this control variable again and again when its view discarded and redrawn, because ObservedObjects are not as same as State variables, and SwiftUI won't keep track of their states.

To try this out, uncomment the line for 'scenePhase' and add an 'init' method to 'LaunchViewControl', then add some print statements to understand what's going on, or add a breakpoint.

For example:

final class LaunchViewControl: ObservableObject {
    // ....
    init() {
        print("initialized")
    }
    // ....
}

You will see multiple "initialized" logs on the console. Which means that, your 'isReady' variable will be overwritten to 'false', again and again.

To prevent this and keep lifecyle events, you can use a basic injection.

For example, change your 'LaunchView' to:

struct LaunchView: View {
    
    // Change here
    @ObservedObject var control: LaunchViewControl
    
    var body: some View {
        NavigationView(content: {
            ZStack {
                //some view hierarchy
                Text("Launch")
                NavigationLink(destination: RootView(),
                               isActive: $control.isReady,
                               label: {EmptyView()})

            }
            .onAppear(perform: {
                control.loadData()
            })
            .navigationBarTitle("")
            .navigationBarHidden(true)
            .statusBar(hidden: true)
        }).navigationViewStyle(StackNavigationViewStyle())
    }
}

and your 'Jam500App' to:

@main
struct Jam500App: App {
    
    @Environment(\.scenePhase) var scenePhase
    
    let launchViewControl = LaunchViewControl()
    
    var body: some Scene {
        WindowGroup {
            // Inject launchViewControl
            LaunchView(control: launchViewControl)
        }
    }
}

You should now see your second view is not popped and you have also kept the lifecycle events.

2. StateObject

Instead of doing these all, you can change your ObservedObject to a 'StateObject', which will keep its state when view discarded and redrawn.

More information

Also, there are some great answers already in SO that you can check about ObservedObject's and how they works. One of them is this. And another one about the differences between StateObject and ObservedObject is this.

To prevent this in further steps of the project, I would give MVVM approach a try, and if you want to check more about it, there are great tutorials out there, for example this.

Related