Swift: Animate Object Along Animating Path

Swift: Animate object along animating path

Using CoreAnimation to animate the position of the red dot based upon the path assumes that the path isn't changing. You could, theoretically, define a spiral path that mirrors the expanding circle. Personally, I'd just use CADisplayLink, a special timer designed optimally for screen refreshes, and retire the CoreAnimation calls entirely. E.g.

func startDisplayLink() {
let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
displayLink.add(to: .main, forMode: .common)
}

@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
let percent = CGFloat(displayLink.timestamp).truncatingRemainder(dividingBy: duration) / duration
let radius = ...
let center = containerView.bounds.center
circleLayer.path = makeArcPath(arcCenter: center, radius: radius)
let angle = percent * .pi * 2
let dotCenter = CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius)
redDot.path = makeArcPath(arcCenter: dotCenter, radius: 5)
}

That yields:

Sample Image


The full example:

class ViewController: UIViewController {

private let radiusRange: ClosedRange<CGFloat> = 10...100
private let duration: CGFloat = 1.5

private lazy var containerView: UIView = {
let containerView = UIView()
containerView.translatesAutoresizingMaskIntoConstraints = false
return containerView
}()

private lazy var redDot: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillColor = UIColor.red.cgColor
return layer
}()

private lazy var circleLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = 1.5
layer.strokeColor = UIColor.black.cgColor
layer.fillColor = UIColor.clear.cgColor
return layer
}()

private weak var displayLink: CADisplayLink?

override func viewDidLoad() {
super.viewDidLoad()

setup()
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startDisplayLink()
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopDisplayLink()
}
}

// MARK: Private utility methods

private extension ViewController {
func setup() {
addContainer()

containerView.layer.addSublayer(circleLayer)
containerView.layer.addSublayer(redDot)
}

func addContainer() {
view.addSubview(containerView)

NSLayoutConstraint.activate([
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
containerView.topAnchor.constraint(equalTo: view.topAnchor),
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}

func makeArcPath(arcCenter: CGPoint, radius: CGFloat) -> CGPath {
UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true).cgPath
}
}

// MARK: - DisplayLink related methods

private extension ViewController {
func startDisplayLink() {
stopDisplayLink() // stop existing display link, if any

let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
displayLink.add(to: .main, forMode: .common)
self.displayLink = displayLink
}

func stopDisplayLink() {
displayLink?.invalidate()
}

@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
let percent = CGFloat(displayLink.timestamp).truncatingRemainder(dividingBy: duration) / duration
let radius = radiusRange.percent(percent)
let center = containerView.bounds.center
circleLayer.path = makeArcPath(arcCenter: center, radius: radius)
let angle = percent * .pi * 2
let dotCenter = CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius)
redDot.path = makeArcPath(arcCenter: dotCenter, radius: 5)
}
}

// MARK: - CGRect extension

extension CGRect {
var center: CGPoint { return CGPoint(x: midX, y: midY) }
}

// MARK: - ClosedRange extension

extension ClosedRange where Bound: FloatingPoint {
func percent(_ percent: Bound) -> Bound {
(upperBound - lowerBound) * percent + lowerBound
}
}

Animate a UIView along a part of a bezier path

You can accomplish this by modifying the timing of the "complete" animation and a animation group that wraps it.

To illustrate how the timing of such an animation works, imagine – instead of animating a position along a path – that a color is being animated. Then, the complete animation from one color to another can be illustrated as this, where a value further to the right is at a later time — so that the very left is the start value and the very right is the end value:

The complete animation

Note that the "timing" of this animation is linear. This is intentional since the "timing" of the end result will be configured later.

In this animation, imagine that we're looking to animate only the middle third, this part of the animation:

Part of the animation

There are a few steps to animating only this part of the animation.

First, configure the "complete" animation to have linear timing (or in the case of an animation along a path; to have a paced calculation mode) and to have a the "complete" duration.

For example: if you're looking to animate a third of the animation an have that take 1 second, configure the complete animation to take 3 seconds.

let relativeStart = 1.0/3.0
let relativeEnd = 2.0/3.0

let duration = 1.0
let innerDuration = duration / (relativeEnd - relativeStart) // 3 seconds

// configure the "complete" animation
animation.duration = innerDuration

