I’m trying to create a SwiftUI view where I have a red knob that can be rotated smoothly around a blue rectangle. I’ve implemented the code below, but the knob seems to be jumping around instead of smoothly rotating like a knob would:
import SwiftUI
struct RotatingKnobView: View {
@State private var knobRotation: Double = 0.0
var body: some View {
ZStack {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(Color.blue)
Circle()
.frame(width: 40, height: 40)
.foregroundColor(Color.red)
.offset(x: 100, y: 50)
.gesture(
DragGesture()
.onChanged { value in
DispatchQueue.main.async {
withAnimation {
let translation = value.translation.width
let degrees = translation / 2 // Adjust sensitivity as needed
// Update the rotation angle state in degrees
if degrees < 0 {
knobRotation = degrees * -1
} else {
knobRotation = degrees
}
print(knobRotation)
}
}
}
)
}
.rotationEffect(Angle(degrees: knobRotation))
}
}
struct RotatingKnobView_Previews: PreviewProvider {
static var previews: some View {
RotatingKnobView()
}
}
What could be causing this behavior, and how can I make the knob rotate smoothly around the rectangle?
Your example certainly doesn’t work smoothly and it crashed when I ran it in the simulator 😢
I found that a rotation effect and a drag gesture do not always work in harmony if the drag gesture impacts the rotation. The order of modifiers appears to be very important.
Your solution works smoothly with the following changes:
- move the drag gesture to the
ZStack
- move the rotation effect to the
Rectangle
- apply the
Circle
as an overlay to theRectangle
, instead of as aZStack
layer - the asynchronous update is not necessary either.
Like this:
var body: some View {
ZStack {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(Color.blue)
.overlay(alignment: .bottomTrailing) {
Circle()
.frame(width: 40, height: 40)
.foregroundColor(Color.red)
.offset(x: 20, y: 20)
}
.rotationEffect(Angle(degrees: knobRotation))
}
.gesture(
DragGesture()
.onChanged { value in
withAnimation {
let translation = value.translation.width
let degrees = translation / 2 // Adjust sensitivity as needed
// Update the rotation angle state in degrees
if degrees < 0 {
knobRotation = degrees * -1
} else {
knobRotation = degrees
}
print(knobRotation)
}
}
)
}
It seems that you are just approximating the angle based on the horizontal drag offset. To calculate the angle precisely, see SwiftUI – Grab angle of circle on drag. Using the algorithm from the answer to that post (it was my answer), here is an adapted version of your example where the angle of rotation tracks the drag gesture more accurately:
struct RotatingKnobView: View {
@State private var previousRotation: Double = 0.0
@State private var knobRotation: Double = 0.0
private func location2Degrees(location: CGPoint, midX: CGFloat, midY: CGFloat) -> CGFloat {
let radians = location.y < midY
? atan2(location.x - midX, midY - location.y)
: .pi - atan2(location.x - midX, location.y - midY)
let degrees = (radians * 180 / .pi) - 135
return degrees < 0 ? degrees + 360 : degrees
}
private func applyRotation(width: CGFloat, height: CGFloat) -> some Gesture {
DragGesture()
.onChanged { value in
let midX = width / 2
let midY = height / 2
let startAngle = location2Degrees(location: value.startLocation, midX: midX, midY: midY)
let endAngle = location2Degrees(location: value.location, midX: midX, midY: midY)
let dAngle = endAngle - startAngle
knobRotation = previousRotation + dAngle
}
.onEnded { value in
previousRotation = knobRotation
}
}
var body: some View {
ZStack {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(Color.blue)
.overlay(alignment: .bottomTrailing) {
Circle()
.frame(width: 40, height: 40)
.foregroundColor(Color.red)
.offset(x: 20, y: 20)
}
.rotationEffect(Angle(degrees: knobRotation))
}
.gesture(applyRotation(width: 200, height: 100))
}
}