Why is my SwiftUI knob jumping around instead of smoothly rotating?

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 the Rectangle, instead of as a ZStack 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))
    }
}

Leave a Comment