Files
yattee/Yattee/Views/Toast/ToastOverlayView.swift
Arkadiusz Fal 33152f4371 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.
2026-10-07 23:59:43 +02:00

109 lines
3.2 KiB
Swift

//
// ToastOverlayView.swift
// Yattee
//
// Overlay container for displaying toast notifications at the top of the screen.
//
import SwiftUI
/// Overlay that displays toast notifications at the top of the screen.
struct ToastOverlayView: View {
@Environment(\.appEnvironment) private var appEnvironment
/// 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, shown only by the top-most overlay
private var scopedToasts: [Toast] {
guard let toastManager, toastManager.topOverlayDepth(for: scope) == depth else { return [] }
return toastManager.toasts(for: scope)
}
var body: some View {
VStack(spacing: 8) {
ForEach(scopedToasts) { toast in
ToastCardView(
toast: toast,
onDismiss: {
toastManager?.dismiss(id: toast.id)
},
onAction: toast.action?.handler,
onDragBegan: {
toastManager?.pauseAutoDismiss(id: toast.id)
},
onDragCancelled: {
toastManager?.resumeAutoDismiss(id: toast.id)
}
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.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
#elseif os(macOS)
return 16
#elseif os(tvOS)
// TV safe area
return 60
#endif
}
}
// MARK: - View Extension
extension View {
/// Adds a toast overlay for the main window scope.
func toastOverlay() -> some View {
overlay(alignment: .top) {
ToastOverlayView(scope: .main)
}
}
/// Adds a toast overlay for the player scope.
func playerToastOverlay() -> some View {
overlay(alignment: .top) {
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
#Preview {
ZStack {
Color.gray.opacity(0.3)
.ignoresSafeArea()
Text("Main Content")
}
.toastOverlay()
.appEnvironment(.preview)
}