Unstick more tvOS focus dead-ends in channel views

Settings → Notifications → Manage Channels: wrap the tvOS NavigationLink
destination in TVSidebarDetailContainer(showsDismissButton: true) so the
no-subscriptions, error, and loading states all have a focusable Done.

Channels sidebar tab: lift the tvOS search field + View Options button
out of the loaded-channels branch and render it above every state. The
empty state previously had zero focusable elements, leaving the right
pane blank when swiping in from the sidebar.
This commit is contained in:
Arkadiusz Fal
2026-05-06 23:01:02 +02:00
parent 39beb45cff
commit cc109043b3
2 changed files with 66 additions and 45 deletions

View File

@@ -137,7 +137,13 @@ private struct ManageChannelsSection: View {
SettingsFormSection {
#if os(tvOS)
NavigationLink {
ManageChannelNotificationsView()
TVSidebarDetailContainer(
systemImage: "bell.badge",
title: String(localized: "settings.notifications.manageChannels"),
showsDismissButton: true
) {
ManageChannelNotificationsView()
}
} label: {
Label(
String(localized: "settings.notifications.manageChannels"),

View File

@@ -81,24 +81,20 @@ struct ManageChannelsView: View {
var body: some View {
GeometryReader { geometry in
Group {
if isLoading && channels.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if channels.isEmpty {
ContentUnavailableView {
Label(String(localized: "subscriptions.channels.title"), systemImage: "person.2")
} description: {
Text(String(localized: "subscriptions.channels.empty"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
channelsView
}
#if os(tvOS)
VStack(spacing: 0) {
tvOSHeader
stateContent
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#else
stateContent
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "subscriptions.channels.title"))
@@ -226,42 +222,61 @@ struct ManageChannelsView: View {
// MARK: - Content Views
@ViewBuilder
private var stateContent: some View {
if isLoading && channels.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if channels.isEmpty {
ContentUnavailableView {
Label(String(localized: "subscriptions.channels.title"), systemImage: "person.2")
} description: {
Text(String(localized: "subscriptions.channels.empty"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
channelsView
}
}
#if os(tvOS)
/// Header rendered above every state on tvOS so focus always has a target.
@ViewBuilder
private var tvOSHeader: some View {
HStack(spacing: 24) {
TextField("search.channels.placeholder", text: $searchText)
.textFieldStyle(.plain)
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 20)
.padding(.bottom, 20)
}
#endif
@ViewBuilder
private var channelsView: some View {
#if os(tvOS)
VStack(spacing: 0) {
// tvOS: Inline search field and action button for better focus navigation
HStack(spacing: 24) {
TextField("search.channels.placeholder", text: $searchText)
.textFieldStyle(.plain)
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
Group {
if filteredChannels.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 20)
.padding(.bottom, 20)
// Content
Group {
if filteredChannels.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.focusSection()
}
.focusSection()
#else
Group {
if filteredChannels.isEmpty {