Show toasts above shared sheets

A toast shown while a shared sheet was up, such as a failed link action
over Open Link, appeared behind the sheet. Sheets from .appSheetHost now
carry their own toast overlay, and only the top-most overlay of a scope
shows its toasts.
This commit is contained in:
Arkadiusz Fal
2026-10-07 23:59:43 +02:00
parent 7527370f7f
commit 33152f4371
6 changed files with 76 additions and 7 deletions

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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()

View File

@@ -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