Skip to content

Instantly share code, notes, and snippets.

@dmr121
Last active July 22, 2024 17:32
Show Gist options
  • Save dmr121/b765f44dcb98af346b20d72990fb3fec to your computer and use it in GitHub Desktop.
Save dmr121/b765f44dcb98af346b20d72990fb3fec to your computer and use it in GitHub Desktop.
SwiftUI - Snapping horizontal scrolling pomodoro picker
//
// 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]
}
}
@nkanellopoulos
Copy link

nkanellopoulos commented Jun 30, 2022

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.

@hedgein
Copy link

hedgein commented Jul 8, 2022

Super awesome write up, thanks for sharing! Customized it a bit to my needs and added arrow buttons. Everything works like a charm!

@wm-alex
Copy link

wm-alex commented Jan 12, 2024

I'm also interested in a vertical one!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment