From bc6ad55f3817a05a821ffd797da832b31b26ae3d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 7 Oct 2026 23:59:44 +0200 Subject: [PATCH] Ask link action confirmations in an alert on tvOS A tvOS sheet fills most of the screen, far too much for a two-button question. The shared sheet host now presents link action confirmations as an alert there, answering cancel when it goes away without a choice. --- .../Navigation/PresentationRouter.swift | 25 +++++++- .../LinkActionConfirmationSheet.swift | 64 ++++++++++++------- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/Yattee/Services/Navigation/PresentationRouter.swift b/Yattee/Services/Navigation/PresentationRouter.swift index 57c4f6b0..5d7ed620 100644 --- a/Yattee/Services/Navigation/PresentationRouter.swift +++ b/Yattee/Services/Navigation/PresentationRouter.swift @@ -130,11 +130,14 @@ private struct AppSheetHost: ViewModifier { func body(content: Content) -> some View { content .environment(\.presentAppSheet, PresentAppSheetAction { sheet = $0 }) - .sheet(item: $sheet) { sheet in + .sheet(item: presentedSheet) { sheet in sheetContent(for: sheet) .sheetToastOverlay(scope: sheetToastLevel.scope, depth: sheetToastLevel.depth) .environment(\.sheetToastLevel, sheetToastLevel) } + #if os(tvOS) + .linkActionConfirmationAlert(pendingConfirmation) { sheet = nil } + #endif // Declared before the request observer so a deep link that both // dismisses and presents ends with its own sheet up. .onChange(of: dismissGeneration) { _, _ in @@ -145,6 +148,26 @@ private struct AppSheetHost: ViewModifier { } } + /// The sheet to present as a sheet. On tvOS a link action confirmation + /// is a compact alert instead: a tvOS sheet fills most of the screen. + private var presentedSheet: Binding { + #if os(tvOS) + Binding { + if case .linkActionConfirmation = sheet { return nil } + return sheet + } set: { sheet = $0 } + #else + $sheet + #endif + } + + #if os(tvOS) + private var pendingConfirmation: LinkActionConfirmation? { + if case .linkActionConfirmation(let confirmation) = sheet { return confirmation } + return nil + } + #endif + private var dismissGeneration: Int? { dismissesOnExternalNavigation ? appEnvironment?.navigationCoordinator.dismissPresentationsGeneration : nil } diff --git a/Yattee/Views/Components/LinkActionConfirmationSheet.swift b/Yattee/Views/Components/LinkActionConfirmationSheet.swift index 89c37c89..5e3bd28d 100644 --- a/Yattee/Views/Components/LinkActionConfirmationSheet.swift +++ b/Yattee/Views/Components/LinkActionConfirmationSheet.swift @@ -44,6 +44,12 @@ final class LinkActionConfirmation { } } +extension LinkActionConfirmation: Equatable { + nonisolated static func == (lhs: LinkActionConfirmation, rhs: LinkActionConfirmation) -> Bool { + lhs === rhs + } +} + struct LinkActionConfirmationSheet: View { @Environment(\.dismiss) private var dismiss @@ -57,29 +63,6 @@ struct LinkActionConfirmationSheet: View { } } - #if os(tvOS) - private var content: some View { - NavigationStack { - List { - Section { - header - .padding(.vertical, 8) - } - Section { - Button { respond(true) } label: { - Label(confirmation.confirmTitle, systemImage: confirmation.systemImage) - } - Button(role: .cancel) { respond(false) } label: { - Label(String(localized: "common.cancel"), systemImage: "xmark") - } - } - } - .scrollClipDisabled() - .padding(.horizontal, 40) - .padding(.vertical, 24) - } - } - #else private var content: some View { VStack(spacing: 0) { header @@ -112,10 +95,11 @@ struct LinkActionConfirmationSheet: View { #if os(macOS) .frame(width: 380) #endif + #if !os(tvOS) .presentationDetents([.height(210)]) .presentationDragIndicator(.visible) + #endif } - #endif private var header: some View { VStack(alignment: .leading, spacing: 6) { @@ -141,3 +125,35 @@ struct LinkActionConfirmationSheet: View { dismiss() } } + +#if os(tvOS) +extension View { + /// Presents a link action confirmation as an alert. tvOS sheets fill most + /// of the screen, too much for a two-button question. + func linkActionConfirmationAlert( + _ confirmation: LinkActionConfirmation?, + onDismiss: @escaping () -> Void + ) -> some View { + alert( + confirmation?.title ?? "", + isPresented: Binding { + confirmation != nil + } set: { isPresented in + if !isPresented { onDismiss() } + }, + presenting: confirmation + ) { confirmation in + Button(confirmation.confirmTitle) { confirmation.answer(true) } + Button(String(localized: "common.cancel"), role: .cancel) { confirmation.answer(false) } + } message: { confirmation in + Text([confirmation.subject, confirmation.detail].compactMap { $0 }.joined(separator: "\n")) + } + .onChange(of: confirmation) { previous, _ in + // Gone without an answer (Menu button, external navigation, another + // sheet): treat as cancel. Deferred so a button's answer comes first. + guard let previous else { return } + Task { @MainActor in previous.answer(false) } + } + } +} +#endif