jellyflood/Shared/Objects/CaseIterablePicker.swift

134 lines
3.7 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
/// A `View` that automatically generates a SwiftUI `Picker` if `Element` conforms to `CaseIterable`.
///
/// If `Element` is optional, an additional `none` value is added to select `nil` that can be customized
/// by `.noneStyle()`.
struct CaseIterablePicker<Element: CaseIterable & Displayable & Hashable>: View {
enum NoneStyle: Displayable {
case text
case dash(Int)
case custom(String)
var displayTitle: String {
switch self {
case .text:
return L10n.none
case let .dash(length):
assert(length >= 1, "Dash must have length of at least 1.")
return String(repeating: "-", count: length)
case let .custom(text):
assert(text.isNotEmpty, "Custom text must have length of at least 1.")
return text
}
}
}
@Binding
private var selection: Element?
@ViewBuilder
private var label: (Element) -> any View
private let title: String
private let hasNone: Bool
private var noneStyle: NoneStyle
var body: some View {
Picker(title, selection: $selection) {
if hasNone {
Text(noneStyle.displayTitle)
.tag(nil as Element?)
}
ForEach(Element.allCases.asArray, id: \.hashValue) {
label($0)
.eraseToAnyView()
.tag($0 as Element?)
}
}
}
}
// MARK: Text
extension CaseIterablePicker {
init(_ title: String, selection: Binding<Element?>) {
self.init(
selection: selection,
label: { Text($0.displayTitle) },
title: title,
hasNone: true,
noneStyle: .text
)
}
init(_ title: String, selection: Binding<Element>) {
let binding = Binding<Element?> {
selection.wrappedValue
} set: { newValue, _ in
precondition(newValue != nil, "Should not have nil new value with non-optional binding")
selection.wrappedValue = newValue!
}
self.init(
selection: binding,
label: { Text($0.displayTitle) },
title: title,
hasNone: false,
noneStyle: .text
)
}
func noneStyle(_ newStyle: NoneStyle) -> Self {
copy(modifying: \.noneStyle, with: newStyle)
}
}
// MARK: Label
// TODO: I didn't entirely like the forced label design that this
// uses, decide whether to actually keep
// extension CaseIterablePicker where Element: SystemImageable {
//
// init(title: String, selection: Binding<Element?>) {
// self.init(
// selection: selection,
// label: { Label($0.displayTitle, systemImage: $0.systemImage) },
// title: title,
// hasNone: true,
// noneStyle: .text
// )
// }
//
// init(title: String, selection: Binding<Element>) {
// let binding = Binding<Element?> {
// selection.wrappedValue
// } set: { newValue, _ in
// precondition(newValue != nil, "Should not have nil new value with non-optional binding")
// selection.wrappedValue = newValue!
// }
//
// self.init(
// selection: binding,
// label: { Label($0.displayTitle, systemImage: $0.systemImage) },
// title: title,
// hasNone: false,
// noneStyle: .text
// )
// }
// }