Swift: Pause an animation that is moving along a path? - ios

I have an animation of a bubble, like so:
func bubblePoint(_ value: CGFloat, midX: CGFloat) -> CGPoint {
let startY: CGFloat = UIScreen.main.bounds.height
let endY: CGFloat = -100
let rangeX: CGFloat = UIScreen.main.bounds.width * 0.1
let y = startY + (endY - startY) * value
let x = sin(value * 4 * .pi) * rangeX * (0.1 + value * 0.9) + midX * UIScreen.main.bounds.width
let point = CGPoint(x: x, y: y)
return point
}
func bubblePath(midX: CGFloat) -> UIBezierPath {
let path = UIBezierPath()
path.move(to: bubblePoint(0, midX: midX))
for value in stride(from: CGFloat(0.01), through: 1, by: 0.01) {
path.addLine(to: bubblePoint(value, midX: midX))
}
return path
}
func createAnimation(midX: CGFloat, duration: CFTimeInterval) -> CAKeyframeAnimation {
let animation = CAKeyframeAnimation(keyPath: "position")
animation.path = bubblePath(midX: midX).cgPath
animation.duration = duration
animation.repeatCount = Float.infinity
return animation
}
func createBubble(midX: CGFloat, duration: CFTimeInterval) -> (UIImageView, CAKeyframeAnimation) {
return (
view: UIImageView().then {
$0.image = image
},
animation: createAnimation(midX: midX, duration: duration)
)
}
let bubbles = createBubble(midX: 100, duration: 11, )
bubble.layer.add(animation, forKey: nil)
I want to pause the bubble when the user goes to another screen (and then resume the animation when the user comes back). I have looked into a solution like this, but I would have no idea how to do this with an animation that uses a path like mine. Is it practical to do this?

There are two main ways to pause (freeze) an animation. One is to set the layer speed to zero. The other is to wrap the animation in a UIViewPropertyAnimator and pause the animator (you can do this even with a keyframe animation).
Notice, however, that when "user goes to another screen" the animation may be removed entirely. You may thus need to store info about where in the animation we were and start from there when your view controller comes back on screen.

Related

iOS - Draw a view with gradient background

