|
import OrderedCollections |
|
import SwiftUI |
|
|
|
@main |
|
struct HorizontalScrollingPickerApp: App { |
|
|
|
@State |
|
private var selection = SomeChoices.photo |
|
|
|
var body: some Scene { |
|
WindowGroup { |
|
VStack(alignment: .center) { |
|
|
|
TextCarouselPicker( |
|
selection: $selection, |
|
labels: { $0.name.uppercased() }, |
|
spacing: 15.0 |
|
) |
|
.accentColor(.yellow) |
|
.font(.caption.weight(.medium)) |
|
.fixedSize(horizontal: false, vertical: true) |
|
|
|
HStack { |
|
Spacer() |
|
Rectangle().frame(width: 1).opacity(0.2) |
|
Spacer() |
|
} |
|
} |
|
.preferredColorScheme(.dark) |
|
} |
|
} |
|
} |
|
|
|
// MARK: - Model |
|
|
|
enum SomeChoices: Int, PickerSelectable { |
|
case slomo, video, photo, square, pano |
|
|
|
var id: Self { self } |
|
|
|
var name: String { |
|
switch self { |
|
case .slomo: return .init("Slo-Mo") |
|
case .video: return .init("Video") |
|
case .photo: return .init("Photo") |
|
case .square: return .init("Square") |
|
case .pano: return .init("Pano") |
|
} |
|
} |
|
} |
|
|
|
protocol PickerSelectable: CaseIterable & Hashable & Identifiable {} |
|
|
|
// MARK: - Picker |
|
|
|
struct TextCarouselPicker<T: PickerSelectable>: View { |
|
|
|
init( |
|
selection: Binding<T>, |
|
labels: @escaping (T) -> String, |
|
orderedChoices: Array<T> = T.allCases.map { $0 }, |
|
spacing: CGFloat |
|
) { |
|
self.choices = orderedChoices |
|
self.labels = labels |
|
self.spacing = spacing |
|
_selection = selection |
|
_widths = State( |
|
initialValue: |
|
OrderedDictionary( |
|
uncheckedUniqueKeys: orderedChoices, |
|
values: Array(repeating: 0, count: choices.count) |
|
) |
|
) |
|
} |
|
|
|
@Binding |
|
private var selection: T |
|
|
|
private let choices: Array<T> |
|
|
|
private let labels: (T) -> String |
|
|
|
@State |
|
private var widths: OrderedDictionary<AnyHashable, CGFloat> |
|
|
|
private let spacing: CGFloat |
|
|
|
var body: some View { |
|
Color.clear |
|
.hidden() |
|
.overlay(alignment: .center) { |
|
alignedButtonsRow |
|
.frame(maxWidth: .infinity) |
|
.mask(horizontalMask) |
|
.accessibilityRepresentation { |
|
accessibilityRepresentation |
|
} |
|
} |
|
} |
|
} |
|
|
|
private extension TextCarouselPicker { |
|
|
|
var alignedButtonsRow: some View { |
|
HStack(alignment: .center, spacing: spacing) { |
|
ForEach(choices) { number in |
|
buildButton(number) |
|
} |
|
} |
|
.alignmentGuide(HorizontalAlignment.center) { d in |
|
d[HorizontalAlignment.leading] + alignedButtonsRowOffset |
|
} |
|
.onPreferenceChange(OrderedWidthsPreferenceKey.self) { newValue in |
|
withAnimation { |
|
widths.merge(newValue) { $1 } |
|
} |
|
} |
|
} |
|
|
|
var alignedButtonsRowOffset: CGFloat { |
|
let selectedIndex = widths.index(forKey: selection)! |
|
let buttonWidths = widths.elements.values[..<selectedIndex].reduce(0, +) |
|
// Raw value is zero-based (effectively: spacing * items.count - 1) |
|
let interButtonSpacing = spacing * CGFloat(selectedIndex) |
|
let centerOfCurrentSelection = widths.elements[selectedIndex].value / 2 |
|
return buttonWidths + interButtonSpacing + centerOfCurrentSelection |
|
} |
|
|
|
func buildButton(_ choice: T) -> some View { |
|
Button( |
|
action: { |
|
withAnimation(.easeInOut(duration: 0.25)) { |
|
selection = choice |
|
} |
|
}, |
|
label: { |
|
Text(labels(choice)) |
|
.lineLimit(1) |
|
.fixedSize(horizontal: true, vertical: true) |
|
.foregroundStyle(selection == choice ? Color.accentColor : .secondary) |
|
} |
|
) |
|
.buttonStyle(NoMovementButtonStyle()) |
|
.background { |
|
Measure<OrderedWidthsPreferenceKey> { value, geometry in |
|
value[choice] = geometry.size.width |
|
} |
|
} |
|
} |
|
|
|
var horizontalMask: some View { |
|
LinearGradient( |
|
stops: [ |
|
.init(color: .black.opacity(0), location: 0.05), |
|
.init(color: .black.opacity(1), location: 0.25), |
|
.init(color: .black.opacity(1), location: 0.75), |
|
.init(color: .black.opacity(0), location: 0.95) |
|
], |
|
startPoint: .leading, |
|
endPoint: .trailing |
|
) |
|
} |
|
|
|
var accessibilityRepresentation: some View { |
|
Picker(selection: $selection) { |
|
ForEach(choices) { choice in |
|
Text(labels(choice)) |
|
.tag(choice) |
|
} |
|
} label: { |
|
EmptyView() |
|
} |
|
} |
|
} |
|
|
|
struct NoMovementButtonStyle: ButtonStyle { |
|
func makeBody(configuration: Configuration) -> some View { |
|
configuration.label |
|
.opacity(configuration.isPressed ? 0.8 : 1) |
|
} |
|
} |
|
|
|
// MARK: - View Width Measurement |
|
|
|
public struct Measure<Key: PreferenceKey>: View { |
|
|
|
public init( |
|
_ transform: @escaping (_ value: inout Key.Value, _ geometry: GeometryProxy) -> Void |
|
) { |
|
self.transform = transform |
|
} |
|
|
|
private let transform: (inout Key.Value, GeometryProxy) -> Void |
|
|
|
public var body: some View { |
|
GeometryReader { proxy in |
|
Color.clear |
|
.transformPreference(Key.self) { transform(&$0, proxy) } |
|
} |
|
} |
|
} |
|
|
|
fileprivate struct OrderedWidthsPreferenceKey: PreferenceKey { |
|
|
|
static var defaultValue: Dictionary<AnyHashable, CGFloat> = [:] |
|
|
|
static func reduce( |
|
value: inout Dictionary<AnyHashable, CGFloat>, |
|
nextValue: () -> Dictionary<AnyHashable, CGFloat> |
|
) { |
|
value.merge(nextValue()) { $1 } |
|
} |
|
} |