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;
Related
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?
In my ios swift app I have SVG file - it's a shape of a map marker, now I want to draw it with animation. I managed to display the drawing thanks to the usage of pocketSVG https://github.com/pocketsvg/PocketSVG/ My code is as follows:
#IBOutlet weak var mainLogoView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
let url = Bundle.main.url(forResource: "mainLogo", withExtension: "svg")!
//initialise a view that parses and renders an SVG file in the bundle:
let svgImageView = SVGImageView.init(contentsOf: url)
//scale the resulting image to fit the frame of the view, but
//maintain its aspect ratio:
svgImageView.contentMode = .scaleAspectFit
//layout the view:
svgImageView.translatesAutoresizingMaskIntoConstraints = false
mainLogoView.addSubview(svgImageView)
svgImageView.topAnchor.constraint(equalTo: mainLogoView.topAnchor).isActive = true
svgImageView.leftAnchor.constraint(equalTo: mainLogoView.leftAnchor).isActive = true
svgImageView.rightAnchor.constraint(equalTo: mainLogoView.rightAnchor).isActive = true
svgImageView.bottomAnchor.constraint(equalTo: mainLogoView.bottomAnchor).isActive = true
Timer.scheduledTimer(timeInterval: TimeInterval(2), target: self, selector: "functionHere", userInfo: nil, repeats: false)
}
func functionHere(){
self.performSegue(withIdentifier: "MainSegue", sender: nil)
}
So basically in my ViewController I added on storyboard a view called mainLogoView and then I'm displaying there the SVG file. Now, since it's a shaped marker, very similar to this: http://image.flaticon.com/icons/svg/116/116395.svg I want to draw it - during the time of 2 seconds I want to draw the shape around the circle.
How can I do it?
=====EDIT:
Following Josh's advice I commented out most of the code in my viewDidLoad and did instead:
let url = Bundle.main.url(forResource: "mainLogo", withExtension: "svg")!
for path in SVGBezierPath.pathsFromSVG(at: url)
{
var layer:CAShapeLayer = CAShapeLayer()
layer.path = path.cgPath
layer.lineWidth = 4
layer.strokeColor = orangeColor.cgColor
layer.fillColor = UIColor.white.cgColor
mainLogoView.addSubview(layer)
}
but now the last line throws an error
Cannot convert value of type 'CAShapeLayer' to expected argument type 'UIView'
Also, after editing my code - could you give me a hint how could I use Josh's method here?
Instead of using the imageView method convert your SVG into a cgPath. Assign this path to a CAShapeLayer and add that shape layer to your view. You can animate the CAShapeLayer's endStroke as follows:
func animateSVG(From startProportion: CGFloat, To endProportion: CGFloat, Duration duration: CFTimeInterval = animationDuration) {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = startProportion
animation.toValue = endProportion
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
svgLayer.strokeEnd = endProportion
svgLayer.strokeStart = startProportion
svgLayer.add(animation, forKey: "animateRing")
}
I did encounter the same problem, this is my solution. svgWidth, svgHeight are width and height of svg tag
let url = Bundle.main.url(forResource: "mainLogo", withExtension: "svg")!
for path in SVGBezierPath.pathsFromSVG(at: url)
{
var layer:CAShapeLayer = CAShapeLayer()
layer.transform = CATransform3DMakeScale(CGFloat(mainLogoView.frame.width/svgWidth), CGFloat(mainLogoView.frame.height/svgHeight), 1)
layer.path = path.cgPath
layer.lineWidth = 4
layer.strokeColor = orangeColor.cgColor
layer.fillColor = UIColor.white.cgColor
layer.transform = CATransform3DMakeScale(2, 2, 1)
mainLogoView.layer.addSublayer(layer)
}
I have a subclass of UIView connected to a XIB file of a UIViewController.
I'm using PocketSVG to convert my SVG file to CGPath like that:
override func awakeFromNib() {
let myPath = PocketSVG.pathFromSVGFileNamed("").takeUnretainedValue()
let myShapeLayer = CAShapeLayer()
myShapeLayer.path = myPath
myShapeLayer.strokeColor = UIColor.redColor().CGColor
myShapeLayer.lineWidth = 3
myShapeLayer.fillColor = UIColor.whiteColor().CGColor
self.layer.addSublayer(myShapeLayer)
}
the problem is that when I run the app I can't see anything, the layer isn't visible.
What am I doing wrong now?
Thank you!
Given what you reported for CGPathGetPathBoundingBox, you may want to transform the path so it falls within the visible portion of the window.
For example, if you want to translate and scale this so it fits the view:
let insetRect = CGRectInset(bounds, lineWidth / 2.0, lineWidth / 2.0)
let boundingRect = CGPathGetPathBoundingBox(myPath)
let scale = min(insetRect.size.height / boundingRect.size.height, insetRect.size.width / boundingRect.size.width)
var transform = CGAffineTransformScale(CGAffineTransformMakeTranslation(-boundingRect.origin.x * scale + insetRect.origin.x, -boundingRect.origin.y * scale + insetRect.origin.y), scale, scale)
let transformedPath = CGPathCreateMutableCopyByTransformingPath(myPath, &transform)
There are lots of permutations on this idea, but hopefully it illustrates the concept.
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")
}
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.