This means that the animation currently is illustrated like this (the full animation):

The full animation

Next, so that the animation "starts" a third of the way into the full animation, we "offset" its time by a third of the duration:

animation.timeOffset = relativeStart * innerDuration

Now the animation is illustrated like this, offset and wrapping from its end value to its start value:

The animation with its time offset

Next, so that we only display part of this animation, we create an animation group with the wanted duration and add only the "complete" animation to it.

let group = CAAnimationGroup()
group.animations = [animation]
group.duration = duration

Even though this group contains an animation that is 3 seconds long it will end after just 1 second, meaning that the 2 seconds of the offset "complete" animation will never be shown.

Now the group animation is illustrated like this, ending after a third of the "complete" animation:

Group animation

If you look closely you'll see that this (the part that isn't faded out) is the middle third of the "complete" animation.

Now that this group animates animates between the wanted values, it (the group) can be configured further and then added to a layer. For example, if you wanted this "partial" animation to reverse, repeat, and have a timing curve you would configure these things on the group:

group.repeatCount = HUGE
group.autoreverses = true
group.timingFunction = CAMediaTimingFunction(name: "easeInEaseOut")

With this additional configuration, the animation group would be illustrated like this:

Part of the animation, auto reversing and repeated


As a more concrete example, this is an animation that I created using this technique (in fact all the code is from that example) that moves a layer back and forth like a pendulum. In this case the "complete" animation was a "position" animation along a path that was a full circle

A layer moving back and forth like a pendulum

How do I animate a UIView along a circular path?

Updated: Update code to Swift 5.6

Use UIBezierPath and CAKeyFrameAnimation.

Here is the code:

let circlePath = UIBezierPath(arcCenter: view.center, radius: 20, startAngle: 0, endAngle: .pi*2, clockwise: true)

let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
animation.path = circlePath.cgPath
animation.duration = 1
animation.repeatCount = .infinity

let squareView = UIView()
// whatever the value of origin for squareView will not affect the animation
squareView.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
squareView.backgroundColor = .lightGray
view.addSubview(squareView)
// you can also pass any unique string value for key
squareView.layer.add(animation, forKey: nil)

// circleLayer is only used to show the circle animation path
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath
circleLayer.strokeColor = UIColor.black.cgColor
circleLayer.fillColor = UIColor.clear.cgColor
view.layer.addSublayer(circleLayer)

Here is the screenshot:

Sample Image

Moreover, you can set the anchorPoint property of squareView.layer to make the view not animate anchored in it's center. The default value of anchorPoint is (0.5, 0.5) which means the center point.

Ios Swift Animate a view in non linear path

You can use keyFrame animation with path

    let keyFrameAnimation = CAKeyframeAnimation(keyPath:"position")
let mutablePath = CGPathCreateMutable()
CGPathMoveToPoint(mutablePath, nil,50,200)
CGPathAddQuadCurveToPoint(mutablePath, nil,150,100, 250, 200)
keyFrameAnimation.path = mutablePath
keyFrameAnimation.duration = 2.0
keyFrameAnimation.fillMode = kCAFillModeForwards
keyFrameAnimation.removedOnCompletion = false
self.label.layer.addAnimation(keyFrameAnimation, forKey: "animation")

Gif

About this function

void CGContextAddQuadCurveToPoint (
CGContextRef _Nullable c,
CGFloat cpx,
CGFloat cpy,
CGFloat x,
CGFloat y
);

(cpx,cpy) is control point,and (x,y) is end point
Sample Image

Swift: Move UIImage partly along path

To avoid jump to its original position add:

animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false

Animate view borders with custom UIBezierPath

The reasons why the animation happens immediately is that the same path is assigned to maskLayer and is used as the toValue of the animation.

let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath // same path used here...

let animation = CABasicAnimation(keyPath: "path")
animation.toValue = path.cgPath // ... as here
animation.duration = 1

maskLayer.add(animation, forKey: "makeRoundedCorners")
self.layer.mask = maskLayer

Since that path is the expected end value of your animation, you need to provide a value to animate from.

Note: the path animation is "undefined"
if the two paths have a different number of control points or segment.

SceneKit animate node along path