I have attached a rough sketch. The lines are deformed as the sketch was drawn manually just to illustrate the concept.
As seen in the sketch, I have a list of points that has to be drawn on the view automatically (it is irregular shape), clockwise with some delay (0.1 seconds) to see the progress visually.
Sketch would illustrate 70% approximate completion of the shape.
As the view draws, I have to maintain the background gradient. As seen in the sketch, Start point and the Current point are never connected directly. The colour must be filled only between Start point -> Centre point -> Current point.
Coming to the gradient part, there are two colours. Turquoise colour concentrated at the centre and the colour gets lighter to white as it moves away from the centre point.
How would I implement this in iOS? I am able to draw the black lines in the shape, but, I am unable to fill the colour. And gradient, I have no idea at all.
To begin with a path needs to be generated. You probably already have this but you have not provided any code for it although you mentioned "I am able to draw the black lines in the shape". So to begin with the code...
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { $1.radius > $0.radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: $0) }
path.close()
return path
}
Here points are expected to be around 0.0. They are distributed so that they try to fill maximum space depending on given frame and they are centered on it. Nothing special, just basic math.
After a path is generated you may either use shape-layer approach or draw-rect approach. I will use the draw-rect:
You may subclass an UIView and override a method func draw(_ rect: CGRect). This method will be called whenever a view needs a display and you should NEVER call this method directly. So in order to redraw the view you simply call setNeedsDisplay on the view. Starting with code:
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
}
Nothing very special. The context is grabbed for drawing the gradient and for clipping (later). Other than that the path is created using the previous method and then passed to two rendering methods.
Starting with the line things get very simple:
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
there should most likely be a property for color but I just hardcoded it.
As for gradient things do get a bit more scary:
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { $0.cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
When drawing a radial gradient you need to clip it with your path. This is done by calling path.addClip() which uses a "fill" approach on your path and applies it to current context. This means that everything you draw after this call will be clipped to this path and outside of it nothing will be drawn. But you DO want to draw outside of it later (the line does) and you need to reset the clip. This is done by saving and restoring state on your current context calling saveGState and restoreGState. These calls are push-pop so for every "save" there should be a "restore". And you can nest this procedure (as it will be done when applying a progress).
Using just this code you should already be able to draw your full shape (as in with 100% progress). To give my test example I use it all in code like this:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
}
}
Adding a progress now only needs additional clipping. We would like to draw only within a certain angle. This should be straight forward by now:
Another property is added to the view:
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
I use progress as value between 0 and 1 where 0 is 0% progress and 1 is 100% progress.
Then to create a clipping path:
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
This is simply a path from center and creating an arc from zero angle to progress angle.
Now to apply this additional clipping:
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
We really just want to apply clipping when progress is not full. And we want to discard all drawing when progress is at zero since everything would be clipped.
You can see that the start angle of the shape is toward right instead of facing upward. Let's apply some transformation to fix that:
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
At this point the new view is capable of drawing and redrawing itself. You are free to use this in storyboard, you can add inspectables and make it designable if you will. As for the animation you are now only looking to animate a simple float value and assign it to progress. There are many ways to do that and I will do the laziest, which is using a timer:
#objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
This is pretty much it. A full code that I used to play around with this:
class ViewController: UIViewController {
private var progressView: GradientProgressView?
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
self.progressView = progressView
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateProgress)))
}
#objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
}
private extension ViewController {
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { $0.cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { $1.radius > $0.radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: $0) }
path.close()
return path
}
}
}
Matic wrote a War and Peace length answer to your question using drawRect to create the animation you are after. While impressive, I don't recommend that approach. When you implement drawRect, you do all the drawing on a single core, using the main processor on the main thread. You don't take advantage of the hardware-accelerated rendering in iOS at all.
Instead, I would suggest using Core Animation and CALayers.
It looks to me like your animation is a classic "clock wipe" animation, where you animate your image into view as if you are painting it with the sweep second hand of a clock.
I created just such an animation quite a few years ago in Objective-C, and have since updated it to Swift. See this link for a description and a link to a Github project. The effect looks like this:
You'd then need to create the image you are after and install it as the contents of a view, and then use the clock wipe animation code to reveal it. I didn't look too closely at Matic's answer, but it appears to explain how to draw an image that looks like yours.

Animate UIView to "squiggle" its way to destination instead of going in straight line

I have an animation like this:
bubble.frame.origin = CGPoint(x: 75, y: -120)
UIView.animate(
withDuration: 2.5,
animations: {
self.bubble.transform = CGAffineTransform(translationX: 0, y: self.view.frame.height * -1.3)
}
)
However, the animation goes in a straight line. I want the animation to do a little back and forth action on its way to its destination. Like a bubble. Any ideas?
If you want to animate along a path you can use CAKeyframeAnimation. The only question is what sort of path do you want. A dampened sine curve might be sufficient:
func animate() {
let box = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
box.backgroundColor = .blue
box.center = bubblePoint(1)
view.addSubview(box)
let animation = CAKeyframeAnimation(keyPath: "position")
animation.path = bubblePath().cgPath
animation.duration = 5
box.layer.add(animation, forKey: nil)
}
where
private func bubblePath() -> UIBezierPath {
let path = UIBezierPath()
path.move(to: bubblePoint(0))
for value in 1...100 {
path.addLine(to: bubblePoint(CGFloat(value) / 100))
}
return path
}
/// Point on curve at moment in time.
///
/// - Parameter time: A value between 0 and 1.
/// - Returns: The corresponding `CGPoint`.
private func bubblePoint(_ time: CGFloat) -> CGPoint {
let startY = view.bounds.maxY - 100
let endY = view.bounds.minY + 100
let rangeX = min(30, view.bounds.width * 0.4)
let midX = view.bounds.midX
let y = startY + (endY - startY) * time
let x = sin(time * 4 * .pi) * rangeX * (0.1 + time * 0.9) + midX
let point = CGPoint(x: x, y: y)
return point
}
Yielding:

