Created
January 27, 2024 19:26
-
-
Save zachgibson/e80c222c995b6d080874e15e2bc6112d to your computer and use it in GitHub Desktop.
Slider With Haptic Feedback & Magnetic Pull
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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