'How to move a view/shape along a custom path with swiftUI?

There doesn't seem to be an intuitive way of moving a view/shape along a custom path, particularly a curvy path. I've found several libraries for UIKit that allow views to move on a Bézier Paths (DKChainableAnimationKit,TweenKit,Sica,etc.) but I am not that comfortable using UIKit and kept running into errors.

currently with swiftUI I'm manually doing it like so:

import SwiftUI
struct ContentView: View {
    @State var moveX = true
    @State var moveY = true
    @State var moveX2 = true
    @State var moveY2 = true
    @State var rotate1 = true
    var body: some View {
        ZStack{
            Circle().frame(width:50, height:50)
                .offset(x: moveX ? 0:100, y: moveY ? 0:100)
                .animation(Animation.easeInOut(duration:1).delay(0))
                .rotationEffect(.degrees(rotate1 ? 0:350))
                .offset(x: moveX2 ? 0:-100, y: moveY2 ? 0:-200)
                .animation(Animation.easeInOut(duration:1).delay(1))

                .onAppear(){
                    self.moveX.toggle();
                    self.moveY.toggle();
                    self.moveX2.toggle();
                    self.moveY2.toggle();
                    self.rotate1.toggle();
                    //    self..toggle()
            }
        }
} }

It somewhat gets the job done, but the flexibility is severely limited and compounding delays quickly becomes a mess.

If anyone knows how I could get a custom view/shape to travel along the following path it would be very very much appreciated.

  Path { path in
        path.move(to: CGPoint(x: 200, y: 100))
        path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
        path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
        path.addLine(to: CGPoint(x: 90, y: 600))
    }
    .stroke()

The closest solution I've managed to find was on SwiftUILab but the full tutorial seems to be only available to paid subscribers.

Something like this:
enter image description here



Solution 1:[1]

OK, it is not simple, but I would like to help ...

In the next snippet (macOS application) you can see the basic elements which you can adapt to your needs.

For simplicity I choose simple parametric curve, if you like to use more complex (composite) curve, you have to solve how to map partial t (parameter) for each segment to the composite t for the whole curve (and the same must be done for mapping between partial along-track distance to composite track along-track distance).

Why such a complication?

There is a nonlinear relation between the along-track distance required for aircraft displacement (with constant speed) and curve parameter t on which parametric curve definition depends.

Let see the result first

enter image description here

and next to see how it is implemented. You need to study this code, and if necessary study how parametric curves are defined and behave.

//
//  ContentView.swift
//  tmp086
//
//  Created by Ivo Vacek on 11/03/2020.
//  Copyright © 2020 Ivo Vacek. All rights reserved.
//

import SwiftUI
import Accelerate

protocol ParametricCurve {
    var totalArcLength: CGFloat { get }
    func point(t: CGFloat)->CGPoint
    func derivate(t: CGFloat)->CGVector
    func secondDerivate(t: CGFloat)->CGVector
    func arcLength(t: CGFloat)->CGFloat
    func curvature(t: CGFloat)->CGFloat
}

extension ParametricCurve {
    func arcLength(t: CGFloat)->CGFloat {
        var tmin: CGFloat = .zero
        var tmax: CGFloat = .zero
        if t < .zero {
            tmin = t
        } else {
            tmax = t
        }
        let quadrature = Quadrature(integrator: .qags(maxIntervals: 8), absoluteTolerance: 5.0e-2, relativeTolerance: 1.0e-3)
        let result = quadrature.integrate(over: Double(tmin) ... Double(tmax)) { _t in
            let dp = derivate(t: CGFloat(_t))
            let ds = Double(hypot(dp.dx, dp.dy)) //* x
            return ds
        }
        switch result {
        case .success(let arcLength, _/*, let e*/):
            //print(arcLength, e)
            return t < .zero ? -CGFloat(arcLength) : CGFloat(arcLength)
        case .failure(let error):
            print("integration error:", error.errorDescription)
            return CGFloat.nan
        }
    }
    func curveParameter(arcLength: CGFloat)->CGFloat {
        let maxLength = totalArcLength == .zero ? self.arcLength(t: 1) : totalArcLength
        guard maxLength > 0 else { return 0 }
        var iteration = 0
        var guess: CGFloat = arcLength / maxLength

        let maxIterations = 10
        let maxErr: CGFloat = 0.1

        while (iteration < maxIterations) {
            let err = self.arcLength(t: guess) - arcLength
            if abs(err) < maxErr { break }
            let dp = derivate(t: guess)
            let m = hypot(dp.dx, dp.dy)
            guess -= err / m
            iteration += 1
        }

        return guess
    }
    func curvature(t: CGFloat)->CGFloat {
        /*
                    x'y" - y'x"
        ?(t)  = --------------------
                 (x'² + y'²)^(3/2)
         */
        let dp = derivate(t: t)
        let dp2 = secondDerivate(t: t)
        let dpSize = hypot(dp.dx, dp.dy)
        let denominator = dpSize * dpSize * dpSize
        let nominator = dp.dx * dp2.dy - dp.dy * dp2.dx

        return nominator / denominator
    }
}