Apple Workout Rings SpriteKit Animation

I am trying to re-create the Apple Workout Rings in my WatchOS App. I am making use of SpriteKit and GameScene for the animation. However, I am not able to understand how to implement the overlapping rings and include a gradient.
Workout Rings
I tried using SKShader in order to incorporate the gradient effect. However, SKShapeNode ignores the line cap when SKShader is present so I'm not able to get the rounded edges.
I have also looked at other approaches like : Circle Progress View like activity app
However, I don't know how to use this approach for the watchOS as SpriteKit works on the concept of nodes and this approach deals with CGContext.
class GameScene: SKScene {
func circle(radius:CGFloat, percent:CGFloat) -> CGPath {
let start:CGFloat = 0
let end = ((CGFloat.pi * 2)) * percent
let center = CGPoint.zero
let corePath = CGMutablePath()
corePath.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: true)
return corePath
}
// Animated Timer for Progress Circle
func countdownCircle(circle:SKShapeNode, steps:Int, duration:TimeInterval, completion:#escaping ()->Void) {
guard let path = circle.path else {
return
}
let radius = path.boundingBox.width/2
let timeInterval = duration/TimeInterval(steps)
let increment = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.run {
percent -= increment
circle.path = self.circle(radius: radius, percent:percent)
}
let wait = SKAction.wait(forDuration:timeInterval)
let action = SKAction.sequence([wait, animate])
run(SKAction.repeatForever(action)) {
self.run(SKAction.wait(forDuration:timeInterval/2)) {
circle.path = nil
completion()
}
//(action,count:steps-1)
}
}
// Animated Timer for Shadow Circle
func countdownShadow(circle:SKShapeNode, steps:Int, duration:TimeInterval, completion:#escaping ()->Void) {
guard let path = circle.path else {
return
}
let radius = path.boundingBox.width/2
let timeInterval = duration/TimeInterval(steps)
let increment = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.run {
percent -= increment
circle.path = self.circle(radius: radius, percent:percent)
}
let wait = SKAction.wait(forDuration:timeInterval)
let action = SKAction.sequence([wait, animate])
run(SKAction.repeatForever(action)) {
self.run(SKAction.wait(forDuration:timeInterval)) {
circle.path = nil
completion()
}
}
}
//(action,count:steps-1)
override func sceneDidLoad() {
let pathForCircle = CGMutablePath()
pathForCircle.addArc(center: CGPoint.zero, radius: 100, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true)
// This is the circle that indicates the progress.
let progressCircle = SKShapeNode()
progressCircle.lineCap = .round
progressCircle.position = CGPoint(x: 0, y: 15)
progressCircle.strokeColor = SKColor.green
progressCircle.lineWidth = 20
progressCircle.path = pathForCircle
progressCircle.zPosition = 4
self.addChild(progressCircle)
countdownCircle(circle: progressCircle, steps: 400, duration: 5){
print("Done")
}
// This is the circle that gives the ring the shadow effect.
let shadowCircle = SKShapeNode()
shadowCircle.lineCap = .round
shadowCircle.position = CGPoint(x: 0, y: 15)
shadowCircle.strokeColor = SKColor.black
shadowCircle.glowWidth = 30
shadowCircle.zPosition = 3
shadowCircle.path = pathForCircle
self.addChild(shadowCircle)
countdownShadow(circle: shadowCircle, steps: 400, duration: 5){
print("Done")
}
// This is the bottommost circle.
let bottommostCircle = SKShapeNode()
bottommostCircle.position = CGPoint(x: 0, y: 15)
bottommostCircle.lineCap = .butt
bottommostCircle.strokeColor = SKColor.green
bottommostCircle.alpha = 0.2
bottommostCircle.lineWidth = 20
bottommostCircle.path = pathForCircle
bottommostCircle.zPosition = 0
self.addChild(bottommostCircle)
}
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
}
}
there are plenty of Spritekit implementations of this exact sort on GitHub
Here is one that looks particularly good.
https://github.com/HarshilShah/ActivityRings

