Skip to content

Instantly share code, notes, and snippets.

@ts95
Last active December 29, 2023 06:59
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save ts95/9f8e05380824c6ca999ab3bc1ff8541f to your computer and use it in GitHub Desktop.
Save ts95/9f8e05380824c6ca999ab3bc1ff8541f to your computer and use it in GitHub Desktop.
Dial component in SwiftUI
import SwiftUI
struct Dial: View {
@Binding public var value: Double
public var minValue: Double = 0
public var maxValue: Double = .greatestFiniteMagnitude
public var divisor: Double = 1
public var stepping: Double = 1
@State private var dialAngle: Angle = .zero
@State private var dialShadowAngle: Angle = .zero
@State private var dialReleaseAngle: Angle = .zero
@State private var dialStartAngle: Angle = .zero
@State private var isDialRotating: Bool = false
@State private var dialRevolutions: Int = 0
var adjustedDivisor: Double {
divisor > 0 ? divisor : 1
}
var adjustedStepping: Double {
stepping > 0 ? stepping : 1
}
var adjustedMinValue: Double {
(minValue * adjustedDivisor) / adjustedStepping
}
var adjustedMaxValue: Double {
(maxValue * adjustedDivisor) / adjustedStepping
}
var metallicGradient: AngularGradient {
let spectrum = [
Color(UIColor.systemGray2),
Color(UIColor.systemGray3),
Color(UIColor.systemGray4),
Color(UIColor.systemGray5),
Color(UIColor.systemGray4),
Color(UIColor.systemGray3),
Color(UIColor.systemGray2),
Color(UIColor.systemGray3),
Color(UIColor.systemGray4),
Color(UIColor.systemGray5),
Color(UIColor.systemGray4),
Color(UIColor.systemGray3),
Color(UIColor.systemGray2),
]
return AngularGradient(
gradient: Gradient(colors: spectrum),
center: .center,
angle: .degrees(45)
)
}
var body: some View {
GeometryReader { geometry in
ZStack() {
Circle()
.fill(metallicGradient)
.rotationEffect(.init(degrees: 90), anchor: .center)
.shadow(color: Color(UIColor.systemGray2), radius: 24)
Circle()
.fill(metallicGradient)
.scaleEffect(0.95, anchor: .center)
}
.rotationEffect(dialAngle)
.gesture(rotationDragGesture(geometry: geometry))
}
}
private func rotationDragGesture(geometry: GeometryProxy) -> some Gesture {
let frame = geometry.frame(in: .local)
let center = CGPoint(x: frame.midX, y: frame.midY)
return DragGesture()
.onChanged { value in
if !isDialRotating {
isDialRotating = true
dialStartAngle = rotationAngle(of: value.startLocation, around: center)
}
let dialCurrentAngle = rotationAngle(of: value.location, around: center)
let dragAngleDelta = dialCurrentAngle - dialStartAngle
let newDialAngle = dialReleaseAngle + dragAngleDelta
let dialAngleDelta = newDialAngle - dialAngle
let prevDialAngle = dialAngle
// This is the actual angle of the dial that's drawn on the screen.
dialAngle += dialAngleDelta
// This is the angle that's used to calculate self.value. If the dial
// is turned past minValue or maxValue and then back, this angle will
// start to diverge from dialAngle. This is so that the dial on the screen
// can continue to rotate past minValue or maxValue while dialShadowValue
// doesn't change (i.e. remains constant). If dialValue didn't change,
// the dial wouldn't be able to rotate freely past minValue or maxValue.
dialShadowAngle += dialAngleDelta
if abs(dialAngle - prevDialAngle) > Angle(degrees: 360) - abs(dragAngleDelta) {
let offset = dragAngleDelta.radians <= 0 ? 1 : -1
dialRevolutions += offset
}
let totalDegrees = (Double(dialRevolutions) * 360) + dialShadowAngle.degrees
self.value = min(adjustedMaxValue, max(adjustedMinValue, floor(totalDegrees / adjustedDivisor) * adjustedStepping))
if totalDegrees <= adjustedMinValue {
dialRevolutions = Int(adjustedMinValue / 360)
dialShadowAngle = .degrees(adjustedMinValue.truncatingRemainder(dividingBy: 360))
} else if totalDegrees >= adjustedMaxValue {
dialRevolutions = Int(adjustedMaxValue / 360)
dialShadowAngle = .degrees(adjustedMaxValue.truncatingRemainder(dividingBy: 360))
}
}
.onEnded { _ in
dialReleaseAngle = dialAngle
isDialRotating = false
}
}
private func abs(_ angle: Angle) -> Angle {
.radians(Swift.abs(angle.radians))
}
private func rotationAngle(of point: CGPoint, around center: CGPoint) -> Angle {
let deltaY = point.y - center.y
let deltaX = point.x - center.x
return Angle(radians: Double(atan2(deltaY, deltaX)))
}
}
struct Dial_Previews: PreviewProvider {
static var previews: some View {
Dial(value: .constant(0))
.frame(width: 250)
.padding(.all, 24)
}
}
@ts95
Copy link
Author

ts95 commented Jul 17, 2020

image

@SadOldGoth
Copy link

Hi Toni,

That looks really sweet, and ideal for the project I'm working on! I'm trying to put a textbox in the middle of it, to show the value, but can't seem to get it to work without rotating along with the rest of the dial - any thoughts? Thanks in advance,

Jes

@mahimranjan
Copy link

This is a fantastic UI component. Thank you for this.

@toni: If you want to add text in the middle, wrap the whole thing in a ZStack and add a text component.

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