cdeerinck
cdeerinck

Reputation: 796

SwiftUI Animated Shape going crazy

First off, my appologies for the unconventional title of this post, but I don't know of any way to describe the behavior any better than that.

To reproduce this problem, create a new project in xCode 12 specifying IOS App with any name and **Interface:**SwiftUI, Life Cycle: SwiftUI App, Language: Swift. Then replace all of Content View with the code listed here.

In either Live Preview, or when running the app, a click on the Poly will trigger the animation. It should move a fraction of a pixel, and do a 1/3 turn (as the angle is in radians). The problem, as noted on line 37 is that when I try to move or turn the Poly, it goes crazy, multiplying any move by much greater amounts. The color animates fine, but the Animatable properties in the shape do not. The location starts at 200,200 with an angle of 0, and if you try to move it very close, as the sample code does, it overreacts. If you try to move it only a few pixes (say 190,190) it will fly off the screen.

I have not had this happen to any other animations I have done, and have no idea why this is behaving this way.

In trying to debug this, at one point I put print statements on the animatableData getters and setters, and can make no sense of what the animation engine is doing to the variables. It just seems to pick a number that is much further from the source that the value I am asking it to go to.

I am confident that the trig in the path is correct, and suspect the issue lies in one of the following:

I am running Xcode 12.1 (12A7403) and Swift 5. After many hours of trying to figure this out, I humbly present my problem here. Help me Obiwan Kenobi, you are my only hope...

enter image description here

import SwiftUI

let twoPi:CGFloat = CGFloat.pi * 2
let pi = CGFloat.pi

class Poly: ObservableObject, Identifiable {

    @Published var location:CGPoint
    @Published var color:Color

    var sides:CGFloat
    var vertexRadius:CGFloat
    var angle:CGFloat

    init(at:CGPoint, color:Color, sides:CGFloat, radius:CGFloat, angle:CGFloat=0) {
        self.location = at
        self.color = color
        self.sides = sides
        self.vertexRadius = radius
        self.angle = angle
    }
}

struct ContentView: View {

    @ObservedObject var poly:Poly = Poly(at: CGPoint(x:200,y:200), color: .green, sides: 6, radius: 100)
    
    var body: some View {

        PolyShape(poly: poly)
            .fill(poly.color)
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onEnded { gesture in
                        withAnimation(.easeInOut(duration:15)) {
                            //This is what doesn't work.
                            //Try to nudge it a fraction of a pixel, and do only 1/3 of a turn, and it spins and moves much further.
                            poly.location.x = 200.4
                            poly.location.y = 200.2
                            poly.angle = twoPi / 3
                            poly.color = .red
                        }
                    }
            )
    }
}

struct ContentView_Previews: PreviewProvider {

    static var previews: some View {
        Group {
            ContentView(poly: Poly(at: CGPoint(x:200,y:200), color: .blue, sides: 3, radius: 100))
        }
    }
}

struct PolyShape:Shape {

    var poly:Poly

    public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat,CGFloat>>  {
        get { AnimatablePair(poly.angle, AnimatablePair(poly.location.x, poly.location.y))
        }
        set {
            poly.angle = newValue.first
            poly.location.x = newValue.second.first
            poly.location.y = newValue.second.second
        }
    }

    func path(in rect: CGRect) -> Path {

        var path = Path()
        var radial:CGFloat = 0.5

        while radial < twoPi + 0.5 {
            let radialAngle = twoPi / poly.sides * radial + poly.angle
            let newX = poly.location.x + cos(radialAngle) * poly.vertexRadius
            let newY = poly.location.y + sin(radialAngle) * poly.vertexRadius
            if radial == 0.5 {
                path.move(to: CGPoint(x: newX, y: newY))
            } else {
                path.addLine(to: CGPoint(x: newX, y: newY))
            }
            radial += 1
        }
        return path
    }
}

Upvotes: 2

Views: 1331

Answers (2)

Asperi
Asperi

Reputation: 258117

You need to separate model from view model, because to have PolyShape correctly work in your case the input data have to be a value.

Here is tested solution (Xcode 12 / iOS 14)

demo

  1. Separate model and view model
class Poly: ObservableObject, Identifiable {
    @Published var data:PolyData

    init(data: PolyData) {
        self.data = data
    }
}

struct PolyData {
    var location:CGPoint
    var color:Color

    var sides:CGFloat
    var vertexRadius:CGFloat
    var angle:CGFloat

    init(at:CGPoint, color:Color, sides:CGFloat, radius:CGFloat, angle:CGFloat=0) {
        self.location = at
        self.color = color
        self.sides = sides
        self.vertexRadius = radius
        self.angle = angle
    }
}
  1. Make shape value dependent
struct PolyShape:Shape {

    var poly:PolyData      // << here !!

    // ... other code no changes
}
  1. Update dependent demo
struct ContentView: View {

    @ObservedObject var poly:Poly = Poly(data: PolyData(at: CGPoint(x:200,y:200), color: .green, sides: 6, radius: 100))
    
    var body: some View {

        PolyShape(poly: poly.data)     // << pass data only here !!
            .fill(poly.data.color)
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onEnded { gesture in
                        withAnimation(.easeInOut(duration:15)) {
                            poly.data.location = CGPoint(x: 200.4, y: 200.2)
                            poly.data.angle = twoPi / 3
                            poly.data.color = .red
                        }
                    }
            )
    }
}

struct ContentView_Previews: PreviewProvider {

    static var previews: some View {
        Group {
            ContentView(poly: Poly(data: PolyData(at: CGPoint(x:200,y:200), color: .blue, sides: 3, radius: 100)))
        }
    }
}

Upvotes: 1

Bill
Bill

Reputation: 469

Can you give the following a try?

import SwiftUI

let twoPi:CGFloat = CGFloat.pi * 2
let pi = CGFloat.pi

struct ContentView: View {
    @State var location: CGPoint = CGPoint(x: 200, y: 200)
    @State var color: Color = .blue
    @State var angle: CGFloat = 0

    var body: some View {
        PolyShape(location: location, color: color, angle: angle, sides: 3, vertexRadius: 100)
            .fill(color)
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onEnded { gesture in
                        withAnimation(.easeInOut(duration:1)) {
                            //This is what doesn't work.
                            //Try to nudge it a fraction of a pixel, and do only 1/3 of a turn, and it spins and moves much further.
                            location.x = 220
                            location.y = 220
                            angle = (CGFloat.pi * 2) / 3
                            color = .red
                        }
                    }
            )
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView(location: CGPoint(x: 200, y: 200), color: .blue, angle: 0)
        }
    }
}

struct PolyShape:Shape {
    var location: CGPoint
    var color: Color
    var angle: CGFloat
    var sides: CGFloat
    var vertexRadius: CGFloat

    public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat,CGFloat>>  {
        get {
            return AnimatablePair(angle, AnimatablePair(location.x, location.y))
        }
        set {
            angle = newValue.first
            location.x = newValue.second.first
            location.y = newValue.second.second
        }
    }

    func path(in rect: CGRect) -> Path {
        var path = Path()
        var radial:CGFloat = 0.5
        while radial < twoPi + 0.5 {
            let radialAngle = twoPi / sides * radial + angle
            let newX = location.x + cos(radialAngle) * vertexRadius
            let newY = location.y + sin(radialAngle) * vertexRadius
            if radial == 0.5 {
                path.move(to: CGPoint(x: newX, y: newY))
            } else {
                path.addLine(to: CGPoint(x: newX, y: newY))
            }
            radial += 1
        }
        return path
    }
}

Upvotes: 0

Related Questions