Clockwise image rotation animation

I'm having an animation that it supposed to rotate an image constantly. But there are couple issues with it. The velocity is quite odd and despite I've set it to repeat constantly, you can see how it starts, stops and then repeats. Which should not happen. It should be uninterrupted rotating.
Also, the other problem is when the animation stops, the image moves left for some reason.
Here's my code:
func animateLogo()
{
UIView.animate(withDuration: 6.0, delay: 0.0, options: .repeat, animations: {
self.logo.transform = CGAffineTransform(rotationAngle: ((180.0 * CGFloat(Double.pi)) / 180.0))
}, completion: nil)
}
Try this
func rotateView(targetView: UIView, duration: Double = 1.0) {
UIView.animate(withDuration: duration, delay: 0.0, options: .curveLinear, animations: {
targetView.transform = targetView.transform.rotated(by: CGFloat(M_PI))
}) { finished in
self.rotateView(targetView: YOUR_LOGO, duration: duration)
}
}
How to use
self.rotateView(targetView: YOUR_LOGO, duration: duration)
In iOS, the coordinate system is flipped. So you go clockwise as your degree gains. It means that passing 270° will give you an angle, equivalent to 90° in the standard coordinate system. Keep that in mind and provide needed angle accordingly.
Consider the following approach.
1) Handy extension for angle
postfix operator °
protocol IntegerInitializable: ExpressibleByIntegerLiteral {
init (_: Int)
}
extension Int: IntegerInitializable {
postfix public static func °(lhs: Int) -> CGFloat {
return CGFloat(lhs) * .pi / 180
}
}
extension CGFloat: IntegerInitializable {
postfix public static func °(lhs: CGFloat) -> CGFloat {
return lhs * .pi / 180
}
}
2) Rotate to any angle with CABasicAnimation:
extension UIView {
func rotateWithAnimation(angle: CGFloat, duration: CGFloat? = nil) {
let pathAnimation = CABasicAnimation(keyPath: "transform.rotation")
pathAnimation.duration = CFTimeInterval(duration ?? 2.0)
pathAnimation.fromValue = 0
pathAnimation.toValue = angle
pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
self.transform = transform.rotated(by: angle)
self.layer.add(pathAnimation, forKey: "transform.rotation")
}
}
Usage:
override func viewDidAppear(_ animated: Bool) {
// clockwise
myView.rotateWithAnimation(angle: 90°)
// counter-clockwise
myView.rotateWithAnimation(angle: -270°)
}
Passing negative value will rotate counter-clockwise.
Angles should be in radians and not degrees. Angle in radians = degrees * pi / 180. So if you want to rotate by 360 degrees you should enter radians = 360 * pi / 180 = 2 * pi = 2 * 3.1415 = 6.283.

How to animate view on circular path with iOS 10 UIViewPropertyAnimator

Is it possible to animate a view in a circular path using the new iOS 10 UIViewPropertyAnimator?
I know that it can be done with CAKeyframeAnimation as per How do I animate a UIView along a circular path?
How can I achieve the same result with the new API?
I ended up with the following. Essentially, calculating the points on circle and using animateKeyFrames to move between them.
let radius = CGFloat(100.0)
let center = CGPoint(x: 150.0, y: 150.0)
let animationDuration: TimeInterval = 3.0
let animator = UIViewPropertyAnimator(duration: animationDuration, curve: .linear)
animator.addAnimations {
UIView.animateKeyframes(withDuration: animationDuration, delay: 0, options: [.calculationModeLinear], animations: {
let points = 1000
let slice = 2 * CGFloat.pi / CGFloat(points)
for i in 0..<points {
let angle = slice * CGFloat(i)
let x = center.x + radius * CGFloat(sin(angle))
let y = center.y + radius * CGFloat(cos(angle))
let duration = 1.0/Double(points)
let startTime = duration * Double(i)
UIView.addKeyframe(withRelativeStartTime: startTime, relativeDuration: duration) {
ninja.center = CGPoint(x: x, y: y)
}
}
})
}
animator.startAnimation()

Resources