Make tvOS sidebar main navigation toggles focusable

The tvOS Main Navigation list relied on .onMove with native Toggle
rows inside editMode, which leaves only the drag handle focusable on
tvOS. Replace it with a TVSidebarMainItemRow modeled on the home
customization screen: explicit up/down chevrons on the left and a
tap-to-toggle checkmark button as the row body. Required items render
disabled with a dimmed checkmark.
This commit is contained in:
Arkadiusz Fal
2026-04-14 23:32:49 +02:00
parent 2dcfe52bfb
commit 9260d48f4c

View File

@@ -133,7 +133,7 @@ struct SidebarSettingsView: View {
channelsSection channelsSection
playlistsSection playlistsSection
} }
#if os(iOS) || os(tvOS) #if os(iOS)
.environment(\.editMode, .constant(.active)) .environment(\.editMode, .constant(.active))
#endif #endif
.navigationTitle(String(localized: "settings.sidebar.title")) .navigationTitle(String(localized: "settings.sidebar.title"))
@@ -169,6 +169,21 @@ struct SidebarSettingsView: View {
private var mainNavigationSection: some View { private var mainNavigationSection: some View {
Section { Section {
#if os(tvOS)
let availableItems = mainItemOrder.filter { $0.isAvailableOnCurrentPlatform }
ForEach(Array(availableItems.enumerated()), id: \.element.id) { index, item in
TVSidebarMainItemRow(
icon: item.icon,
title: item.localizedTitle,
isRequired: item.isRequired,
isVisible: mainItemBinding(for: item),
canMoveUp: index > 0,
canMoveDown: index < availableItems.count - 1,
onMoveUp: { moveMainItem(at: index, direction: -1) },
onMoveDown: { moveMainItem(at: index, direction: 1) }
)
}
#else
ForEach(mainItemOrder.filter { $0.isAvailableOnCurrentPlatform }) { item in ForEach(mainItemOrder.filter { $0.isAvailableOnCurrentPlatform }) { item in
SidebarMainItemRow( SidebarMainItemRow(
icon: item.icon, icon: item.icon,
@@ -206,6 +221,7 @@ struct SidebarSettingsView: View {
// Save immediately // Save immediately
saveMainNavigationSettings() saveMainNavigationSettings()
} }
#endif
} header: { } header: {
Text(String(localized: "settings.sidebar.mainNavigation.header")) Text(String(localized: "settings.sidebar.mainNavigation.header"))
} footer: { } footer: {
@@ -213,6 +229,24 @@ struct SidebarSettingsView: View {
} }
} }
#if os(tvOS)
private func moveMainItem(at index: Int, direction: Int) {
let available = mainItemOrder.filter { $0.isAvailableOnCurrentPlatform }
let newIndex = index + direction
guard index >= 0, index < available.count,
newIndex >= 0, newIndex < available.count else { return }
let movedItem = available[index]
let neighborItem = available[newIndex]
guard let fromActual = mainItemOrder.firstIndex(of: movedItem),
let toActual = mainItemOrder.firstIndex(of: neighborItem) else { return }
mainItemOrder.swapAt(fromActual, toActual)
saveMainNavigationSettings()
}
#endif
private func mainItemBinding(for item: SidebarMainItem) -> Binding<Bool> { private func mainItemBinding(for item: SidebarMainItem) -> Binding<Bool> {
Binding( Binding(
get: { mainItemVisibility[item] ?? true }, get: { mainItemVisibility[item] ?? true },
@@ -490,6 +524,81 @@ private struct SidebarMainItemRow: View {
} }
} }
// MARK: - tvOS Sidebar Main Item Row
#if os(tvOS)
private struct TVSidebarMainItemRow: View {
let icon: String
let title: String
let isRequired: Bool
@Binding var isVisible: Bool
let canMoveUp: Bool
let canMoveDown: Bool
let onMoveUp: () -> Void
let onMoveDown: () -> Void
var body: some View {
HStack(spacing: 12) {
VStack(spacing: 4) {
Button(action: onMoveUp) {
Image(systemName: "chevron.up")
.font(.caption)
.foregroundStyle(canMoveUp ? .primary : .tertiary)
.frame(width: 30, height: 24)
}
.buttonStyle(TVSidebarCompactButtonStyle())
.disabled(!canMoveUp)
Button(action: onMoveDown) {
Image(systemName: "chevron.down")
.font(.caption)
.foregroundStyle(canMoveDown ? .primary : .tertiary)
.frame(width: 30, height: 24)
}
.buttonStyle(TVSidebarCompactButtonStyle())
.disabled(!canMoveDown)
}
Button {
guard !isRequired else { return }
isVisible.toggle()
} label: {
HStack {
Image(systemName: icon)
.frame(width: 24)
.foregroundStyle(.secondary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Image(systemName: isVisible ? "checkmark.circle.fill" : "circle")
.foregroundColor(isRequired ? .secondary : (isVisible ? .green : .secondary))
.font(.title3)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TVFormRowButtonStyle())
.disabled(isRequired)
}
.padding(.vertical, 4)
}
}
private struct TVSidebarCompactButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isFocused ? Color.white.opacity(0.2) : Color.clear)
)
.scaleEffect(configuration.isPressed ? 0.9 : (isFocused ? 1.1 : 1.0))
.animation(.easeInOut(duration: 0.1), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {