Make tvOS detail dismiss button opt-in and unstick more views

TVSidebarDetailContainer now exposes a showsDismissButton flag instead of
always attaching a Done toolbar item. The button is only enabled where a
view can end up with no focusable element on its own — Device
Capabilities (informational rows) and the Import Playlists/Subscriptions
flows.

Wrap Contributors, Translators, Acknowledgements, and Device Capabilities
destinations in TVSidebarDetailContainer for the consistent sidebar look,
and make the Translators/Acknowledgements rows focusable on tvOS by
wrapping them in Buttons so the Menu remote button can pop the stack.
This commit is contained in:
Arkadiusz Fal
2026-05-06 22:41:46 +02:00
parent 5c7429abf3
commit 39beb45cff
7 changed files with 68 additions and 71 deletions

View File

@@ -9,22 +9,24 @@
#if os(tvOS) #if os(tvOS)
import SwiftUI import SwiftUI
struct TVSidebarDetailContainer<Content: View, BottomAction: View>: View { struct TVSidebarDetailContainer<Content: View>: View {
let content: Content let content: Content
let bottomAction: BottomAction
var systemImage: String? var systemImage: String?
var title: String? var title: String?
var showsDismissButton: Bool
@Environment(\.dismiss) private var dismiss
init( init(
systemImage: String? = nil, systemImage: String? = nil,
title: String? = nil, title: String? = nil,
@ViewBuilder bottomAction: () -> BottomAction = { EmptyView() }, showsDismissButton: Bool = false,
@ViewBuilder content: () -> Content @ViewBuilder content: () -> Content
) { ) {
self.content = content() self.content = content()
self.bottomAction = bottomAction()
self.systemImage = systemImage self.systemImage = systemImage
self.title = title self.title = title
self.showsDismissButton = showsDismissButton
} }
var body: some View { var body: some View {
@@ -32,30 +34,19 @@ struct TVSidebarDetailContainer<Content: View, BottomAction: View>: View {
.focusSection() .focusSection()
.safeAreaInset(edge: .leading) { .safeAreaInset(edge: .leading) {
if let systemImage { if let systemImage {
VStack(spacing: 0) { VStack(spacing: 16) {
Spacer() Image(systemName: systemImage)
VStack(spacing: 16) { .font(.system(size: 80))
Image(systemName: systemImage) .foregroundStyle(.secondary)
.font(.system(size: 80)) if let title {
Text(title)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if let title { .multilineTextAlignment(.center)
Text(title)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
} }
.allowsHitTesting(false)
if BottomAction.self != EmptyView.self {
bottomAction
.padding(.top, 40)
.focusSection()
}
Spacer()
} }
.allowsHitTesting(false)
.frame(width: 400) .frame(width: 400)
} else { } else {
Spacer() Spacer()
@@ -63,22 +54,17 @@ struct TVSidebarDetailContainer<Content: View, BottomAction: View>: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
} .toolbar {
} if showsDismissButton {
ToolbarItem(placement: .cancellationAction) {
struct TVDismissBottomButton: View { Button {
var title: String = String(localized: "common.done") dismiss()
var systemImage: String = "chevron.backward" } label: {
Label(String(localized: "common.done"), systemImage: "chevron.backward")
@Environment(\.dismiss) private var dismiss }
}
var body: some View { }
Button { }
dismiss()
} label: {
Label(title, systemImage: systemImage)
}
.buttonStyle(TVSettingsButtonStyle())
} }
} }
#endif #endif

View File

@@ -23,19 +23,34 @@ struct AboutView: View {
SettingsFormSection { SettingsFormSection {
#if os(tvOS) #if os(tvOS)
NavigationLink { NavigationLink {
ContributorsView() TVSidebarDetailContainer(
systemImage: "person.3",
title: String(localized: "settings.contributors.title")
) {
ContributorsView()
}
} label: { } label: {
Label(String(localized: "settings.contributors.title"), systemImage: "person.3") Label(String(localized: "settings.contributors.title"), systemImage: "person.3")
} }
NavigationLink { NavigationLink {
TranslationContributorsView() TVSidebarDetailContainer(
systemImage: "globe",
title: String(localized: "settings.translators.title")
) {
TranslationContributorsView()
}
} label: { } label: {
Label(String(localized: "settings.translators.title"), systemImage: "globe") Label(String(localized: "settings.translators.title"), systemImage: "globe")
} }
NavigationLink { NavigationLink {
AcknowledgementsView() TVSidebarDetailContainer(
systemImage: "heart.text.square",
title: String(localized: "settings.acknowledgements.title")
) {
AcknowledgementsView()
}
} label: { } label: {
Label(String(localized: "settings.acknowledgements.title"), systemImage: "heart.text.square") Label(String(localized: "settings.acknowledgements.title"), systemImage: "heart.text.square")
} }
@@ -58,7 +73,13 @@ struct AboutView: View {
SettingsFormSection { SettingsFormSection {
#if os(tvOS) #if os(tvOS)
NavigationLink { NavigationLink {
DeviceCapabilitiesView() TVSidebarDetailContainer(
systemImage: "cpu",
title: String(localized: "settings.advanced.deviceCapabilities"),
showsDismissButton: true
) {
DeviceCapabilitiesView()
}
} label: { } label: {
Label(String(localized: "settings.advanced.deviceCapabilities"), systemImage: "cpu") Label(String(localized: "settings.advanced.deviceCapabilities"), systemImage: "cpu")
} }

View File

@@ -26,9 +26,6 @@ struct AcknowledgementsView: View {
@ViewBuilder @ViewBuilder
private func dependencyLink(_ name: String, url: String) -> some View { private func dependencyLink(_ name: String, url: String) -> some View {
#if os(tvOS)
Text(name)
#else
Button { Button {
if let url = URL(string: url) { if let url = URL(string: url) {
openURL(url) openURL(url)
@@ -37,15 +34,16 @@ struct AcknowledgementsView: View {
HStack { HStack {
Text(name) Text(name)
Spacer() Spacer()
#if !os(tvOS)
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#endif
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
#if os(macOS) #if os(macOS)
.buttonStyle(.plain) .buttonStyle(.plain)
#endif #endif
#endif
} }
} }

View File

@@ -242,7 +242,8 @@ private struct EditRemoteServerContent: View {
#if os(tvOS) #if os(tvOS)
TVSidebarDetailContainer( TVSidebarDetailContainer(
systemImage: "person.2", systemImage: "person.2",
title: String(localized: "sources.import.subscriptions") title: String(localized: "sources.import.subscriptions"),
showsDismissButton: true
) { ) {
ImportSubscriptionsView(instance: instance) ImportSubscriptionsView(instance: instance)
} }
@@ -258,7 +259,8 @@ private struct EditRemoteServerContent: View {
#if os(tvOS) #if os(tvOS)
TVSidebarDetailContainer( TVSidebarDetailContainer(
systemImage: "list.bullet.rectangle", systemImage: "list.bullet.rectangle",
title: String(localized: "sources.import.playlists") title: String(localized: "sources.import.playlists"),
showsDismissButton: true
) { ) {
ImportPlaylistsView(instance: instance) ImportPlaylistsView(instance: instance)
} }

View File

@@ -11,7 +11,6 @@ struct ImportPlaylistsView: View {
let instance: Instance let instance: Instance
@Environment(\.appEnvironment) private var appEnvironment @Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
@State private var playlists: [Playlist] = [] @State private var playlists: [Playlist] = []
@State private var importedPlaylistIDs: Set<String> = [] @State private var importedPlaylistIDs: Set<String> = []
@@ -60,15 +59,6 @@ struct ImportPlaylistsView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar { .toolbar {
#if os(tvOS)
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Label(String(localized: "common.done"), systemImage: "chevron.backward")
}
}
#endif
if !unimportedPlaylists.isEmpty && importingPlaylistID == nil { if !unimportedPlaylists.isEmpty && importingPlaylistID == nil {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {

View File

@@ -11,7 +11,6 @@ struct ImportSubscriptionsView: View {
let instance: Instance let instance: Instance
@Environment(\.appEnvironment) private var appEnvironment @Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
@State private var channels: [Channel] = [] @State private var channels: [Channel] = []
@State private var subscribedChannelIDs: Set<String> = [] @State private var subscribedChannelIDs: Set<String> = []
@@ -51,15 +50,6 @@ struct ImportSubscriptionsView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar { .toolbar {
#if os(tvOS)
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Label(String(localized: "common.done"), systemImage: "chevron.backward")
}
}
#endif
if !unsubscribedChannels.isEmpty { if !unsubscribedChannels.isEmpty {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {

View File

@@ -47,6 +47,16 @@ struct TranslationContributorsView: View {
} }
private func contributorRow(_ contributor: TranslationContributor) -> some View { private func contributorRow(_ contributor: TranslationContributor) -> some View {
#if os(tvOS)
Button {} label: {
contributorRowContent(contributor)
}
#else
contributorRowContent(contributor)
#endif
}
private func contributorRowContent(_ contributor: TranslationContributor) -> some View {
HStack(spacing: 12) { HStack(spacing: 12) {
// Avatar // Avatar
LazyImage(url: contributor.gravatarURL) { state in LazyImage(url: contributor.gravatarURL) { state in