'Problem animating with animatableData in SwiftUI

SwiftUI not only gives us get automatic animation… it lets us control how the animation occurs, using the animatableData property. So cool!

Only I can't get it to work. The following code displays a clock face on the screen (numbers 0…11 🙂) and shows a toggle switch. Switching the toggle rotates the numbers by 180 degrees, animated. Or that is my intent. But instead of each number taking an animated path around the circle of numbers, each number moves along a straight line to its new position… so that the circle of numbers collapses to the circle's center point and then re-explodes into its new configuration:

enter image description here

The animatableData property is not being referenced by SwiftUI. Any idea why?

(Note that it wouldn't work to use a standard rotation animation, because that would turn all the numbers upside down.)

import SwiftUI

struct ContentView: View {
    @State var state: Bool = false

    var body: some View {
        VStack {
            CircleView(hoursOffset: CGFloat(state ? 6 : 0 ))
                .aspectRatio(contentMode: .fit)
            Toggle(isOn: $state, label: { Text("Rotate 180°?") })
                .frame(maxWidth: 200).padding()
        }
    }
}

extension CGPoint {
    static func onCircle(hours: CGFloat, size: CGFloat) -> CGPoint {
        let radians = (3 - hours) * CGFloat.pi / CGFloat (6)
        let hypotenuse = size / 2
        return CGPoint(x: size / 2 + 0.8 * hypotenuse * cos(radians),
                       y: size / 2 - 0.8 * hypotenuse * sin(radians))
    }
}

struct CircleView: View {

    var hoursOffset: CGFloat

    public var animatableData: CGFloat {
        get { hoursOffset }
        set { self.hoursOffset = newValue }
    }

    var body: some View {
        GeometryReader() { geo in
            ForEach(0..<12) { hour in
                ZStack {
                    Text("\(hour)")
                        .position(CGPoint.onCircle(hours: CGFloat(hour) + self.hoursOffset, size: geo.size.width))
                        .animation(Animation.easeInOut(duration: 2.0))
                }
            }
        }
    }
}


Solution 1:[1]

Here is possible approach. Tested with Xcode 11.4 / iOS 13.4. Just animatableData in View does not work - you have to put it in AnimatableModifier.

demo

struct CircleMoving: AnimatableModifier {
    var hoursOffset: CGFloat
    var hour: CGFloat
    var size: CGFloat

    public var animatableData: CGFloat {
        get { hoursOffset }
        set { self.hoursOffset = newValue }
    }

    func body(content: Content) -> some View {
        content
            .position(CGPoint.onCircle(hours: hour + hoursOffset, size: size))
    }
}


struct CircleView: View {

    var hoursOffset: CGFloat

    var body: some View {
        GeometryReader() { geo in
            ForEach(0..<12) { hour in
                ZStack {
                    Text("\(hour)")
                        .modifier(CircleMoving(hoursOffset: self.hoursOffset, hour: CGFloat(hour), size: geo.size.width))
                }
            }
        }
        .animation(.easeInOut(duration: 3.0), value: hoursOffset) // !!!
    }
}

Optional: also I would recommend you round point values for better integrity, however it is not much important.

extension CGPoint {
    static func onCircle(hours: CGFloat, size: CGFloat) -> CGPoint {
        let radians = (3 - hours) * CGFloat.pi / CGFloat (6)
        let hypotenuse = size / 2
        return CGPoint(x: (size / 2 + 0.8 * hypotenuse * cos(radians)).rounded(.up),
                       y: (size / 2 - 0.8 * hypotenuse * sin(radians)).rounded(.up))
    }
}

backup

Solution 2:[2]

the problem is the evil mathematics...you are calculating the endpositions. Apple does just adding steps from one position to another (as a line) so this is what you see. If you toggle from 0 to 1 it "looks" like a circular move, but it is also linear ;) the solution would be, to calculate the x,y positions manually ON the circle depending on your hours offset values

enter image description here

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2