Is DragGesture buggy or am I messing something up?

I would like an analog clock, I could set the hands (rectangle) of the clock manually, using DragGesture control. But if I want to set the hand backwards immediately after clicking, it turns 180 degrees. If I want to move it forward, it works fine, but not backwards. What could be the problem?


struct ContentView: View {
    @State private var angle: Double = 0
    
    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: 10, height: 100)
            .rotationEffect(Angle(degrees: angle), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        let vector = CGVector(dx: value.translation.width, dy: value.translation.height)
                        let radians = atan2(vector.dy, vector.dx)
                        let newAngle = radians * 180 / .pi
                        self.angle = Double(newAngle)
                }
        )
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Answered by Claude31 in 748650022

OK, I was puzzled by this issue and reevaluated it from scratch. And conclusion is quite different !

The problem was that the angle you compute was not the rotation angle of the rect (the hand).

I illustrate by a figure below.

The following code finally solves all issues (it also allows to chain multiple gestures):

struct ContentView: View {
    @State private var angle: Double = 0
    @State var currentVector = CGVector.zero
    @State var lastVector = CGVector.zero  // Keep track where we ended
    
    let handHeight = CGFloat(100)  // In case value changed later

    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: 10, height: handHeight)
            .rotationEffect(Angle(degrees: angle), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        
                        currentVector = CGVector(dx: value.translation.width + lastVector.dx, dy: value.translation.height + lastVector.dy)
                        let radians = atan2(currentVector.dy - handHeight, currentVector.dx)
                        let newAngle = 90 + radians * 180 / .pi // That is the new rotation angle for the hand
                        self.angle = Double(newAngle)
                    }
                    .onEnded { value in  // Needed to start a new Gesture
                        self.lastVector = currentVector
                        print(self.lastVector)
                    }
            )
    }
}

Geometrical explanation:

Problem comes from discontinuity of atan2.

You have to bring back radians between -π/2 and π/2.

This does it:

struct ContentView: View {
    @State private var angle: Double = 0
    
    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: 10, height: 100)
            .rotationEffect(Angle(degrees: angle), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        let vector = CGVector(dx: value.translation.width, dy: value.translation.height)
                        var radians = atan2(vector.dy, vector.dx)
                        while radians < -.pi/2 { radians += .pi }
                        while radians > .pi/2 { radians -= .pi }
                        let newAngle = radians * 180 / .pi
                        self.angle = Double(newAngle)
                }
        )
    }
}

But there is still a jump when crossing -π/2. To be solved.

This should work in all cases:

struct ContentView: View {
    @State private var angle: Double = 0
    
    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: 10, height: 100)
            .rotationEffect(Angle(degrees: angle), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        let vector = CGVector(dx: value.translation.width, dy: value.translation.height)
                        let radians = atan2(vector.dy, vector.dx)
                        /* No more needed 
                        while radians < -.pi/2 { radians += .pi }
                        while radians > .pi/2 { radians -= .pi }
                        */
                        var newAngle = radians * 180 / .pi
                        // Avoid jump when crossing -180 or 180
                        if (newAngle - self.angle > 90) || (self.angle - newAngle > 90) {
                            while newAngle > self.angle + 90 { newAngle -= 180 }
                            while newAngle < self.angle - 90 { newAngle += 180 }
                        }
                        
                        self.angle = Double(newAngle)
                }
        )
    }
}

There is no bug in Gesture, just need to be very cautious on the use of atan2…

I see, thanks. Do u have any idea how can I solve this discontinuity problem? Maybe any alternative to the atan2 function?

Accepted Answer

OK, I was puzzled by this issue and reevaluated it from scratch. And conclusion is quite different !

The problem was that the angle you compute was not the rotation angle of the rect (the hand).

I illustrate by a figure below.

The following code finally solves all issues (it also allows to chain multiple gestures):

