如何使用实时位置坐标为圆周运动的视图设置动画?

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

我目前正在做一个 SwiftUI 项目,为了检测 intersections/collisions,我需要实时坐标,而 SwiftUI 动画无法提供。在做了一些研究之后,我发现了一个关于该主题的精彩 by Kike regarding how to get the real-time coordinates of a view when it is moving/transitioning. And Pylyp Dukhov's ,推荐使用 CADisplayLink 来计算每一帧的位置,并提供了一个可行的解决方案,在过渡时 return 实时值. 但是我对 CADisplayLink 和创建自定义动画非常不熟悉,所以我不确定我是否能够改变它以按照我想要的方式运行。

所以这是我想要使用 CADisplayLink 实现的动画,它使用其位置坐标以圆周运动的方式为橙色圆形视图设置动画并永远重复:

这是 SwiftUI 代码:

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)
    }
}

这就是我目前使用的 CADisplayLink,我添加了坐标以形成一个圆圈,仅此而已,它不会像 gif 那样永远​​重复:

这里是CADisplayLink+我弄丢的实时坐标版本:

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
        }
    }
}

Position 中,您正在计算与整个屏幕相关的位置。但是 .position 修饰符需要与父视图大小相关的值。

您需要根据父级尺寸进行计算,您可以使用 sizeReader 来达到此目的:

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)
            }
        )
    }
}

用法:

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
}

还有CADisplayLink使用不当。这个工具的全部意义在于它已经在每一帧上调用,所以你可以计算实时位置,所以你的动画会非常流畅,你不需要计时器或 pre-calculated 值只有 180 (或任何其他数字)职位。

在链接的答案中使用了计时器,因为动画之间需要延迟,但在您的情况下,代码可以大大简化:

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)
        )
    }
}