mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 05:02:53 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user