Fix card management on tvOS

- Extract TVToolbarButton as shared component
- Implement tvOS-specific card row with native Toggle and reorder handle
- Add focus section and move command support for reordering
This commit is contained in:
世界
2025-12-04 15:41:17 +08:00
parent ad9a9ae6b4
commit ada4232f54
3 changed files with 135 additions and 55 deletions
@@ -0,0 +1,42 @@
#if os(tvOS)
import SwiftUI
import UIKit
/// Workaround for tvOS SwiftUI bug where toolbar Button text labels fail to render properly.
/// Using UIButton via UIViewRepresentable bypasses this issue.
struct TVToolbarButton: UIViewRepresentable {
let title: String
let action: () -> Void
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.addTarget(context.coordinator, action: #selector(Coordinator.buttonTapped), for: .primaryActionTriggered)
return button
}
func updateUIView(_ uiView: UIButton, context: Context) {
uiView.setTitle(title, for: .normal)
uiView.invalidateIntrinsicContentSize()
uiView.sizeToFit()
context.coordinator.action = action
}
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
class Coordinator: NSObject {
var action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
}
@objc func buttonTapped() {
action()
}
}
}
#endif
@@ -100,6 +100,8 @@ import SwiftUI
#if os(tvOS) #if os(tvOS)
@MainActor public struct CardManagementView: View { @MainActor public struct CardManagementView: View {
@StateObject private var configuration = DashboardCardConfiguration() @StateObject private var configuration = DashboardCardConfiguration()
@FocusState private var focusedCard: DashboardCard?
@State private var movingCard: DashboardCard?
private let onDisappear: (() -> Void)? private let onDisappear: (() -> Void)?
public init(onDisappear: (() -> Void)? = nil) { public init(onDisappear: (() -> Void)? = nil) {
@@ -111,39 +113,115 @@ import SwiftUI
if configuration.isLoading { if configuration.isLoading {
ProgressView() ProgressView()
} else { } else {
List { ScrollView {
ForEach(configuration.cardOrder) { card in LazyVStack(spacing: 12) {
CardRow( ForEach(configuration.cardOrder) { card in
card: card, TVCardRow(
isEnabled: configuration.isEnabled(card), card: card,
onToggle: { isEnabled: configuration.isEnabled(card),
configuration.toggleCard(card) isMoving: movingCard == card,
} focusedCard: $focusedCard,
) onToggle: {
} configuration.toggleCard(card)
.onMove { source, destination in },
Task { onToggleMoving: {
await configuration.moveCard(from: source, to: destination) withAnimation {
if movingCard == card {
movingCard = nil
} else {
movingCard = card
}
}
}
)
} }
} }
.padding()
} }
.applyContentMargins() .focusSection()
} }
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Reset", role: .destructive) { TVToolbarButton(title: "Reset") {
Task { Task {
await configuration.resetToDefault() await configuration.resetToDefault()
} }
} }
} }
} }
.onMoveCommand { direction in
guard let movingCard,
let currentIndex = configuration.cardOrder.firstIndex(of: movingCard)
else { return }
let newIndex: Int
switch direction {
case .up:
guard currentIndex > 0 else { return }
newIndex = currentIndex - 1
case .down:
guard currentIndex < configuration.cardOrder.count - 1 else { return }
newIndex = currentIndex + 1
default:
return
}
Task {
await configuration.moveCard(from: IndexSet(integer: currentIndex), to: newIndex > currentIndex ? newIndex + 1 : newIndex)
}
focusedCard = movingCard
}
.onDisappear { .onDisappear {
onDisappear?() onDisappear?()
} }
} }
} }
private struct TVCardRow: View {
let card: DashboardCard
let isEnabled: Bool
let isMoving: Bool
var focusedCard: FocusState<DashboardCard?>.Binding
let onToggle: () -> Void
let onToggleMoving: () -> Void
private var isProfileCard: Bool {
card == .profile
}
var body: some View {
HStack(spacing: 16) {
Label(card.title, systemImage: card.systemImage)
.font(.headline)
.foregroundStyle(isEnabled ? .primary : .secondary)
Spacer()
if !isProfileCard {
Toggle("", isOn: Binding(
get: { isEnabled },
set: { _ in onToggle() }
))
.labelsHidden()
}
Button {
onToggleMoving()
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 20))
}
.buttonStyle(.plain)
.actionButtonStyle()
.focused(focusedCard, equals: card)
}
.padding()
.background(Color.secondary.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
.scaleEffect(isMoving ? 1.05 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isMoving)
}
}
#endif #endif
private struct CardRow: View { private struct CardRow: View {
@@ -953,46 +953,6 @@ private struct ProfilePickerRow: View {
// MARK: - Legacy iOS ProfilePickerRow (iOS < 26) // MARK: - Legacy iOS ProfilePickerRow (iOS < 26)
#if os(tvOS)
/// Workaround for tvOS SwiftUI bug where toolbar Button text labels fail to render properly.
/// Using UIButton via UIViewRepresentable bypasses this issue.
private struct TVToolbarButton: UIViewRepresentable {
let title: String
let action: () -> Void
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.addTarget(context.coordinator, action: #selector(Coordinator.buttonTapped), for: .primaryActionTriggered)
return button
}
func updateUIView(_ uiView: UIButton, context: Context) {
uiView.setTitle(title, for: .normal)
uiView.invalidateIntrinsicContentSize()
uiView.sizeToFit()
context.coordinator.action = action
}
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
class Coordinator: NSObject {
var action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
}
@objc func buttonTapped() {
action()
}
}
}
#endif
#if os(iOS) #if os(iOS)
private struct LegacyProfilePickerRow: View { private struct LegacyProfilePickerRow: View {
@EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var environments: ExtensionEnvironments