Animate only the newest section of a UIBezierPath (for a line graph) - ios

I'm building a simple UIView subclass that plots some data as a line graph. I'd like to animate the data points as they come in, but as a newcomer to CAShapeLayer, I feel there's got to be a better way than what I'm doing. I've read a few articles and put together some code loosely based on this article.
Here's the code. Essentially it redraws the entire path every time new data are available, but only animates the new section (well, approximately).
let animationLayer = CALayer()
func setupDrawingLayer(){
pathLayer.frame = self.animationLayer.bounds
pathLayer.bounds = self.animationLayer.bounds
pathLayer.geometryFlipped = true
pathLayer.strokeColor = UIColor.blackColor().CGColor
pathLayer.fillColor = nil
pathLayer.lineWidth = 3.0
pathLayer.lineJoin = kCALineJoinBevel
self.animationLayer.addSublayer(pathLayer)
}
func nextPoint(){ // This is called by a timer to simulate a new data point
let path = UIBezierPath()
path.moveToPoint(self.dataPoints[0])
for i in 1...pointIndex{
path.addLineToPoint(self.dataPoints[i])
}
pathLayer.path = path.CGPath
pointIndex += 1
let pathAnimation = CABasicAnimation(keyPath:"strokeEnd")
pathAnimation.duration = 0.5
pathAnimation.fromValue = (Double(pointIndex) - 1.0) / Double(pointIndex)
pathAnimation.toValue = 1.0
self.pathLayer.addAnimation(pathAnimation, forKey:"strokeEnd")
}
Also, as my fromValue weighting is just an approximation, it doesn't lead to a smooth animation. Ideally, I would think the approach in nextPoint() should be to animate only the newest section, then "save" it to the layer… but not sure how to approach that. Any pointers would be very welcome.
UPDATE:
This approach still recreates the path with each call of nextPoint() but at least the animation is now accurate:
func nextPoint(){
let oldPath = pathLayer.path
let path = UIBezierPath()
path.moveToPoint(self.dataPoints[0])
for i in 1...pointIndex{
path.addLineToPoint(self.dataPoints[i])
}
pathLayer.path = path.CGPath
pointIndex += 1
let pathAnimation = CABasicAnimation(keyPath:"path")
pathAnimation.duration = 0.5
pathAnimation.fromValue = oldPath
pathAnimation.toValue = path.CGPath
self.pathLayer.addAnimation(pathAnimation, forKey:"path")
}

Related

Why can't I animate the path of a layer's sublayer as part of a CAAnimationGroup, but I can animate other properties in the group?

