Search code examples
swiftuicadisplaylinkswiftui-animation

How to animate a view in a circular motion using its real-time position coordinates?


I'm currently working on a SwiftUI project, and in order to detect intersections/collisions, I need real-time coordinates, which SwiftUI animations cannot offer. After doing some research, I came across a wonderful question by Kike regarding how to get the real-time coordinates of a view when it is moving/transitioning. And Pylyp Dukhov's answer to that topic recommended utilizing CADisplayLink to calculate the position for each frame and provided a workable solution that did return the real time values when transitioning. But I'm so unfamiliar with CADisplayLink and creating custom animations that I'm not sure I'll be able to bend it to function the way I want it to.

So this is the animation I want to achieve using CADisplayLink that animates the orange circle view in a circular motion using its position coordinates and repeats forever:

Here is the SwiftUI code:

struct CircleView: View {
    @Binding var moveClockwise: Bool
    @Binding var duration: Double  // Works as speed, since it repeats forever
    let geo: GeometryProxy
    var body: some View {
        ZStack {
            Circle()
                .stroke()
                .frame(width: geo.size.width, height: geo.size.width, alignment: .center)
            
            //MARK: - What I have with SwiftUI animation
            Circle()
                .fill(.orange)
                .frame(width: 35, height: 35, alignment: .center)
                .offset(x: -CGFloat(geo.size.width / 2))
                .rotationEffect(.degrees(moveClockwise ? 360 : 0))
                .animation(
                    .linear(duration: duration)
                    .repeatForever(autoreverses: false), value: moveClockwise
                )
            //MARK: - What I need with CADisplayLink
//            Circle()
//                .fill(.orange)
//                .frame(width: 35, height: 35, alignment: .center)
//                .position(CGPoint(x: pos.realTimeX, y: realTimeY))
            
            Button("Start Clockwise") {
                moveClockwise = true
//                pos.startMovement
            }.foregroundColor(.orange)
        }.fixedSize()
    }
}
struct ContentView: View {
    @State private var moveClockwise = false
    @State private var duration = 2.0 // Works as speed, since it repeats forever
    var body: some View {
        VStack {
            GeometryReader { geo in
                CircleView(moveClockwise: $moveClockwise, duration: $duration, geo: geo)
            }
        }.padding(20)
    }
}

This is what I have currently with CADisplayLink, I added the coordinates to make a circle and that’s about it & it doesn’t repeat forever like the gif does:

Here is the CADisplayLink + real-time coordinate version that I’ve tackled and got lost:

struct Point: View {
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 35, height: 35, alignment: .center)
    }
}
struct ContentView: View {
    @StateObject var P: Position = Position()
    var body: some View {
        VStack {
            ZStack {
                Circle()
                    .stroke()
                    .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width, alignment: .center)
                Point()
                    .position(x: P.realtimePosition.x, y: P.realtimePosition.y)
            }
            Text("X: \(P.realtimePosition.x), Y: \(P.realtimePosition.y)")
        }.onAppear() {
            P.startMovement()
        }
    }
}

class Position: ObservableObject, Equatable {
    struct AnimationInfo {
        let startDate: Date
        let duration: TimeInterval
        let startPoint: CGPoint
        let endPoint: CGPoint
        
        func point(at date: Date) -> (point: CGPoint, finished: Bool) {
            let progress = CGFloat(max(0, min(1, date.timeIntervalSince(startDate) / duration)))
            return (
                point: CGPoint(
                    x: startPoint.x + (endPoint.x - startPoint.x) * progress,
                    y: startPoint.y + (endPoint.y - startPoint.y) * progress
                ),
                finished: progress == 1
            )
        }
    }
    
    @Published var realtimePosition = CGPoint.zero
    
