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.
This commit is contained in:
Arkadiusz Fal
2026-10-07 23:59:44 +02:00
parent 33152f4371
commit bc6ad55f38
2 changed files with 64 additions and 25 deletions

View File

@@ -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<AppSheet?> {
#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
}

View File

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