diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index bc195419..9f410999 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -31,7 +31,8 @@ struct ContentView: View { // and takes global requests while it is up. .appSheetHost( isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded != true, - dismissesOnExternalNavigation: true + dismissesOnExternalNavigation: true, + toastScope: .main ) .toastOverlay() } diff --git a/Yattee/Services/Navigation/PresentationRouter.swift b/Yattee/Services/Navigation/PresentationRouter.swift index 4e0a4a90..57c4f6b0 100644 --- a/Yattee/Services/Navigation/PresentationRouter.swift +++ b/Yattee/Services/Navigation/PresentationRouter.swift @@ -92,6 +92,18 @@ extension EnvironmentValues { } } +// MARK: - Toasts + +/// The toast overlay level of a presented sheet. +struct SheetToastLevel { + let scope: ToastScope + let depth: Int +} + +extension EnvironmentValues { + @Entry var sheetToastLevel = SheetToastLevel(scope: .main, depth: 0) +} + // MARK: - Host private struct AppSheetHost: ViewModifier { @@ -103,13 +115,25 @@ private struct AppSheetHost: ViewModifier { /// window's root host: the expanded player keeps its own sheets. let dismissesOnExternalNavigation: Bool + /// The toast scope of the window this host is in; nil inherits it. + let toastScope: ToastScope? + + @Environment(\.sheetToastLevel) private var parentToastLevel @State private var sheet: AppSheet? + /// Where this host's sheets show toasts: the window's scope, one level + /// deeper than the content they cover. + private var sheetToastLevel: SheetToastLevel { + SheetToastLevel(scope: toastScope ?? parentToastLevel.scope, depth: toastScope == nil ? parentToastLevel.depth + 1 : 1) + } + func body(content: Content) -> some View { content .environment(\.presentAppSheet, PresentAppSheetAction { sheet = $0 }) .sheet(item: $sheet) { sheet in sheetContent(for: sheet) + .sheetToastOverlay(scope: sheetToastLevel.scope, depth: sheetToastLevel.depth) + .environment(\.sheetToastLevel, sheetToastLevel) } // Declared before the request observer so a deep link that both // dismisses and presents ends with its own sheet up. @@ -198,8 +222,14 @@ extension View { /// - Parameter dismissesOnExternalNavigation: true for the main window's /// root host, whose sheet external navigation (notification, deep link, /// Handoff) dismisses. - func appSheetHost(isTopLevel: Bool, dismissesOnExternalNavigation: Bool = false) -> some View { - modifier(AppSheetHost(isTopLevel: isTopLevel, dismissesOnExternalNavigation: dismissesOnExternalNavigation)) + /// - Parameter toastScope: the toast scope of the window the host is in; + /// nil for hosts inside sheets, which inherit it. + func appSheetHost(isTopLevel: Bool, dismissesOnExternalNavigation: Bool = false, toastScope: ToastScope? = nil) -> some View { + modifier(AppSheetHost( + isTopLevel: isTopLevel, + dismissesOnExternalNavigation: dismissesOnExternalNavigation, + toastScope: toastScope + )) } /// Dismisses a screen's own sheet when navigation arrives from outside the diff --git a/Yattee/Services/ToastManager.swift b/Yattee/Services/ToastManager.swift index e0822556..71fa1280 100644 --- a/Yattee/Services/ToastManager.swift +++ b/Yattee/Services/ToastManager.swift @@ -118,6 +118,29 @@ final class ToastManager { } } + // MARK: - Overlay Hosts + + /// Toast overlays inside presented sheets, counted per scope and depth. + /// Only the deepest overlay of a scope shows its toasts, so they appear + /// above the sheet instead of behind it. + private var sheetOverlayDepths: [ToastScope: [Int: Int]] = [:] + + func registerOverlay(scope: ToastScope, depth: Int) { + guard depth > 0 else { return } + sheetOverlayDepths[scope, default: [:]][depth, default: 0] += 1 + } + + func unregisterOverlay(scope: ToastScope, depth: Int) { + guard depth > 0, let count = sheetOverlayDepths[scope]?[depth] else { return } + sheetOverlayDepths[scope]?[depth] = count > 1 ? count - 1 : nil + } + + /// Depth of the overlay that shows a scope's toasts: 0 for the window's + /// own overlay, higher inside sheets. + func topOverlayDepth(for scope: ToastScope) -> Int { + sheetOverlayDepths[scope]?.keys.max() ?? 0 + } + // MARK: - Filtered Access /// Returns toasts for a specific scope (filtered by activeScope). diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 7ec08029..8c69435f 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -498,7 +498,7 @@ struct ExpandedPlayerSheet: View { ) // Shared sheets opened from the player, and global requests while it // is up, so they appear above the player window. - .appSheetHost(isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded == true) + .appSheetHost(isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded == true, toastScope: .player) #if os(iOS) .toolbar(.hidden, for: .navigationBar) // Corner layout with panels visible keeps the system status bar and diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 1ce6bf73..e55866f9 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -126,7 +126,7 @@ struct TVPlayerView: View { .playerToastOverlay() // Global sheet requests (e.g. link action confirmations) while the // player covers the screen; the root host is underneath it. - .appSheetHost(isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded == true) + .appSheetHost(isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded == true, toastScope: .player) .fullScreenCover(isPresented: $showingErrorSheet) { ZStack { Rectangle() diff --git a/Yattee/Views/Toast/ToastOverlayView.swift b/Yattee/Views/Toast/ToastOverlayView.swift index ba2850e3..7f2810b9 100644 --- a/Yattee/Views/Toast/ToastOverlayView.swift +++ b/Yattee/Views/Toast/ToastOverlayView.swift @@ -13,14 +13,17 @@ struct ToastOverlayView: View { /// The scope of toasts to display in this overlay. let scope: ToastScope + /// 0 for the window's own overlay, 1 and up inside presented sheets. + var depth: Int = 0 private var toastManager: ToastManager? { appEnvironment?.toastManager } - /// Toasts filtered by scope + /// Toasts filtered by scope, shown only by the top-most overlay private var scopedToasts: [Toast] { - toastManager?.toasts(for: scope) ?? [] + guard let toastManager, toastManager.topOverlayDepth(for: scope) == depth else { return [] } + return toastManager.toasts(for: scope) } var body: some View { @@ -45,10 +48,14 @@ struct ToastOverlayView: View { .padding(.top, topPadding) .padding(.horizontal, 16) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: scopedToasts.count) + .onAppear { toastManager?.registerOverlay(scope: scope, depth: depth) } + .onDisappear { toastManager?.unregisterOverlay(scope: scope, depth: depth) } } private var topPadding: CGFloat { #if os(iOS) + // Sheets start below the status bar + if depth > 0 { return 16 } // In player scope (sheet), only need minimal padding since safe area is handled by sheet // In main scope, account for Dynamic Island / notch return scope == .player ? 8 : 60 @@ -77,6 +84,14 @@ extension View { ToastOverlayView(scope: .player) } } + + /// Adds a toast overlay to a presented sheet, which takes over its scope's + /// toasts from the overlays underneath while it is up. + func sheetToastOverlay(scope: ToastScope, depth: Int) -> some View { + overlay(alignment: .top) { + ToastOverlayView(scope: scope, depth: depth) + } + } } // MARK: - Preview