-
-
Save dmr121/b765f44dcb98af346b20d72990fb3fec to your computer and use it in GitHub Desktop.
// | |
// PomodoroPicker.swift | |
// pomodoro | |
// | |
// Created by David Rozmajzl on 1/1/22. | |
// | |
import SwiftUI | |
struct PomodoroPicker<Content, Item: Hashable>: View where Content: View { | |
@State private var persistentOffset: CGFloat = 0 | |
@State private var offset: CGFloat = 0 | |
@Binding var selection: Item? | |
let options: [Item] | |
let itemWidth: CGFloat? | |
let onChange: ((Item?) -> ())? | |
let content: (Item) -> Content | |
func itemWidthOverride(_ geometry: GeometryProxy) -> CGFloat { | |
return itemWidth ?? geometry.size.width * 0.15 | |
} | |
func width(_ geometry: GeometryProxy) -> CGFloat { | |
return geometry.size.width | |
} | |
init(selection: Binding<Item?>, options: [Item], width itemWidth: CGFloat? = nil, onChange: ((Item?) -> ())? = nil, _ content: @escaping (Item) -> Content) { | |
_selection = selection | |
self.options = options | |
self.itemWidth = itemWidth | |
self.onChange = onChange | |
self.content = content | |
} | |
var body: some View { | |
GeometryReader { geometry in | |
Picker(geometry) | |
} | |
.onChange(of: selection) { onChange?($0) } | |
} | |
} | |
extension PomodoroPicker { | |
@ViewBuilder | |
private func Picker(_ geometry: GeometryProxy) -> some View { | |
Group { | |
HStack (spacing: 0) { | |
Spacer() | |
.frame(width: geometry.size.width / 2 - itemWidthOverride(geometry) / 2) | |
ForEach (options, id: \.self) { option in | |
GeometryReader { geo in | |
HStack (spacing: 0) { | |
let relativeX = geo.frame(in: .named("pomodoroPicker")).midX | |
let ratio: Double = (geometry.size.width / 2 - relativeX) / (geometry.size.width / 2) | |
let angle = Angle(degrees: Double(90) * -ratio) | |
let scale = 1 - abs(ratio) <= 0 ? 0.001: 1 - abs(ratio) / 1 | |
Group { | |
if scale == 0.001 { | |
content(option) | |
.hidden() | |
} else { | |
content(option) | |
.scaleEffect(scale) | |
} | |
} | |
.position(x: geo.size.width / 2, y: geo.size.height / 2) | |
.rotation3DEffect(angle, axis: (x: 0, y: 1, z: 0)) | |
} | |
} | |
.frame(width: itemWidthOverride(geometry)) | |
.onTapGesture { onTapped(option, geometry) } | |
} | |
.onAppear { | |
if let selection = selection, let index = options.firstIndex(of: selection) { | |
persistentOffset = -CGFloat(index) * itemWidthOverride(geometry) | |
} else if let first = options.first { | |
selection = first | |
} else { | |
selection = nil | |
} | |
} | |
Spacer() | |
.frame(width: geometry.size.width / 2 - itemWidthOverride(geometry) / 2) | |
} | |
.contentShape(Rectangle()) | |
.offset(x: persistentOffset + offset, y: 0) | |
.coordinateSpace(name: "pomodoroPicker") | |
} | |
.gesture( | |
DragGesture() | |
.onChanged{ onDragChanged($0, geometry) } | |
.onEnded{ onDragEnded($0, geometry) } | |
) | |
} | |
} | |
extension PomodoroPicker { | |
private func onTapped(_ option: Item, _ geometry: GeometryProxy) { | |
guard let index = options.firstIndex(of: option) else { return } | |
withAnimation (.spring()) { | |
self.persistentOffset = CGFloat(index) * itemWidthOverride(geometry) * -1 | |
self.offset = 0 | |
} | |
selection = option | |
} | |
private func onDragChanged(_ drag: DragGesture.Value, _ geometry: GeometryProxy) { | |
let totalOffset = persistentOffset + drag.translation.width | |
var newOffset: CGFloat! | |
var calculatedIndex: Int! | |
let length = CGFloat(options.count - 1) * itemWidthOverride(geometry) | |
if totalOffset > 0 { | |
let offsetToEdge = drag.translation.width - abs(totalOffset) | |
newOffset = offsetToEdge + totalOffset / 2.5 | |
calculatedIndex = 0 | |
} else if totalOffset < -length { | |
let offsetOffEdge = totalOffset + length | |
let offsetToEdge = drag.translation.width - offsetOffEdge | |
newOffset = offsetToEdge + offsetOffEdge / 2.5 | |
calculatedIndex = options.count - 1 | |
} else { | |
newOffset = drag.translation.width | |
calculatedIndex = Int(round(abs(totalOffset / itemWidthOverride(geometry)))) | |
} | |
guard calculatedIndex >= 0 && calculatedIndex < options.count else { | |
selection = nil | |
return | |
} | |
withAnimation (.easeOut(duration: 0.15)) { | |
self.offset = newOffset | |
} | |
selection = options[calculatedIndex] | |
} | |
private func onDragEnded(_ drag: DragGesture.Value, _ geometry: GeometryProxy) { | |
let totalOffset = persistentOffset + drag.translation.width | |
var calculatedIndex: Int! | |
let length = CGFloat(options.count - 1) * itemWidthOverride(geometry) | |
if totalOffset > 0 { | |
calculatedIndex = 0 | |
} else if totalOffset < -length { | |
calculatedIndex = options.count - 1 | |
} else { | |
calculatedIndex = Int(round(abs(totalOffset / itemWidthOverride(geometry)))) | |
} | |
guard calculatedIndex >= 0 && calculatedIndex < options.count else { | |
selection = nil | |
return | |
} | |
withAnimation (.spring()) { | |
self.persistentOffset = CGFloat(calculatedIndex) * itemWidthOverride(geometry) * -1 | |
self.offset = 0 | |
} | |
selection = options[calculatedIndex] | |
} | |
} |
@vmorris1959 The haptics manager is used for causing a vibration whenever the value changes. It's not necessary but here's the code if you'd still like to use it:
import UIKit
class HapticsManager {
static func vibrate(withIntensity intensity: UIImpactFeedbackGenerator.FeedbackStyle = .medium) {
let generator = UIImpactFeedbackGenerator(style: intensity)
generator.impactOccurred()
}
static func vibrate(withIntensity intensity: CGFloat = 0.5) {
let generator = UIImpactFeedbackGenerator()
generator.impactOccurred(intensity: intensity)
}
static func vibrate(withFeedback feedback: UINotificationFeedbackGenerator.FeedbackType = .warning) {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(feedback)
}
}
Awesome! may i request vertical version or mode?
@SangkuOh Is this still something you'd like?
How I can scroll to the first item programmatically? I changed selection variable but nothing happened :-(
This is very cool!
However if we put two of them in an HStack, they get confused.
You drag over the first, and the second one moves.
@dmr121 can you think of a fix?
Tried changing the CoordinateSpace name to be unique, but it did not help.
Note: I am setting the item width to X, and the picker frame width to 3X, to show exactly 3 items.
Super awesome write up, thanks for sharing! Customized it a bit to my needs and added arrow buttons. Everything works like a charm!
I'm also interested in a vertical one!
I'm trying the ExampleView but it fails at HapticsManager.vibrate(withIntensity: .rigid). Where can I find that HapticsManager? Thanks