4

I have an app that shows a bunch of people who each have an origin and angle.

struct Location {
    var centre:CGPoint
    var facing:Angle
}

SwiftUI magically and automatically does a lot of the animation as they move from location A to location B

withAnimation {
    person.location = newLocation
}

However - for the Angle (facing) property, I want the animation to go in the shortest route (bearing in mind that in the real world - angles wrap around).

e.g. Swift UI correctly animates when the angle changes 5 -> 10 (degrees)

5,6,7,8,9,10

but going from 2 to 358, it takes the long way around

SwiftUI does 2,3,4,5,6,7.......,357,358

where I would like it to do

2,1,0,359,358

how can I go about this?

thank you

update: I'm hoping for a solution which allows me to work with the animation system, perhaps using a new MyAngle struct which provides the animation steps directly, perhaps using some kind of animation modifier. .easeInOut modifies the steps - is there an equivalent approach where I can create a .goTheRightWay animation?

Confused Vorlon
  • 9,659
  • 3
  • 46
  • 49

4 Answers4

3

Ok - Posting my own answer. It works a bit like @Ben's answer - but moves the 'shadow angle' management to the rotation effect.

All you have to do is switch rotationEffect(angle:Angle) for shortRotationEffect(angle:Angle,id:UUID)

this looks like

        @State private var rotationStorage = RotationStorage()

        //and then in body
        Image(systemName: "person.fill").resizable()
            .frame(width: 50, height: 50)
            .shortRotationEffect(self.person.angle,id:person.id,storage:rotationStorage)
            .animation(.easeInOut)

the ShortRotationEffect uses the provided id to maintain a dictionary of previous angles. When you set a new angle, it figures out the equivalent angle which provides a short rotation and applies that with a normal rotationEffect(...)

Here it is:

class RotationStorage {
    private var storage: [UUID: Angle] = [:]
    
    fileprivate func setAngle(id:UUID,angle:Angle) {
        storage[id] = angle
    }
    
    fileprivate func getAngle(_ id:UUID) -> Angle? {
        return storage[id]
    }
}

extension View {

    /// Like RotationEffect - but when animated, the rotation moves in the shortest direction.
    /// - Parameters:
    ///   - angle: new angle
    ///   - anchor: anchor point
    ///   - id: unique id for the item being displayed. This is used as a key to maintain the rotation history and figure out the right direction to move
    func shortRotationEffect(_ angle: Angle,
                             anchor: UnitPoint = .center,
                             id: UUID,
                             storage:RotationStorage) -> some View {
        
        modifier(ShortRotation(angle: angle,
                               anchor: anchor,
                               id: id,
                               storage:storage))
    }
}

struct ShortRotation: ViewModifier {
    
    var angle: Angle
    var anchor: UnitPoint
    var id: UUID
    let storage:RotationStorage
    

    func getAngle() -> Angle {
        var newAngle = angle

        if let lastAngle = storage.getAngle(id) {
            let change: Double = (newAngle.degrees - lastAngle.degrees) %% 360.double

            if change < 180 {
                newAngle = lastAngle + Angle.init(degrees: change)
            } else {
                newAngle = lastAngle + Angle.init(degrees: change - 360)
            }
        }

        storage.setAngle(id: id, angle: newAngle)

        return newAngle
    }


    func body(content: Content) -> some View {
        content
            .rotationEffect(getAngle(), anchor: anchor)
    }
}

this relies on my positive modulus function:

public extension Double {
    
    /// Returns modulus, but forces it to be positive
    /// - Parameters:
    ///   - left: number
    ///   - right: modulus
    /// - Returns: positive modulus
    static  func %% (_ left: Double, _ right: Double) -> Double {
        let truncatingRemainder = left.truncatingRemainder(dividingBy: right)
        return truncatingRemainder >= 0 ? truncatingRemainder : truncatingRemainder+abs(right)
    }
}
Confused Vorlon
  • 9,659
  • 3
  • 46
  • 49
  • This is better, but doesn't quite work. When I rotate in a complete circle, it still jumps at the end of the 360 deg rotation. This does solve the jump that was happening between 0 and 360, though. – Dominic Holmes Jun 10 '20 at 22:42
  • @DominicHolmes - not sure why you're seeing that. I have been using this in production for years now without any jumps... – Confused Vorlon Dec 09 '22 at 19:19
1

How about adjusting the newLocation value to keep within 180˚ of the start? Here's a function to check if the distance animated is greater than half way around and provide a new endpoint that satisfies it.

func adjustedEnd(from start: CGFloat, to target: CGFloat) -> CGFloat {

    // Shift end to be greater than start
    var end = target
    while end < start { end += 360 }

    // Mod the distance with 360, shifting by 180 to keep on the same side of a circle
    return (end - start + 180).truncatingRemainder(dividingBy: 360) - 180 + start
}

Some sample test cases:

let startValues: [CGFloat] = [2, -10, 345, 365, 700]
let endValues: [CGFloat] = [2, 10, 180, 185, 350, -10, 715, -700]
for start in startValues {
    print("From \(start):")
    for end in endValues {
        let adjusted = adjustedEnd(from: start, to: end)
        print("\t\(end) \tbecomes \(adjusted);\tdistance \(abs(adjusted - start))")
    }
}

