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]
}
}
@watery-desert
Copy link

Nice, thank you so much for sharing 💛

@dmr121
Copy link
Author

dmr121 commented Jan 6, 2022

Example usage:

struct ExampleView: View {
  // @State private var selection? = nil    // edit
  @State private var selection: Int? = nil
  let options = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  
  var body: some View {
    // You can set width manually as one of the PomodoroPicker arguments if the default width isn't right for you

    PomodoroPicker(
      selection: $selection,
      options: options,
      onChange: { value in
        print("Valued changed to \(value)")
        HapticsManager.vibrate(withIntensity: .rigid)
      }) { option in
        // option will be of type Int because options is of type Array<Int>
        // option will be whatever type the options array is, it's generic
        
        // This is where each individual view goes
        Text("\(option)")
          .fontWeight(selection == option ? .bold: .regular)
      }
    }
  }
}

@watery-desert
Copy link

Thanks but since I got the source code I couldn't run it. Now you gave this example which is much appreciated but I am getting compiler time error.

@State private var selection? = nil

This line Xcode is asking to define the data type I tried to add Item? but didn't work.

@dmr121
Copy link
Author

dmr121 commented Jan 7, 2022

Oh ha! I messed that up. I typed this quickly last night. It should be:

@State private var selection: Int? = nil

// Or if you want the picker to not start at the first option, set selection's initial value to something that's in the options list
// ex.
@State private var selection: Int? = 5

@watery-desert
Copy link

Thanks, it's working now. 👍

@SangkuOh
Copy link

Awesome!
may i request vertical version or mode?

@vmorris1959
Copy link

I'm trying the ExampleView but it fails at HapticsManager.vibrate(withIntensity: .rigid). Where can I find that HapticsManager? Thanks

@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