Skip to content

Instantly share code, notes, and snippets.

@dmr121
Last active April 7, 2024 22:00
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • 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]
}
}
@dmr121
Copy link
Author

dmr121 commented May 31, 2022

@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)
  }
}

@dmr121
Copy link
Author

dmr121 commented May 31, 2022

Awesome! may i request vertical version or mode?

@SangkuOh Is this still something you'd like?

@kengura3
Copy link

How I can scroll to the first item programmatically? I changed selection variable but nothing happened :-(

@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