    private var mainTimer: Timer = Timer()
    private var executedTimes: Int = 0
    private lazy var displayLink: CADisplayLink = {
        let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkAction))
        displayLink.add(to: .main, forMode: .default)
        return displayLink
    }()
    private let animationDuration: TimeInterval = 0.1
    private var animationInfo: AnimationInfo?
    
    private var coordinatesPoints: [CGPoint] {
        let screenWidth = UIScreen.main.bounds.width
        let screenHeight = UIScreen.main.bounds.height
        // great progress haha
        let radius: Double = Double(screenWidth / 2)
        let center = CGPoint(x: screenWidth / 2, y: screenHeight / 2)
        var coordinates: [CGPoint] = []
        for i in stride(from: 1, to: 360, by: 10) {
            let radians = Double(i) * Double.pi / 180 // raiments = degrees * pI / 180
            let x = Double(center.x) + radius * cos(radians)
            let y = Double(center.y) + radius * sin(radians)
            coordinates.append(CGPoint(x: x, y: y))
        }
        return coordinates
    }
    
    // Conform to Equatable protocol
    static func ==(lhs: Position, rhs: Position) -> Bool {
        // not sure why would you need Equatable for an observable object?
        // this is not how it determines changes to update the view
        if lhs.realtimePosition == rhs.realtimePosition {
            return true
        }
        return false
    }
    
    func startMovement() {
        mainTimer = Timer.scheduledTimer(
            timeInterval: 0.1,
            target: self,
            selector: #selector(movePoint),
            userInfo: nil,
            repeats: true
        )
    }
    
    @objc func movePoint() {
        if (executedTimes == coordinatesPoints.count) {
            mainTimer.invalidate()
            return
        }
        animationInfo = AnimationInfo(
            startDate: Date(),
            duration: animationDuration,
            startPoint: realtimePosition,
            endPoint: coordinatesPoints[executedTimes]
        )
        displayLink.isPaused = false
        executedTimes += 1
    }
    
    @objc func displayLinkAction() {
        guard
            let (point, finished) = animationInfo?.point(at: Date())
        else {
            displayLink.isPaused = true
            return
        }
        realtimePosition = point
        if finished {
            displayLink.isPaused = true
            animationInfo = nil
        }
    }
}

Solution

  • Inside Position you're calculating position related to whole screen. But .position modifier requires value related to the parent view size.

    You need to make your calculations based on the parent size, you can use such sizeReader for this purpose:

    extension View {
        func sizeReader(_ block: @escaping (CGSize) -> Void) -> some View {
            background(
                GeometryReader { geometry in
                    Color.clear
                        .onAppear {
                            block(geometry.size)
                        }
                        .onChange(of: geometry.size, perform: block)
                }
            )
        }
    }
    

    Usage:

    ZStack {
        Circle()
            .stroke()
            .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width)
        Point()
            .position(x: P.realtimePosition.x, y: P.realtimePosition.y)
    }
    .sizeReader { size in
        P.containerSize = size
    }
    

    Also CADisplayLink is not used in the right way. The whole point of this tool is that it's already called on each frame, so you can calculate real time position, so your animation is gonna be really smooth, and you don't need a timer or pre-calculated values for only 180(or any other number) positions.

    In the linked answer timer was used because a delay was needed between animations, but in your case the code can be greatly simplified:

    class Position: ObservableObject {
        @Published var realtimePosition = CGPoint.zero
        var containerSize: CGSize?
    
        private lazy var displayLink: CADisplayLink = {
            let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkAction))
            displayLink.add(to: .main, forMode: .default)
            displayLink.isPaused = true
            return displayLink
        }()
    
        private var startDate: Date?
    
        func startMovement() {
            startDate = Date()
            displayLink.isPaused = false
        }
    
        let animationDuration: TimeInterval = 5
    
        @objc func displayLinkAction() {
            guard
                let containerSize = containerSize,
                let timePassed = startDate?.timeIntervalSinceNow,
                case let progress = -timePassed / animationDuration,
                progress <= 1
            else {
                displayLink.isPaused = true
                startDate = nil
                return
            }
            let frame = CGRect(origin: .zero, size: containerSize)
            let radius = frame.midX
    
            let radians = CGFloat(progress) * 2 * .pi
    
            realtimePosition = CGPoint(
                x: frame.midX + radius * cos(radians),
                y: frame.midY + radius * sin(radians)
            )
        }
    }