struct Bezier3: ParametricCurve {

    let p0: CGPoint
    let p1: CGPoint
    let p2: CGPoint
    let p3: CGPoint

    let A: CGFloat
    let B: CGFloat
    let C: CGFloat
    let D: CGFloat
    let E: CGFloat
    let F: CGFloat
    let G: CGFloat
    let H: CGFloat


    public private(set) var totalArcLength: CGFloat = .zero

    init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) {
        p0 = from
        p1 = control1
        p2 = control2
        p3 = to
        A = to.x - 3 * control2.x + 3 * control1.x - from.x
        B = 3 * control2.x - 6 * control1.x + 3 * from.x
        C = 3 * control1.x - 3 * from.x
        D = from.x
        E = to.y - 3 * control2.y + 3 * control1.y - from.y
        F = 3 * control2.y - 6 * control1.y + 3 * from.y
        G = 3 * control1.y - 3 * from.y
        H = from.y
        // mandatory !!!
        totalArcLength = arcLength(t: 1)
    }

    func point(t: CGFloat)->CGPoint {
        let x = A * t * t * t + B * t * t + C * t + D
        let y = E * t * t * t + F * t * t + G * t + H
        return CGPoint(x: x, y: y)
    }

    func derivate(t: CGFloat)->CGVector {
        let dx = 3 * A * t * t + 2 * B * t + C
        let dy = 3 * E * t * t + 2 * F * t + G
        return CGVector(dx: dx, dy: dy)
    }

    func secondDerivate(t: CGFloat)->CGVector {
        let dx = 6 * A * t + 2 * B
        let dy = 6 * E * t + 2 * F
        return CGVector(dx: dx, dy: dy)
    }

}

class AircraftModel: ObservableObject {
    let track: ParametricCurve
    let path: Path
    var aircraft: some View {
        let t = track.curveParameter(arcLength: alongTrackDistance)
        let p = track.point(t: t)
        let dp = track.derivate(t: t)
        let h = Angle(radians: atan2(Double(dp.dy), Double(dp.dx)))
        return Text("?").font(.largeTitle).rotationEffect(h).position(p)
    }
    @Published var alongTrackDistance = CGFloat.zero
    init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) {
        track = Bezier3(from: from, to: to, control1: control1, control2: control2)
        path = Path({ (path) in
            path.move(to: from)
            path.addCurve(to: to, control1: control1, control2: control2)
        })
    }
}

struct ContentView: View {
    @ObservedObject var aircraft = AircraftModel(from: .init(x: 0, y: 0), to: .init(x: 500, y: 600), control1: .init(x: 600, y: 100), control2: .init(x: -300, y: 400))

