Search code examples
swiftui

Sheet losing state when the app goes into background


The following code snippet demonstrates a strange behaviour in SwiftUI (seen on iOS 17.6 and iOS 18 beta) where the content of a .sheet() loses its state when the app goes into background:

import SwiftUI

class ExampleModel: ObservableObject {
    static var shared = ExampleModel()

    @Published var inProgress: Bool = false

    init() {
        heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.runHeartbeat()
        }
    }

    var heartbeatTimer: Timer?

    private func runHeartbeat() {
        Task { @MainActor in
            print("Heartbeat")
            self.inProgress.toggle()
        }
    }
}

struct MainView: View {
    enum MainTab {
        case exampleTab
    }

    @ObservedObject var model: ExampleModel = .shared
    @State var showSheet = false
    @State var currentTab = MainTab.exampleTab

    var body: some View {
        let _ = Self._printChanges()

        TabView(selection: $currentTab) {
            NavigationView {
                VStack {
                    Form {
                        Button("Show Sheet") { self.showSheet.toggle() }
                            .sheet(
                                isPresented: self.$showSheet,
                                content: {
                                    CounterView()
                                }
                            )
                    }
                }
            }
            .tabItem { Label("Example", systemImage: "scribble") }
            .tag(MainView.MainTab.exampleTab)
        }
    }
}

struct CounterView: View {
    @State var counter = 0
    var body: some View {
        let _ = Self._printChanges()

        Button("Value \(counter)") { self.counter += 1 }
    }
}

To reproduce the issue, open the app and press the 'Show Sheet' button. Click the counter button a few times. Move the app in the background and in the foreground again and you'll see the the counter resetted to 0 (the sheet loses its state).

Notes: One can see the @identity being reset for only the View inside the sheet using _printChanges. This seems related to the timer updating a value of an ObservableObject. Everything around the Views like the TabView, VStack, Form and .sheet() seem necessary for the bug to appear.

I reported this as a bug to Apple (FB15031072).

In the meantime: Is there any explanation for this behaviour that might help finding a proper fix?


Solution

  • I think this is because you are putting the sheet modifier on a Form's row.

    Form, similar to List, is lazy. It will remove its rows from the view hierarchy when they are not on the screen. Going back to the home screen of course causes the rows to become invisible, and so Form removes them. Since the sheet is attached to the button, the sheet gets destroyed too. When you open the app again, the Form recreates a brand new Button (and hence also a brand new sheet).

    This also happens when you scroll the Form and scrolls the button out of the visible area.

    This can be fixed by moving sheet to anywhere out of the Form.

    The same thing also applies to other presentation modifiers like navigationDestination. In fact, there is a dedicated error message for navigationDestination. Try replacing sheet with navigationDestination, and you'll get:

    Do not put a navigation destination modifier inside a "lazy” container, like List or LazyVStack. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination. There's a misplaced navigationDestination(isPresented:destination:) modifier presenting CounterView. It will be ignored in a future release.


    If you have multiple such Buttons and want to show a different sheet for each of those buttons, consider using sheet(item:). For example, suppose you have some Items and each Item has a corresponding button that shows a sheet related to the Item:

    @State var sheetItem: Item?
    @State var items = [Item(id: 1), Item(id: 2), Item(id: 3)]
    
    var body: some View {
        TabView(selection: $currentTab) {
            NavigationStack {
                ScrollViewReader { proxy in
                    Form {
                        ForEach(items) { item in
                            Button("Show Sheet \(item.id)") {
                                sheetItem = item
                            }
                        }
                    }
                    // sheet should be outside of the Form!
                    .sheet(item: $sheetItem) { item in
                        Text("Sheet for \(item.id)")
                        CounterView()
                    }
                }
            }
            .tabItem { Label("Example", systemImage: "scribble") }
            .tag(MainView.MainTab.exampleTab)
        }
    }
    
    struct Item: Identifiable {
        let id: Int
    }