prints the following:

From 2.0:
    2.0     becomes 2.0;    distance 0.0
    10.0    becomes 10.0;   distance 8.0
    180.0   becomes 180.0;  distance 178.0
    185.0   becomes -175.0; distance 177.0
    350.0   becomes -10.0;  distance 12.0
    -10.0   becomes -10.0;  distance 12.0
    715.0   becomes -5.0;   distance 7.0
    -700.0  becomes 20.0;   distance 18.0
From -10.0:
    2.0     becomes 2.0;    distance 12.0
    10.0    becomes 10.0;   distance 20.0
    180.0   becomes -180.0; distance 170.0
    185.0   becomes -175.0; distance 165.0
    350.0   becomes -10.0;  distance 0.0
    -10.0   becomes -10.0;  distance 0.0
    715.0   becomes -5.0;   distance 5.0
    -700.0  becomes 20.0;   distance 30.0
From 345.0:
    2.0     becomes 362.0;  distance 17.0
    10.0    becomes 370.0;  distance 25.0
    180.0   becomes 180.0;  distance 165.0
    185.0   becomes 185.0;  distance 160.0
    350.0   becomes 350.0;  distance 5.0
    -10.0   becomes 350.0;  distance 5.0
    715.0   becomes 355.0;  distance 10.0
    -700.0  becomes 380.0;  distance 35.0
From 365.0:
    2.0     becomes 362.0;  distance 3.0
    10.0    becomes 370.0;  distance 5.0
    180.0   becomes 540.0;  distance 175.0
    185.0   becomes 185.0;  distance 180.0
    350.0   becomes 350.0;  distance 15.0
    -10.0   becomes 350.0;  distance 15.0
    715.0   becomes 355.0;  distance 10.0
    -700.0  becomes 380.0;  distance 15.0
From 700.0:
    2.0     becomes 722.0;  distance 22.0
    10.0    becomes 730.0;  distance 30.0
    180.0   becomes 540.0;  distance 160.0
    185.0   becomes 545.0;  distance 155.0
    350.0   becomes 710.0;  distance 10.0
    -10.0   becomes 710.0;  distance 10.0
    715.0   becomes 715.0;  distance 15.0
    -700.0  becomes 740.0;  distance 40.0

(Edited to account for negative ending values)

Edit: From your comment about keeping a second value around, what about setting Location.facing to the adjusted angle, and then adding to Location something like

var prettyFacing: Angle {
    var facing = self.facing
    while facing.degrees < 0 { facing += Angle(degrees: 360) }
    while facing.degrees > 360 { facing -= Angle(degrees: 360) }
    return facing
}
Ben
  • 425
  • 6
  • 11
  • thanks for chiming in. I have considered that approach - but it requires maintaining a set of shadow locations which have to be adjusted as you change the actual location. I'm hoping for a way that I can change the animation logic directly. Somewhere in the animation system - there must something which is figuring out the steps between A and B... – Confused Vorlon Jan 30 '20 at 18:29
  • Ah I see @ConfusedVorlon. Does the person model need to keep an angle value between 0 and 360? What if the location.facing was set to this adjusted value, and then you added a computed variable to `Location`, something like `prettyFacing` that adjusts `facing` back to non-negative if necessary? – Ben Feb 01 '20 at 12:43
  • Answer edited to with example. Still not helpful about the animation question I realize, just thought maybe it could be helpful! – Ben Feb 01 '20 at 12:52
0

After trying both of the other options, we were still getting visual glitches (less common, but still there!).

Our Solution: Use UIKit for Animation

We've created a SPM package that adds a simple modifier, .uiRotationEffect(). This modifier wraps your View in a UIView, and uses UIView's .animate(...) function to get the correct behavior.

You can install the package here or you can just copy and paste the code here, it's not very long.

GIF of the working solution:

enter image description here

Dominic Holmes
  • 581
  • 5
  • 13
  • I think this is a great solution and works well! However, when used within a Geometry Reader, it appears the anchor point is off center. Any suggestions on how to fix that? – RRR Jul 01 '23 at 12:44
0

withAnimation in SwiftUI

The above method won't work if using withAnimation in SwiftUI. I managed to find a workaround as follows, to avoid animation 'pop back' when crossing the 360/0 limit, we set the value of the angle to 0 (or 360) directly to avoid animating, then the next animation will just fix the behaviour:

Super greedy, but it works, and the cut is not really noticeable

        private func onDragGesture(value: DragGesture.Value, centerX: Double, centerY: Double) {
        let current = value.location
        
        // calculate difference in angle based on gesture
        var theta = (atan2(current.x - centerX, centerY - current.y) - atan2(value.startLocation.x - centerX, centerY - value.startLocation.y)) * 180.0 / Double.pi
        
        if (theta < 0) { theta += 360 }
        if(self.angle > 330 && self.lastAngle + theta < 30) {
            self.angle = 0
        } else if (self.angle < 30 && self.lastAngle + theta > 330) {
            self.angle = 360
        }else{
            withAnimation{
                self.angle = theta + self.lastAngle
            }
        }
    }
Luca Pasini
  • 141
  • 8