Thanks to #matt, I learned a while back that it is possible to animate multiple layers' properties in a CAAnimationGroup. The trick is to add name the child layer(s).
See Matt's answer in this thread for a discussion of the technique, plus a working example I wrote to test it out.
You then add the animation group to the parent layer, and in the animations that affect the sublayers of the parent layer, you create those sublayer animations using a keyPath value that references the child layer as a named sublayer of the parent layer.
So if I have CAShapeLayer parentLayer, that also has a sublayer named "sublayer",
let parentLayer = CAShapeLayer()
let sublayer = CAShapeLayer()
sublayer.name = "sublayer"
parentLayer.addSublayer(sublayer)
And I want to create an animation group that animates properties of the parent layer and the sublayer at the same time, that code might look like this:
let animationGroup = CAAnimationGroup()
animationGroup.duration = animationDuration
let parentLayerAnimation = CABasicAnimation(keyPath: "path")
parentLayerAnimation.duration = animationDuration
parentLayerAnimation.fromValue = parentLayer.path
parentLayerAnimation.toValue = newPath
let sublayerAnimation = CABasicAnimation(keyPath: "sublayers.sublayer.path")
sublayerAnimation.duration = animationDuration
sublayerAnimation.fromValue = subLayer.path
sublayerAnimation.toValue = newSublayerPath
let animationGroup = CAAnimationGroup()
animationGroup.duration = animationDuration
animationGroup.animations = [parentLayerAnimation, sublayerAnimation]
parentLayer.add(animationGroup, forKey: nil)
The code above does not work.
The problem appears to be specifically with trying to animate the path of the sublayer. If I instead animate the sublayer's rotation, using a code like this:
let sublayerRotationAnimation = CABasicAnimation(keyPath: "sublayers.sublayer.transform.rotation.z")
sublayerRotationAnimation.duration = animationDuration
sublayerRotationAnimation.fromValue = 0
sublayerRotationAnimation.toValue = CGFloat.pi
And add the sublayerRotationAnimation to the animation group, that works.
The problem seems to occur if I try to animate the sublayer's path. Animating other properties works.
(And if I change the key path of the sublayer's animation to just "path" and add the animation directly to the sublayer, that works too.)
The effect I am after is this:
(The black caret that animates into an arc is the parent layer, and the red lines with the dots are the sublayer bezierIllustrationLayer.)
I have a branch of a working project on Github that illustrates this problem:
Github project branch with animation group that doesn't work
The main branch of the project simply submits separate animations to the layer and the sublayer, and that works.
The code in that branch that attempts to add an animation group is in the file CaretToArcView.swift:
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.duration = animationDuration
pathAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
pathAnimation.fromValue = layer.path
pathAnimation.toValue = path.cgPath
// This animation does not work when added to an animation group applied to the parent layer of the bezierIllustrationLayer
let bezierPathAnimation = CABasicAnimation(keyPath: "sublayers.bezierillustrationlayer.path")
bezierPathAnimation.duration = animationDuration
bezierPathAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
bezierPathAnimation.fromValue = bezierIllustrationLayer.path
bezierPathAnimation.toValue = illustrationPath.cgPath
//If I add this rotation animation to the animation group, it works
//let rotationAnimation = CABasicAnimation(keyPath: "sublayers.bezierillustrationlayer.transform.rotation.z")
//rotationAnimation.duration = 1.0
//rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
//rotationAnimation.fromValue = 0
//rotationAnimation.toValue = CGFloat.pi * 2
let animationGroup = CAAnimationGroup()
animationGroup.duration = animationDuration
animationGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
animationGroup.animations = [pathAnimation, bezierPathAnimation]
layer.add(animationGroup, forKey: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
layer.path = path.cgPath
self.bezierIllustrationLayer.path = illustrationPath.cgPath
}
As far as I can tell, this is a bug in CAAnimationGroup. The same animation works if I apply it directly to the sublayer (after changing the keyPath appropriately). An animation of a different property like the sublayer's transform.rotation.z works in an animation group. It's just animating the sublayer's path that fails.
Has anybody else encountered this, and do you have a solution? Matt? I think you're the reigning expert on CAAnimation here. Can you help?

How to parse SVG path component to UIBezierPath using SVGKit in iOS?

I am animating a SVG image in iOS using Swift. I have been able to render the SVG easily using SVGKit (https://github.com/SVGKit/SVGKit) but to animate it I need to convert the SVG path element to UIBezierPath. I can do so using other libraries but it'd be nice if I could do all of it using SVGKit alone. I haven't find any straight forward way to get the path element as well.
I had the same issues with Swift and using SVGKit. Even after following this simple tutorial and converting it to swift, I could render the SVG but not animate the line drawing. What worked for me was switching to PocketSVG
They have a function to iterate over each Layer and this is how I used it to animate a SVG file:
let url = NSBundle.mainBundle().URLForResource("tiger", withExtension: "svg")!
let paths = SVGBezierPath.pathsFromSVGAtURL(url)
for path in paths {
// Create a layer for each path
let layer = CAShapeLayer()
layer.path = path.CGPath
// Default Settings
var strokeWidth = CGFloat(4.0)
var strokeColor = UIColor.blackColor().CGColor
var fillColor = UIColor.whiteColor().CGColor
// Inspect the SVG Path Attributes
print("path.svgAttributes = \(path.svgAttributes)")
if let strokeValue = path.svgAttributes["stroke-width"] {
if let strokeN = NSNumberFormatter().numberFromString(strokeValue as! String) {
strokeWidth = CGFloat(strokeN)
}
}
if let strokeValue = path.svgAttributes["stroke"] {
strokeColor = strokeValue as! CGColor
}
if let fillColorVal = path.svgAttributes["fill"] {
fillColor = fillColorVal as! CGColor
}
// Set its display properties
layer.lineWidth = strokeWidth
layer.strokeColor = strokeColor
layer.fillColor = fillColor
// Add it to the layer hierarchy
self.view.layer.addSublayer(layer)
// Simple Animation
let animation = CABasicAnimation(keyPath:"strokeEnd")
animation.duration = 4.0
animation.fromValue = 0.0
animation.toValue = 1.0
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
layer.addAnimation(animation, forKey: "strokeEndAnimation")
The path is available by calling .path on Apple's classes. I suggest you read the Apple CoreAnimation documentation, especially CAShapeLayer (which is used heavily by SVGKit).
Apple also provides code for converting to UIBezierPath - again, search the Apple docs for details on how to do this. e.g.:
(UIBezierPath *)bezierPathWithCGPath:(CGPathRef)CGPath;

Animating on some part of UIBezier path

I have two UIBezierPath...
First path shows the total path from to and fro destination and the second path is a copy of the first path but that copy should be a percentage of the first path which I am unable to do.
Basically I would like the plane to stop at the part where green UIBezier path ends and not go until the past green color.
I am attaching a video in hte link that will show the animation I am trying to get. http://cl.ly/302I3O2f0S3Y
Also a similar question asked is Move CALayer via UIBezierPath
Here is the relevant code
override func viewDidLoad() {
super.viewDidLoad()
let endPoint = CGPointMake(fullFlightLine.frame.origin.x + fullFlightLine.frame.size.width, fullFlightLine.frame.origin.y + 100)
self.layer = CALayer()
self.layer.contents = UIImage(named: "Plane")?.CGImage
self.layer.frame = CGRectMake(fullFlightLine.frame.origin.x - 10, fullFlightLine.frame.origin.y + 10, 120, 120)
self.path = UIBezierPath()
self.path.moveToPoint(CGPointMake(fullFlightLine.frame.origin.x, fullFlightLine.frame.origin.y + fullFlightLine.frame.size.height/4))
self.path.addQuadCurveToPoint(endPoint, controlPoint:CGPointMake(self.view.bounds.size.width/2, -200))
self.animationPath = self.path.copy() as! UIBezierPath
let w = (viewModel?.completePercentage) as CGFloat!
// let animationRectangle = UIBezierPath(rect: CGRectMake(fullFlightLine.frame.origin.x-20, fullFlightLine.frame.origin.y-270, fullFlightLine.frame.size.width*w, fullFlightLine.frame.size.height-20))
// let currentContext = UIGraphicsGetCurrentContext()
// CGContextSaveGState(currentContext)
// self.animationPath.appendPath(animationRectangle)
// self.animationPath.addClip()
// CGContextRestoreGState(currentContext)
self.shapeLayer = CAShapeLayer()
self.shapeLayer.path = path.CGPath
self.shapeLayer.strokeColor = UIColor.redColor().CGColor
self.shapeLayer.fillColor = UIColor.clearColor().CGColor
self.shapeLayer.lineWidth = 10
self.animationLayer = CAShapeLayer()
self.animationLayer.path = animationPath.CGPath
self.animationLayer.strokeColor = UIColor.greenColor().CGColor
self.animationLayer.strokeEnd = w
self.animationLayer.fillColor = UIColor.clearColor().CGColor
self.animationLayer.lineWidth = 3
fullFlightLine.layer.addSublayer(shapeLayer)
fullFlightLine.layer.addSublayer(animationLayer)
fullFlightLine.layer.addSublayer(self.layer)
}
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
updateAnimationForPath(self.animationLayer)
}
func updateAnimationForPath(pathLayer : CAShapeLayer) {
let animation : CAKeyframeAnimation = CAKeyframeAnimation(keyPath: "position")
animation.path = pathLayer.path
animation.calculationMode = kCAAnimationPaced
animation.delegate = self
animation.duration = 3.0
self.layer.addAnimation(animation, forKey: "bezierPathAnimation")
}
}
extension Int {
var degreesToRadians : CGFloat {
return CGFloat(self) * CGFloat(M_PI) / 180.0
}
}
Your animation for traveling a path is exactly right. The only problem now is that you are setting the key path animation's path to the whole bezier path:
animation.path = pathLayer.path
If you want the animation to cover only part of that path, you have two choices:
Supply a shorter version of the bezier path, instead of pathLayer.path.
Alternatively, wrap the whole animation in a CAAnimationGroup with a shorter duration. For example, if your three-second animation is wrapped inside a two-second animation group, it will stop two-thirds of the way through.

Animate UIImageView Diagonally

I'd like to be able to animate my UIImageView background in a diagonal fashion. Meaning the background would animate and scroll from the top left of the screen towards the bottom right of the screen. Ideally, this would be a continuous animation.
I have the directions figured out, however, what I have seems to take a copy of the background & animate it over a static background. This causes for a weird effect.
See gif:
Here is the code:
var imageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
self.setup();
}
func setup() {
self.imageView.frame = self.view.bounds
self.imageView.image = UIImage(named: "myimage.jpg")
self.view.addSubview(self.imageView)
self.scroll()
}
func scroll() {
var theImage = UIImage(named: "myimage.jpg")!
var pattern = UIColor(patternImage: theImage)
var layer = CALayer()
layer.backgroundColor = pattern.CGColor
layer.transform = CATransform3DMakeScale(1,-1,1)
layer.anchorPoint = CGPointMake(0, 1)
var viewSize = self.imageView.bounds.size
layer.frame = CGRectMake(0,0, theImage.size.width + viewSize.width, viewSize.height)
self.imageView.layer.addSublayer(layer)
var startPoint = CGPointZero
var endPoint = CGPointMake(theImage.size.width, theImage.size.height + 15)
var animation = CABasicAnimation(keyPath: "position")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.fromValue = NSValue(CGPoint:startPoint)
animation.toValue = NSValue(CGPoint:endPoint)
animation.repeatCount = Float.infinity
animation.duration = 1.0
layer.addAnimation(animation, forKey: "position")
}
Is anyone able to figure out how to make the whole background scroll? Do i just need a larger image, and scroll that? If that is the case, what does the contentMode of the imageView need to be set at?
Thanks
---- Update
I've reworked the code a bit, and have a working version of what I wanted. I'm curious for some feedback, as I'm still not 100% comfortable with animations in general (but i'm learning! :) )
-- I removed the whole background image in general, as DuncanC suggested it wasn't serving a good purpose. Now, i'm just making an image layer, trying to make if sufficiently big, and looping the animation. Thoughts?
override func viewDidLoad() {
super.viewDidLoad()
self.scroll()
}
func scroll() {
var theImage = UIImage(named: "myimage.jpg")!
var pattern = UIColor(patternImage: theImage)
var layer = CALayer()
layer.backgroundColor = pattern.CGColor
layer.transform = CATransform3DMakeScale(1,-1,1)
layer.anchorPoint = CGPointMake(0, 1)
// i'm making the view large here so the animation can keep running smoothly without
// showing what is behind our image
// is there a better way to do this?
var viewSize = CGSizeMake(self.view.bounds.size.height * 10, self.view.bounds.size.width * 10)
layer.frame = CGRectMake(0,0, theImage.size.width + viewSize.width, viewSize.height)
self.view.layer.addSublayer(layer)
var startPoint = CGPointMake(-theImage.size.width, -theImage.size.height)
var endPoint = CGPointMake(0, 0)
var animation = CABasicAnimation(keyPath: "position")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.fromValue = NSValue(CGPoint:startPoint)
animation.toValue = NSValue(CGPoint:endPoint)
animation.repeatCount = Float.infinity
animation.duration = 3.0
layer.addAnimation(animation, forKey: "position")
}
**Result:*
Your code doesn't make a lot of sense. You have an image view that contains the image myimage.jpg, and then you create a CALayer that contains that image as a pattern color. You add the layer to your image view and animate it's position.
Just use a UIView animation and animate the center of your image view directly. If you're using AutoLayout then you'll need to add horizontal and vertical position constraints, wire them up as IBOutlets, and then in your animation block change the constraints and call layoutIfNeeded() on the image view's superview.