struct ContentView: View {
    @State private var angle: Double = 0
    @State var currentVector = CGVector.zero
    @State var lastVector = CGVector.zero  // Keep track where we ended
    
    let handHeight = CGFloat(100)  // In case value changed later

    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: 10, height: handHeight)
            .rotationEffect(Angle(degrees: angle), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        
                        currentVector = CGVector(dx: value.translation.width + lastVector.dx, dy: value.translation.height + lastVector.dy)
                        let radians = atan2(currentVector.dy - handHeight, currentVector.dx)
                        let newAngle = 90 + radians * 180 / .pi // That is the new rotation angle for the hand
                        self.angle = Double(newAngle)
                    }
                    .onEnded { value in  // Needed to start a new Gesture
                        self.lastVector = currentVector
                        print(self.lastVector)
                    }
            )
    }
}

Geometrical explanation:

Thanks for feedback.

Now, you may want to have also hours hand ?

Here is a simple example on how to do it (should be improved, notably for aligning the 2 hands).

struct ContentView: View {
    @State private var angleMinutes : Double = 0
    @State private var angleHour    : Double = 0
    @State private var cumulAngleMinutes : Double = 0
    @State private var currentVector = CGVector.zero
    @State private var lastVector    = CGVector.zero  // Keep track where we ended
    @State private var handColor     = UIColor(red: 0, green: 0.866, blue: 0.866, alpha: 1) // UIColor.green

    let minutesHandHeight = CGFloat(100)  // In case value changed later
    let hourHandHeight = CGFloat(80)  // Smaller and larger than minutes

    var color: UIColor {
        let red = abs(sin(angleMinutes * .pi / 360))
        let green = abs(sin((angleMinutes + 240) * .pi / 360))
        let blue = abs(sin((angleMinutes + 120) * .pi / 360))
        return UIColor(red: red, green: green, blue: blue, alpha: 1)
    }

    var body: some View {
        Rectangle() // Hours
            .fill(Color.blue)
            .frame(width: 10, height: hourHandHeight)
            .rotationEffect(Angle(degrees: angleHour), anchor: .bottom)
            .offset(x: 0, y: hourHandHeight + 30) // 30: defaultspacing

        Rectangle() // Minutes
            .fill(Color(handColor))
            .frame(width: 8, height: minutesHandHeight)
            .rotationEffect(Angle(degrees: angleMinutes), anchor: .bottom)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        
                        currentVector = CGVector(dx: value.translation.width + lastVector.dx, dy: value.translation.height + lastVector.dy)
                        let radians = atan2(currentVector.dy - minutesHandHeight, currentVector.dx)
                        let newAngle = 90 + radians * 180 / .pi // That is the new rotation angle for the hand

                        var deltaAngle = Double(newAngle) - self.angleMinutes  // What is the increment ; it may jump 360°
                        if deltaAngle > 180 { deltaAngle -= 360 }
                        if deltaAngle < -180 { deltaAngle += 360 }
                        cumulAngleMinutes += deltaAngle
                        
                        self.angleMinutes = Double(newAngle)
                        self.angleHour = self.cumulAngleMinutes / 12 // hourHand rotates 1/12 of minutes
                        handColor = color
                    }
                    .onEnded { _ in  // Needed to start a new Gesture
                        self.lastVector = currentVector
                    }
            )
    }
}

Some explanations:

  • Only the minutes hand has a gesture. Hours angle is computed from minutes'
  • The hours rotate at 1/12 of minutes, so angleHour is computed accordingly.
  • But, minutes angle return to 0 each turn ; and hours must increment. So, you need to keep the cumulated rotation of minutes and compute hours angle from this cumulated value.
  • in addition, when minutes pass 45, angle jumps from 270 to -90. Hence the test on deltaAngle
  • for the fun, the minutes hand changes colour as it rotates…

Hope you'll find it useful as a starting point.

Is DragGesture buggy or am I messing something up?
 
 
Q