    var body: some View {
        VStack {
            ZStack {
                aircraft.path.stroke(style: StrokeStyle( lineWidth: 0.5))
                aircraft.aircraft
            }
            Slider(value: $aircraft.alongTrackDistance, in: (0.0 ... aircraft.track.totalArcLength)) {
                Text("along track distance")
            }.padding()
            Button(action: {
                // fly (to be implemented :-))
            }) {
                Text("Fly!")
            }.padding()
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

If you worry about how to implement "animated" aircraft movement, SwiftUI animation is not the solution. You have to move the aircraft programmatically.

You have to import

import Combine

Add to model

@Published var flying = false
var timer: Cancellable? = nil

func fly() {
    flying = true
    timer = Timer
        .publish(every: 0.02, on: RunLoop.main, in: RunLoop.Mode.default)
        .autoconnect()
        .sink(receiveValue: { (_) in
            self.alongTrackDistance += self.track.totalArcLength / 200.0
            if self.alongTrackDistance > self.track.totalArcLength {
                self.timer?.cancel()
                self.flying = false
            }
        })
}

and modify the button

Button(action: {
    self.aircraft.fly()
}) {
    Text("Fly!")
}.disabled(aircraft.flying)
.padding()

Finally I've got

enter image description here

Solution 2:[2]

try this:

BUT: be careful: this is NOT running in preview, you have to run in on simulator/device

struct MyShape: Shape {

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

        Path { path in
            path.move(to: CGPoint(x: 200, y: 100))
            path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
            path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
            path.addLine(to: CGPoint(x: 90, y: 600))
        }


        return path
    }
}

struct ContentView: View {

    @State var x: CGFloat = 0.0

    var body: some View {
        MyShape()
            .trim(from: 0, to: x)
            .stroke(lineWidth: 10)
            .frame(width: 200, height: 200)
            .onAppear() {
                withAnimation(Animation.easeInOut(duration: 3).delay(0.5)) {
                    self.x = 1
                }
        }
    }
}

enter image description here

Solution 3:[3]

The solution from user3441734 is very general and elegant. The reader will benefit from every second pondering the ParametricCurve and its arc length and curvature. It is the only approach I have found that can re-orient the moving object (the airplane) to point forward while moving.

Asperi has also posted a useful solution in Is it possible to animate view on a certain Path in SwiftUI

Here is a solution that does less, with less. It does use SwiftUI animation, which is a mixed blessing. (E.g. you get more choices for animation curves, but you don't get announcements or callbacks when the animation is done.) It is inspired by Asperi's answer in Problem animating with animatableData in SwiftUI.

import SwiftUI

// Use https://www.desmos.com/calculator/cahqdxeshd to design Beziers.

// Pick a simple example path.
fileprivate let W = UIScreen.main.bounds.width
fileprivate let H = UIScreen.main.bounds.height

fileprivate let p1 = CGPoint(x: 50, y: H - 50)
fileprivate let p2 = CGPoint(x: W - 50, y: 50)

fileprivate var samplePath : Path {
    let c1 = CGPoint(x: p1.x, y: (p1.y + p2.y)/2)
    let c2 = CGPoint(x: p2.x, y: (p1.y + p2.y)/2)

    var result = Path()
    result.move(to: p1)
    result.addCurve(to: p2, control1: c1, control2: c2)
    return result
}

// This View's position follows the Path.
struct SlidingSpot : View {
    let path    : Path
    let start   : CGPoint
    let duration: Double = 1

    @State var isMovingForward = false

    var tMax : CGFloat { isMovingForward ? 1 : 0 }  // Same expressions,
    var opac : Double  { isMovingForward ? 1 : 0 }  // different meanings.

    var body: some View {
        VStack {
            Circle()
            .frame(width: 30)

            // Asperi is correct that this Modifier must be separate. 
            .modifier(Moving(time: tMax, path: path, start: start))

            .animation(.easeInOut(duration: duration), value: tMax)
            .opacity(opac)

            Button {
                isMovingForward = true

                // Sneak back to p1. This is a code smell.
                DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) {
                    isMovingForward = false
                }
            } label: {
                Text("Go")
            }
        }
    }
}

// Minimal modifier. 
struct Moving: AnimatableModifier {
    var time : CGFloat  // Normalized from 0...1.
    let path : Path
    let start: CGPoint  // Could derive from path.

    var animatableData: CGFloat {
        get { time }
        set { time = newValue }
    }

    func body(content: Content) -> some View {
        content
        .position(
            path.trimmedPath(from: 0, to: time).currentPoint ?? start
        )
    }
}

struct ContentView: View {
    var body: some View {
        SlidingSpot(path: samplePath, start: p1)
    }
}

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
Solution 3