From 9260d48f4c7e126e6c69286466f1e60c21af2dc9 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 14 Apr 2026 23:32:49 +0200 Subject: [PATCH] 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. --- .../Views/Settings/SidebarSettingsView.swift | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/Yattee/Views/Settings/SidebarSettingsView.swift b/Yattee/Views/Settings/SidebarSettingsView.swift index 5b099be5..f7e9d6ab 100644 --- a/Yattee/Views/Settings/SidebarSettingsView.swift +++ b/Yattee/Views/Settings/SidebarSettingsView.swift @@ -133,7 +133,7 @@ struct SidebarSettingsView: View { channelsSection playlistsSection } - #if os(iOS) || os(tvOS) + #if os(iOS) .environment(\.editMode, .constant(.active)) #endif .navigationTitle(String(localized: "settings.sidebar.title")) @@ -169,6 +169,21 @@ struct SidebarSettingsView: View { private var mainNavigationSection: some View { 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 SidebarMainItemRow( icon: item.icon, @@ -206,6 +221,7 @@ struct SidebarSettingsView: View { // Save immediately saveMainNavigationSettings() } + #endif } header: { Text(String(localized: "settings.sidebar.mainNavigation.header")) } 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 { Binding( 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 #Preview {