SwiftUI: Replacing window dismisses only topmost modal view

Viewed 85

I need to show a login screen when the user session is expired. I tried to achieve this by changing the current window:

@main
struct ResetViewHierarchyApp: App {
    
    @StateObject private var state = appState
        
    var body: some Scene {
        WindowGroup {
            if state.isLoggedIn {
                ContentView()
            } else {
                LogInView()
            }
        }
    }
}

When no modal views are presented then it works fine. If only one modal view is presented, it also works, the modal view is dismissed. But if there are more than one modal views are presented, then the root view is replaced, but only the topmost modal view is dismissed. Here is ContentView:

struct ContentView: View {
    
    @State private var isPresentingSheet1 = false
    @State private var isPresentingSheet2 = false
    
    var body: some View {
        Text("Hello, world!")
            .padding()
        
        Button(action: {
            isPresentingSheet1 = true
        }, label: {
            Text("Present Sheet")
                .padding()
        }).sheet(isPresented: $isPresentingSheet1) {
            sheetView1
        }
    }
}

private extension ContentView {
    
    var sheetView1: some View {
        VStack {
            Text("Sheet 1")
                .padding()
            Button(action: {
                isPresentingSheet2 = true
            }, label: {
                Text("Present Sheet")
                    .padding()
            }).sheet(isPresented: $isPresentingSheet2) {
                sheetView2
            }
        }
    }
    
    var sheetView2: some View {
        VStack {
            Text("Sheet 2")
                .padding()
            Button(action: {
                appState.isLoggedIn = false
            }, label: {
                Text("Log Out")
                    .padding()
            })
        }
    }
}

The same happens if I use fullScreenCover instead of sheet.

Does anybody know how to solve this issue, to dismiss all the presented modals at once?

2 Answers

I've solved this issue with UIKit windows:

@StateObject private var state = appState @State private var contentWindow: UIWindow?

var body: some Scene {
    WindowGroup {
        EmptyView()
            .onAppear {
                updateContentWindow(isLoggedIn: state.isLoggedIn)
            }.onReceive(state.$isLoggedIn) { isLoggedIn in
                updateContentWindow(isLoggedIn: isLoggedIn)
            }
    }
}

var window: UIWindow? {
    guard let scene = UIApplication.shared.connectedScenes.first,
          let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
          let window = windowSceneDelegate.window else {
        return nil
    }
    
    return window
}

func updateContentWindow(isLoggedIn: Bool) {
    contentWindow?.isHidden = true
    contentWindow = nil
    
    if let windowScene = window?.windowScene {
        contentWindow = UIWindow(windowScene: windowScene)
        contentWindow?.windowLevel = UIWindow.Level.normal
        if isLoggedIn {
            contentWindow?.rootViewController = UIHostingController(rootView: ContentView())
        } else {
            contentWindow?.rootViewController = UIHostingController(rootView: LogInView())
        }
        
        contentWindow?.makeKeyAndVisible()
    }
}

It is indeed a strange bug.. however I found a workaround for it.

You can keep your States of the modal View inside your Observable / Environment Object. When logging out, you have to make sure to close all your sheets.

Here is a example:

First adding showSheet as Published Value in the AppState

class AppState : ObservableObject {
    @Published var isLoggedIn : Bool = true
    
    @Published var showSheet1 : Bool = false
    @Published var showSheet2 : Bool = false
}

When logging out, turn all your sheets to false.

Button(action: {
    self.state.isLoggedIn = false
    self.state.showSheet1 = false
    self.state.showSheet2 = false
}, label: {
    Text("Log Out")
        .padding()
})

Of course you have to use these values in your Button for toggling sheet and in your sheet.

.sheet(isPresented: $state.showSheet2) {

Edit:

Even simpler, you don't have to manually set it to false in the LogOut action. Instead do it all in the appState

@Published var isLoggedIn : Bool = true {
    willSet {
        if newValue == false {
            showSheet1 = false
            showSheet2 = false
        }
    }
}
Related