I came across that question as well and I wrote a little playground. The animation works well. One thing needs to be done. The distance between every point has to be calculated so that time can be scaled to get a smooth animation. Just copy & paste the code into a playground. The code is in Swift 3.

Here is my solution (the BezierPath extension is not from me, found it here):

import UIKit
import SceneKit
import PlaygroundSupport

let animationDuration = 0.1

public extension UIBezierPath {

var elements: [PathElement] {
var pathElements = [PathElement]()
withUnsafeMutablePointer(to: &pathElements) { elementsPointer in
cgPath.apply(info: elementsPointer) { (userInfo, nextElementPointer) in
let nextElement = PathElement(element: nextElementPointer.pointee)
let elementsPointer = userInfo!.assumingMemoryBound(to: [PathElement].self)
elementsPointer.pointee.append(nextElement)
}
}
return pathElements
}
}

public enum PathElement {

case moveToPoint(CGPoint)
case addLineToPoint(CGPoint)
case addQuadCurveToPoint(CGPoint, CGPoint)
case addCurveToPoint(CGPoint, CGPoint, CGPoint)
case closeSubpath

init(element: CGPathElement) {
switch element.type {
case .moveToPoint: self = .moveToPoint(element.points[0])
case .addLineToPoint: self = .addLineToPoint(element.points[0])
case .addQuadCurveToPoint: self = .addQuadCurveToPoint(element.points[0], element.points[1])
case .addCurveToPoint: self = .addCurveToPoint(element.points[0], element.points[1], element.points[2])
case .closeSubpath: self = .closeSubpath
}
}
}

public extension SCNAction {

class func moveAlong(path: UIBezierPath) -> SCNAction {

let points = path.elements
var actions = [SCNAction]()

for point in points {

switch point {
case .moveToPoint(let a):
let moveAction = SCNAction.move(to: SCNVector3(a.x, a.y, 0), duration: animationDuration)
actions.append(moveAction)
break

case .addCurveToPoint(let a, let b, let c):
let moveAction1 = SCNAction.move(to: SCNVector3(a.x, a.y, 0), duration: animationDuration)
let moveAction2 = SCNAction.move(to: SCNVector3(b.x, b.y, 0), duration: animationDuration)
let moveAction3 = SCNAction.move(to: SCNVector3(c.x, c.y, 0), duration: animationDuration)
actions.append(moveAction1)
actions.append(moveAction2)
actions.append(moveAction3)
break

case .addLineToPoint(let a):
let moveAction = SCNAction.move(to: SCNVector3(a.x, a.y, 0), duration: animationDuration)
actions.append(moveAction)
break

case .addQuadCurveToPoint(let a, let b):
let moveAction1 = SCNAction.move(to: SCNVector3(a.x, a.y, 0), duration: animationDuration)
let moveAction2 = SCNAction.move(to: SCNVector3(b.x, b.y, 0), duration: animationDuration)
actions.append(moveAction1)
actions.append(moveAction2)
break

default:
let moveAction = SCNAction.move(to: SCNVector3(0, 0, 0), duration: animationDuration)
actions.append(moveAction)
break
}
}
return SCNAction.sequence(actions)
}
}

let scnView = SCNView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
scnView.autoenablesDefaultLighting = true

let scene = SCNScene()
scnView.scene = scene

let light = SCNLight()
light.type = .ambient
let lightNode = SCNNode()
lightNode.light = light
scene.rootNode.addChildNode(lightNode)

let camera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = camera
cameraNode.position = SCNVector3(0,0,10)
scene.rootNode.addChildNode(cameraNode)

let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0)
let boxNode = SCNNode(geometry: box)
boxNode.geometry?.firstMaterial?.diffuse.contents = UIColor.red

scene.rootNode.addChildNode(boxNode)

let path1 = UIBezierPath(roundedRect: CGRect(x: 1, y: 1, width: 2, height: 2), cornerRadius: 1)

let moveAction = SCNAction.moveAlong(path: path1)
let repeatAction = SCNAction.repeatForever(moveAction)
SCNTransaction.begin()
SCNTransaction.animationDuration = Double(path1.elements.count) * animationDuration
boxNode.runAction(repeatAction)
SCNTransaction.commit()

PlaygroundPage.current.liveView = scnView


Related Topics



Leave a reply



Submit