CAGroupAnimation does not show animations. Animations work fine when added separately

I am implementing a simple activity indicator using Swift and Core Animation. The core animation loop consists of just two animations. When I add them to the layer directly, they work perfectly. When I add them as a CAAnimationGroup, nothing is happening at all. I am utterly confused by this behaviour, and I've already checked all questions on stackoverflow about CAAnimationGroup, all tutorials on the web, and read official docs many times. I can't figure out what's going on. Please help.
Animations:
let anim1 = CABasicAnimation(keyPath: "strokeEnd")
anim1.fromValue = 0
anim1.toValue = 1.0
anim1.duration = 2.0
anim1.beginTime = CACurrentMediaTime()
let anim2 = CABasicAnimation(keyPath:"strokeStart")
anim2.fromValue = 0
anim2.toValue = 1.0
anim2.duration = 2.0
anim2.beginTime = CACurrentMediaTime() + 2.0
This works perfectly as expected:
shapeLayer.addAnimation(anim1, forKey:nil)
shapeLayer.addAnimation(anim2, forKey:nil)
This does nothing whatsoever to my layer:
let group = CAAnimationGroup()
group.animations = [anim1, anim2]
group.duration = 4.0
shapeLayer.addAnimation(group, forKey: nil)
I made a short demo snippet to be used in Playground: https://gist.github.com/anonymous/6021295eab4e00b813ce. Please see for yourself and help me solve this. (In case you're not sure how to use Playground for prototyping animations: http://possiblemobile.com/2015/03/prototyping-uiview-animations-swift-playground/)
#MDB983's answer looks like it would work, but doesn't explain why.
When you add an animation directly to a layer, it's begin time is based on the current media time.
When you add animations to an animation group, their "local time" is the animation group. A begin time of 0 in an animation group is the beginning of the group, and a beginTime of 1.0 is 1 second into the total animation group, etc. When you add animations to an animation group change your begin times to be zero-based, not starting from CACurrentMediaTime().
Try it this way, seemingly CACurrentMediaTime() + 2.0 is causing an issue
import UIKit
import XCPlayground
let view = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
let shapeLayer = CAShapeLayer()
let size:CGFloat = 52
let rect = CGRect(x: view.bounds.midX - size/2, y: view.bounds.midY-size/2, width: size, height: size)
shapeLayer.path = UIBezierPath(ovalInRect: rect).CGPath
shapeLayer.fillColor = UIColor.clearColor().CGColor
shapeLayer.strokeColor = UIColor.redColor().CGColor
shapeLayer.lineWidth = 3
shapeLayer.strokeStart = 0
shapeLayer.strokeEnd = 0
view.layer.addSublayer(shapeLayer)
XCPShowView("view", view)
let beginTime = CACurrentMediaTime()
let anim1 = CABasicAnimation(keyPath: "strokeEnd")
anim1.fromValue = 0
anim1.toValue = 1
anim1.duration = 2
let anim2 = CABasicAnimation(keyPath:"strokeEnd")
anim2.fromValue = 1
anim2.toValue = 0
anim2.duration = 2
anim2.beginTime = 2
let group = CAAnimationGroup()
group.duration = 4.0
group.removedOnCompletion = true
group.animations = [anim1, anim2]
//shapeLayer.addAnimation(anim1, forKey:nil)
//shapeLayer.addAnimation(anim2, forKey:nil)
shapeLayer.addAnimation(group, forKey: nil)
I know this is an old question but I was doing the same with animation group in swift 4. What I missed is specifying the beginTime for the CAAnimationGroup itself (I assumed that the child animations would follow their beginTime). By default, beginTime is 0.0 for CAAnimationGroup
let group = CAAnimationGroup()
group.beginTime = CACurrentMediaTime() + delay
group.duration = 4.0
group.removedOnCompletion = true
group.animations = [anim1, anim2]

Resources