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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user