I have a CoordinatorService
that I would like to manage multiple fullscreen covers, each with its own navigation path. My goal is to present them on top of each other, and allow each to navigate independently.
I force the full screen cover to be at position zero, $presentFullScreens[.zero]
, in order for the first full screen to work for the example.
Below is my minimal example:
import SwiftUI
struct ContentView: View {
private let coordinator = CoordinatorService.shared
var body: some View {
VStack(spacing: 32) {
Button(action: { coordinator.presentFullScreen(content: .auth) }) {
Text("Trigger AuthView in full screen")
}
.buttonStyle(.borderedProminent)
}
.customNavigationStack()
}
}
#Preview {
ContentView()
}
enum CoordinatorDestination: Hashable {
case auth, outcome
}
struct AuthView: View {
private let coordinator = CoordinatorService.shared
var body: some View {
Button(action: { coordinator.presentFullScreen(content: .outcome) }) {
Text("Authenticate")
}
.buttonStyle(.borderedProminent)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { coordinator.dismissFullScreen() }) {
Text("Dismiss")
}
}
}
.customNavigationStack(onFullScreen: true)
}
}
struct OutcomeView: View {
private let coordinator = CoordinatorService.shared
var body: some View {
Text("You have been authenticated!")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { coordinator.dismissFullScreen() }) {
Text("Dismiss")
}
}
}
.customNavigationStack(onFullScreen: true)
}
}
extension View {
func customNavigationStack(onFullScreen: Bool = false) -> some View {
modifier(CustomNavigationStackModifier(onFullScreen: onFullScreen))
}
}
struct CustomNavigationStackModifier: ViewModifier {
private let coordinator = CoordinatorService.shared
@State private var rootPath = NavigationPath()
@State private var fullScreenPaths: [NavigationPath] = [NavigationPath()]
@State private var presentFullScreens: [Bool] = [false]
let onFullScreen: Bool
func body(content: Content) -> some View {
NavigationStack(path: $rootPath) {
content
.onChange(of: coordinator.rootPath) { rootPath = $1 }
.onChange(of: coordinator.presentFullScreens) { presentFullScreens = $1 }
.fullScreenCover(
isPresented: $presentFullScreens[.zero],
onDismiss: { coordinator.removeLastFullScreenPath() }
) {
NavigationStack(path: $fullScreenPaths[.zero]) {
fullScreenContent
.onChange(of: coordinator.fullScreenPaths) { fullScreenPaths = $1 }
}
}
}
}
@ViewBuilder var fullScreenContent: some View {
if let fullScreenView = coordinator.fullScreenViews.last {
triggerNavigation(to: fullScreenView)
} else {
EmptyView()
}
}
@ViewBuilder private func triggerNavigation(
to destination: CoordinatorDestination
) -> some View {
switch destination {
case .auth:
AuthView()
case .outcome:
OutcomeView()
}
}
}
@MainActor @Observable final class CoordinatorService {
static let shared = CoordinatorService()
private(set) var fullScreenPaths = [NavigationPath()]
private(set) var rootPath = NavigationPath()
private(set) var presentFullScreens = [false]
private(set) var fullScreenViews: [CoordinatorDestination] = []
func navigate(
to destination: CoordinatorDestination,
onFullScreen: Bool = false
) {
onFullScreen
? fullScreenPaths[fullScreenPaths.count - 1].append(destination)
: rootPath.append(destination)
}
func presentFullScreen(content destination: CoordinatorDestination) {
if fullScreenViews.count > 1 {
fullScreenPaths.append(NavigationPath())
presentFullScreens.append(false)
}
fullScreenViews.append(destination)
navigate(to: destination, onFullScreen: true)
presentFullScreens[presentFullScreens.count - 1] = true
}
func dismissFullScreen() {
guard !fullScreenViews.isEmpty else { return }
removeLastFullScreenPath()
fullScreenViews.removeLast()
presentFullScreens[presentFullScreens.count - 1] = false
if presentFullScreens.count > 1 {
presentFullScreens.removeLast()
}
}
func removeLastFullScreenPath() {
fullScreenPaths.removeLast()
guard fullScreenPaths.isEmpty else { return }
fullScreenPaths.append(NavigationPath())
}
}
I have tried to put the .fullScreenCover
within a ForEach
, but it does not stack full screens on top of each others.
NavigationStack(path: $rootPath) {
content
.onChange(of: coordinator.rootPath) { rootPath = $1 }
.onChange(of: coordinator.presentFullScreens) { presentFullScreens = $1 }
.overlay {
ForEach(presentFullScreens.indices, id: \.self) { index in
EmptyView()
.fullScreenCover(
isPresented: $presentFullScreens[index],
onDismiss: { coordinator.removeLastFullScreenPath() }
) {
NavigationStack(path: $fullScreenPaths[index]) {
fullScreenContent
.onChange(of: coordinator.fullScreenPaths) { fullScreenPaths = $1 }
}
}
}
}
}
How can I stack multiple SwiftUI full screen covers on top of each other using a Coordinator pattern?