Animating along a SwiftUI Path

We can use trigonometry and finite differences to animate rigid objects along a SwiftUI path.

The SwiftUI Path class is missing several useful methods for evaluating properties of a path:

  • finding the position (as a CGPoint) of a given fractional position of the path.
  • finding the heading (as an angle) of a given fractional position of the path.
  • finding the total length of the path, measured in points.

Happily, we can write these methods based on the existing trimmedPath method.

With the aid of these methods it’s possible to create animations that move rigid bodies along arbitrary paths.

import SwiftUI

fileprivate let defaultEpsilon = 1e-7

extension Path {
  
  /// Returns the position for a point on the path with the given
  /// fractional path value between 0 and 1.
  func evaluate(at: CGFloat,
      epsilon: CGFloat = defaultEpsilon,
      closed: Bool = false) -> CGPoint {
    // Make sure a and b don't go outside the bounds 0 ... 1.0
    var a = at
    var b = at + epsilon
    if closed {
      b = b.truncatingRemainder(dividingBy: 1.0)
    } else {
      if b > 1.0 {
        b = 1.0
        a = b - epsilon
      }
    }
    let littlePieceOfPathFromAToB = self.trimmedPath(from: a, to: b)
    let boundsOfLittlePiece = littlePieceOfPathFromAToB.boundingRect
    let positionOfA = boundsOfLittlePiece.origin
    return positionOfA
  }

  /// Returns the tangent angle in radians for a given fractional path value between 0 and 1.
  /// The tangent angle ranges from -π to π.
  /// An angle of 0 means the curve is pointing in the positive X direction.
  /// The angle increases in the clockwise direction.
  func evaluateTangent(at: CGFloat,
     lookAhead: CGFloat = defaultEpsilon,
     closed: Bool = false) -> CGFloat {
    var a = at
    var b = at + lookAhead
    if closed {
      b = b.truncatingRemainder(dividingBy: 1.0)
    } else {
      if b > 1.0 - lookAhead {
        b = 1.0 - lookAhead
        a = b - lookAhead
      }
    }
    let pa = evaluate(at: a)
    let pb = evaluate(at: b)
    return atan2(pb.y - pa.y, pb.x - pa.x)
  }
  
  /// Return the path length in pixels.
  var pathLength : CGFloat {
    let epsilon = 1e-7
    let sampleParameter = 0.0
    let a = sampleParameter
    let b = sampleParameter + epsilon
    let littlePieceOfPathFromAToB = self.trimmedPath(from: a, to: b)
    let boundsOfLittlePiece = littlePieceOfPathFromAToB.boundingRect
    let dx = boundsOfLittlePiece.width
    let dy = boundsOfLittlePiece.height
    let distance = sqrt(dx * dx + dy * dy)
    let pathLengthEstimate = distance / epsilon
    return pathLengthEstimate
  }
  
}

Here’s an example SwiftUI view that animates a short word along an arbitrary path:

func createPath() -> Path {
  var p = Path()
  p.move(to: CGPoint(x: 10, y: 20))
  p.addLine(to: CGPoint(x: 100, y: 20))
  p.addLine(to: CGPoint(x: 100, y: 100))
  p.addCurve(to: CGPoint(x:100, y: 400),
             control1: CGPoint(x:0, y: 200),
             control2: CGPoint(x:200, y: 300))
  p.addLine(to: CGPoint(x:10, y: 300))

  return p
}

struct ContentView: View {
  @State private var startDate = Date()
  private let path = createPath()
  private let animationDuration: TimeInterval = 10
  private let epsilon = 0.0001
    var body: some View {
      TimelineView(.animation(minimumInterval: 1.0 / 120)) { timeline in
        let elapsed = timeline.date.timeIntervalSince(startDate)
        let animationProgress =
            elapsed.truncatingRemainder(dividingBy: animationDuration)
            / animationDuration
        Canvas { context, size in
          context.stroke(path, with:.color(.green))
          let pos = path.evaluate(at:animationProgress)
          // Use a lookAhead to have the car smoothly animate around sharp corners
          let tangentAngle =
              path.evaluateTangent(at: animationProgress, lookAhead:0.01)
          let oldTransform = context.transform
          context.transform = oldTransform
              .translatedBy(x: pos.x, y: pos.y)
              .rotated(by:tangentAngle)
          context.draw(Text("car"), at: CGPoint(x:0, y:-8))
          context.transform = oldTransform
        }
      }
    }
}