Skip to content

Instantly share code, notes, and snippets.

@zachgibson
Created January 27, 2024 19:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zachgibson/e80c222c995b6d080874e15e2bc6112d to your computer and use it in GitHub Desktop.
Save zachgibson/e80c222c995b6d080874e15e2bc6112d to your computer and use it in GitHub Desktop.
Slider With Haptic Feedback & Magnetic Pull
import SwiftUI
struct ContentView: View {
let handleSize = 40.0
@State private var x = 0.0
@State private var xOffset = 0.0
@State private var sliderWidth = 0.0
@State private var previousInterpolatedX = 0.0
private var sliderMidPoint: Double {
(self.sliderWidth - self.handleSize) / 2
}
private var interpolatedX: Double {
let magneticThreshold = self.handleSize / 2
let inputRange: [Double] = [
0,
self.sliderMidPoint - magneticThreshold,
self.sliderMidPoint,
self.sliderMidPoint + magneticThreshold,
self.sliderWidth - self.handleSize
]
let outputRange: [Double] = [-100, 0, 0, 0, 100]
return interpolate(input: self.x + self.xOffset, inputRange: inputRange, outputRange: outputRange) ?? 0
}
private var signChanged: Bool {
checkHasCollidedWithDefaultValue(previousValue: self.previousInterpolatedX, currentValue: self.interpolatedX)
}
var body: some View {
VStack {
Text("\(self.interpolatedX)")
Rectangle()
.foregroundStyle(Color.gray)
.frame(height: 2)
.clipShape(Capsule())
.frame(height: self.handleSize)
.overlay {
Rectangle()
.frame(width: 1, height: self.handleSize + 8)
.foregroundStyle(Color.red)
}
.overlay(alignment: .leading) {
Circle()
.frame(width: self.handleSize)
.offset(x: self.x + self.xOffset)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
self.xOffset = max(-self.x, min(self.sliderWidth - self.handleSize - self.x, value.translation.width))
}
.onEnded { value in
if self.interpolatedX == 0 {
withAnimation(.bouncy) {
self.x = self.sliderMidPoint
self.xOffset = 0
}
} else {
self.x += self.xOffset
self.xOffset = 0
}
}
)
}
.overlay {
GeometryReader { geometryProxy in
Color.clear
.onAppear {
self.sliderWidth = geometryProxy.size.width
self.x = self.sliderMidPoint
}
// .onChange(of: geometryProxy.size) { oldValue, newValue in
// let ratio = newValue.width / oldValue.width
// self.sliderWidth = newValue.width
// }
}
}
}
.padding(24)
.onChange(of: self.interpolatedX) { oldValue, newValue in
self.previousInterpolatedX = oldValue
}
.sensoryFeedback(.impact, trigger: self.signChanged) { oldValue, newValue in
newValue == true
}
}
}
#Preview {
ContentView()
}
func interpolate<T: FloatingPoint>(input: T, inputRange: [T], outputRange: [T]) -> T? {
guard inputRange.count == outputRange.count, inputRange.count > 1 else {
print("Input and output ranges must have the same count and be more than 1")
return nil
}
// Find the segment in the input range that contains the input value
for i in 1..<inputRange.count {
if (inputRange[i - 1] <= input && input <= inputRange[i]) ||
(inputRange[i - 1] >= input && input >= inputRange[i]) {
let inputStart = inputRange[i - 1]
let inputEnd = inputRange[i]
let outputStart = outputRange[i - 1]
let outputEnd = outputRange[i]
// Perform the linear interpolation
let ratio = (input - inputStart) / (inputEnd - inputStart)
return outputStart + ratio * (outputEnd - outputStart)
}
}
// If input is outside the range, return nil
return nil
}
func checkHasCollidedWithDefaultValue(previousValue: Double, currentValue: Double) -> Bool {
currentValue == 0 || previousValue.sign != currentValue.sign
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment